Skip to main content

xlog_runtime/executor/
wcoj_dispatch.rs

1//! WCOJ triangle dispatch runtime hook.
2//!
3//! Wires the GPU WCOJ kernels into the executor's per-rule loop.
4//! Production callers leave `RuntimeConfig::default()` and use
5//! the stats-backed dispatch model.
6//!
7//! Override knobs (config + env, highest precedence first):
8//!
9//!   1. **Force-WCOJ** — `wcoj_triangle_dispatch=Some(true)` /
10//!      [`ENV_USE_WCOJ_TRIANGLE_U32`]. Bypasses stats decision.
11//!   2. **Explicit force-off** —
12//!      `wcoj_triangle_dispatch=Some(false)`. Used by bench
13//!      `Mode::Off` cells and any test that wants binary-join.
14//!   3. **Default**: stats-backed dispatch model.
15//!
16//! ## Recognized RIR Shape
17//!
18//! The hook now consumes [`RirNode::MultiWayJoin`], produced by
19//! [`xlog_logic::promote::promote_multiway`] after the optimizer
20//! pass in [`xlog_logic::Compiler::compile_program_with_stats_snapshot`].
21//! The promoter rewrites the canonical lowered+optimized triangle
22//! tree to a `MultiWayJoin` whose structure encodes the same
23//! semantic invariants as the earlier strict tree-pattern matcher:
24//!
25//! * `inputs` is a 3-element vec of `Scan` nodes in WCOJ slot
26//!   order `[xy, yz, xz]`.
27//! * `slot_vars` is exactly `[[Some(0), Some(1)], [Some(1), Some(2)],
28//!   [Some(0), Some(2)]]` — variable-class ids for X, Y, Z.
29//! * `output_columns` is exactly
30//!   `[Column(0), Column(1), Column(3)]` (matching the certified
31//!   GPU kernel's (X, Y, Z) emit order).
32//! * `fallback` is the post-optimizer binary-join tree, executed
33//!   verbatim when this hook declines.
34//!
35//! Anything else (rotated/computed projection, non-canonical
36//! slot_vars, non-Scan inputs, recursive SCC, missing input
37//! buffers, unsupported scalar types, mixed-width slots, or no
38//! runtime-backed memory manager) returns `Ok(None)` and the
39//! caller takes the embedded `fallback` path.
40//!
41//! Width branching: 4-byte (U32 / Symbol) inputs go to
42//! `wcoj_layout_u32_recorded` + `wcoj_triangle_hg_u32_recorded`;
43//! 8-byte (U64) inputs go to the `_u64_recorded` siblings. All
44//! three slots must share a width.
45//!
46//! ## Failure handling
47//!
48//! Per dispatch contract: "failure in helper must not corrupt store
49//! state." If the WCOJ pipeline (layout construction or kernel
50//! launch) returns an error, the hook converts it to `Ok(None)`
51//! and the caller falls back to the existing path. The store is
52//! never partially mutated; the dispatch hook only writes when the
53//! full pipeline succeeds, and the writeback is the caller's
54//! responsibility.
55//!
56//! ## Hook surface
57//!
58//! The dispatcher exposes two entry points per shape:
59//!
60//! * `try_dispatch_wcoj_*(rule)` — keyed on `&CompiledRule`,
61//!   used by the non-recursive arm in `execute_stratum_impl`.
62//! * `try_dispatch_wcoj_*_on_body(body)` — keyed on `&RirNode`,
63//!   used by the recursive arm via
64//!   `Executor::execute_wcoj_or_fallback_node` on both seeding
65//!   and per-variant evaluation. The promoter gates recursive bodies
66//!   by per-rule recursive-scan count: a single recursive scan can
67//!   promote, while two or more stay on the binary-join path.
68//!
69//! ## Out of Scope
70//!
71//! * Additional cost-model expansion.
72//! * Mixed-width admission (a triangle with both U32 and U64
73//!   slots stays on the binary-join path).
74//! * Multi-recursive WCOJ with two or more in-SCC body scans.
75
76use std::collections::HashSet;
77
78use xlog_core::{RelId, Result, ScalarType, Schema};
79use xlog_cuda::device_runtime::StreamId;
80use xlog_cuda::provider::NESTED_LOOP_TOTAL_THRESHOLD;
81use xlog_cuda::wcoj_metadata::{Wcoj4CycleRootAggValue, WcojRootAggValue};
82use xlog_cuda::CudaBuffer;
83use xlog_cuda::JoinType as CudaJoinType;
84use xlog_ir::{
85    rir::{KCliqueVariableOrder, MultiwayPlan, ProjectExpr, VariableOrder},
86    CompiledRule, RirNode,
87};
88
89use super::Executor;
90
91#[cfg(feature = "wcoj-phase-timing")]
92use std::time::Instant;
93
94/// Env variable controlling the WCOJ triangle dispatch. Treated
95/// as ON when set to `"1"` or case-insensitive `"true"`; anything
96/// else (unset, `"0"`, `"false"`, empty string, …) means OFF.
97pub const ENV_USE_WCOJ_TRIANGLE_U32: &str = "XLOG_USE_WCOJ_TRIANGLE_U32";
98
99/// Resolve the dispatch gate. Config override (set by tests)
100/// takes precedence over the env var. Production callers leave
101/// the override as `None` and configure via env.
102pub(super) fn wcoj_gate_enabled(config_override: Option<bool>) -> bool {
103    if let Some(v) = config_override {
104        return v;
105    }
106    std::env::var(ENV_USE_WCOJ_TRIANGLE_U32)
107        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
108        .unwrap_or(false)
109}
110
111pub const ENV_WCOJ_BLOCK_WORK_UNIT: &str = "XLOG_WCOJ_BLOCK_WORK_UNIT";
112pub(super) const WCOJ_BLOCK_WORK_UNIT_DEFAULT: u32 = 1024;
113pub(super) const WCOJ_BLOCK_WORK_UNIT_MAX: u32 = 8192;
114
115pub(super) fn wcoj_block_work_unit() -> u32 {
116    match std::env::var(ENV_WCOJ_BLOCK_WORK_UNIT) {
117        Ok(raw) => match raw.trim().parse::<u32>() {
118            Ok(v @ 1..=WCOJ_BLOCK_WORK_UNIT_MAX) => v,
119            Ok(v) => {
120                eprintln!(
121                    "warning: {ENV_WCOJ_BLOCK_WORK_UNIT}={v} is outside 1..={WCOJ_BLOCK_WORK_UNIT_MAX}; \
122                     using {WCOJ_BLOCK_WORK_UNIT_DEFAULT}"
123                );
124                WCOJ_BLOCK_WORK_UNIT_DEFAULT
125            }
126            Err(_) => {
127                eprintln!(
128                    "warning: {ENV_WCOJ_BLOCK_WORK_UNIT}={raw:?} is not a u32; \
129                     using {WCOJ_BLOCK_WORK_UNIT_DEFAULT}"
130                );
131                WCOJ_BLOCK_WORK_UNIT_DEFAULT
132            }
133        },
134        Err(_) => WCOJ_BLOCK_WORK_UNIT_DEFAULT,
135    }
136}
137
138pub(super) fn wcoj_adaptive_enabled(config_override: Option<bool>) -> bool {
139    config_override.unwrap_or(true)
140}
141
142/// Kill switch for the aggregate-fused group-by-root count dispatch.
143/// Default ON (fusion enabled); set to `1`/`true` to force every
144/// GroupBy-over-triangle through the materialize+groupby path.
145pub const ENV_DISABLE_WCOJ_GROUPBY_FUSION: &str = "XLOG_DISABLE_WCOJ_GROUPBY_FUSION";
146
147pub(super) fn wcoj_groupby_fusion_disabled() -> bool {
148    std::env::var(ENV_DISABLE_WCOJ_GROUPBY_FUSION)
149        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
150        .unwrap_or(false)
151}
152
153/// Kill switch for the generalized Free Join dispatch. Default ON
154/// (dispatch enabled); set to `1`/`true` to force every general
155/// multiway body through the embedded binary fallback.
156pub const ENV_DISABLE_FREE_JOIN: &str = "XLOG_DISABLE_FREE_JOIN";
157
158pub(super) fn free_join_disabled() -> bool {
159    std::env::var(ENV_DISABLE_FREE_JOIN)
160        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
161        .unwrap_or(false)
162}
163
164/// Kill switch for the factorized recursive-delta dispatch. Default
165/// ON (dispatch enabled); set to `1`/`true` to force every recursive
166/// delta step through the legacy hash-join -> diff path.
167pub const ENV_DISABLE_FACTORIZED_DELTA: &str = "XLOG_DISABLE_FACTORIZED_DELTA";
168
169pub(super) fn factorized_delta_disabled() -> bool {
170    std::env::var(ENV_DISABLE_FACTORIZED_DELTA)
171        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
172        .unwrap_or(false)
173}
174
175/// Default dense-domain dispatch cap (bitmap 32 MiB + counts 128 MiB).
176/// `XLOG_FACTORIZED_DELTA_MAX_DOMAIN` raises it up to the provider hard
177/// bound `FJ_DELTA_MAX_DOMAIN` (2^16).
178const FACTORIZED_DELTA_DEFAULT_MAX_DOMAIN: u32 = 1 << 14;
179
180fn factorized_delta_max_domain() -> u32 {
181    std::env::var("XLOG_FACTORIZED_DELTA_MAX_DOMAIN")
182        .ok()
183        .and_then(|v| v.parse::<u32>().ok())
184        .unwrap_or(FACTORIZED_DELTA_DEFAULT_MAX_DOMAIN)
185        .min(xlog_cuda::provider::FJ_DELTA_MAX_DOMAIN)
186}
187
188/// Byte ceiling for the sparse route's conservative hash table; over it
189/// the sparse entry declines to the legacy path. Defaults to half the
190/// device budget; `XLOG_FACTORIZED_DELTA_MAX_TABLE_BYTES` overrides it
191/// (tuning + tests forcing the decline boundary).
192fn factorized_delta_max_table_bytes(budget_bytes: u64) -> u64 {
193    std::env::var("XLOG_FACTORIZED_DELTA_MAX_TABLE_BYTES")
194        .ok()
195        .and_then(|v| v.parse::<u64>().ok())
196        .unwrap_or(budget_bytes / 2)
197}
198
199/// Per-iteration work-floor divisor: dispatch only when the estimated
200/// candidate work is at least `n_words / divisor`, protecting sparse /
201/// long-chain fixpoints from the bitmap popcount+scan floor.
202fn factorized_delta_work_divisor() -> u64 {
203    std::env::var("XLOG_FACTORIZED_DELTA_WORK_DIVISOR")
204        .ok()
205        .and_then(|v| v.parse::<u64>().ok())
206        .filter(|&v| v >= 1)
207        .unwrap_or(8)
208}
209
210/// Per-fixpoint dispatch context for the factorized recursive delta.
211/// Owned by one `execute_recursive_scc` call: caches the dense-domain
212/// bound per (head, static rel) — `None` records a for-this-fixpoint
213/// decline — and layout-normalized static buffers for non-recursive
214/// (EDB) static sides.
215#[derive(Default)]
216pub(super) struct FactorizedDeltaCtx {
217    domain_by_key: std::collections::HashMap<(String, RelId), Option<u32>>,
218    static_norm_cache: std::collections::HashMap<(RelId, usize), CudaBuffer>,
219}
220
221/// Diagnostics gate for WCOJ pipeline errors. By default a layout/kernel
222/// error declines to the binary-join fallback (the store is never partially
223/// mutated) but is **counted** (`Executor::wcoj_error_decline_count`) and
224/// logged to stderr, so a regressed kernel cannot silently disappear from
225/// production dispatch behind the silent-fallback contract. Set
226/// `XLOG_WCOJ_STRICT=1` to propagate the error instead (diagnostic mode).
227pub const ENV_WCOJ_STRICT: &str = "XLOG_WCOJ_STRICT";
228
229pub(super) fn wcoj_strict_errors_enabled() -> bool {
230    std::env::var(ENV_WCOJ_STRICT)
231        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
232        .unwrap_or(false)
233}
234
235/// Convert a WCOJ pipeline error into a counted, logged decline
236/// (`Ok(None)` — caller falls back to the binary-join path), or propagate
237/// it when [`ENV_WCOJ_STRICT`] is set. Structural declines (gate off,
238/// shape mismatch, missing buffer) stay silent and do NOT go through here;
239/// this seam is only for real layout/kernel failures.
240pub(super) fn wcoj_decline_on_error(
241    counter: &mut u64,
242    stage: &str,
243    err: xlog_core::XlogError,
244) -> Result<Option<CudaBuffer>> {
245    *counter += 1;
246    if wcoj_strict_errors_enabled() {
247        return Err(err);
248    }
249    eprintln!("warning: WCOJ {stage} pipeline error; declining to binary-join fallback: {err}");
250    Ok(None)
251}
252
253/// Chain dispatcher gate. Default ON after profiler traces showed
254/// chain-shaped rules dominated evaluation time; `XLOG_WCOJ_CHAIN_ENABLE=0`
255/// or `false` disables the route for A/B measurements.
256pub const ENV_WCOJ_CHAIN_ENABLE: &str = "XLOG_WCOJ_CHAIN_ENABLE";
257
258pub(super) fn chain_dispatch_enabled() -> bool {
259    std::env::var(ENV_WCOJ_CHAIN_ENABLE)
260        .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false")))
261        .unwrap_or(true)
262}
263
264// -----------------------------------------------------------------
265// 4-cycle dispatch gates.
266//
267// Width-neutral env naming: `XLOG_USE_WCOJ_4CYCLE` controls the
268// force gate across u32 / u64 / Symbol. Triangle's `_U32` suffix is
269// historical debt; we do NOT propagate that pattern to 4-cycle.
270//
271// Adaptive resolution differs from triangle: 4-cycle is **opt-in by
272// default**. Unset env + `None` config → `false`. Default-on is
273// gated on bench evidence and lives in a separate follow-up decision.
274// -----------------------------------------------------------------
275
276/// Force-gate env. `"1"` / case-insensitive `"true"` → ON.
277pub const ENV_USE_WCOJ_4CYCLE: &str = "XLOG_USE_WCOJ_4CYCLE";
278
279/// Adaptive opt-in env. Default off for explicit-only dispatch.
280pub const ENV_USE_WCOJ_4CYCLE_ADAPTIVE: &str = "XLOG_USE_WCOJ_4CYCLE_ADAPTIVE";
281
282/// Kill switch env.
283pub const ENV_DISABLE_WCOJ_4CYCLE: &str = "XLOG_DISABLE_WCOJ_4CYCLE";
284
285/// Resolve the 4-cycle force gate (config override > env > false).
286pub(super) fn wcoj_4cycle_gate_enabled(config_override: Option<bool>) -> bool {
287    if let Some(v) = config_override {
288        return v;
289    }
290    std::env::var(ENV_USE_WCOJ_4CYCLE)
291        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
292        .unwrap_or(false)
293}
294
295/// Resolve the 4-cycle adaptive opt-in. Precedence:
296///   * `config_override = Some(b)` → `b`.
297///   * `XLOG_USE_WCOJ_4CYCLE_ADAPTIVE=1` → `true`.
298///   * Anything else (including unset) → `false`.
299///
300/// **Differs from triangle**: triangle defaults adaptive to `true`
301/// when env is unset (default-on flip after baseline evidence).
302/// 4-cycle defaults to `false` until its own baseline evidence
303/// supports a default-on flip in a follow-up slice.
304pub(super) fn wcoj_4cycle_adaptive_enabled(config_override: Option<bool>) -> bool {
305    if let Some(v) = config_override {
306        return v;
307    }
308    std::env::var(ENV_USE_WCOJ_4CYCLE_ADAPTIVE)
309        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
310        .unwrap_or(false)
311}
312
313/// Resolve the 4-cycle kill switch (config > env > false).
314pub(super) fn wcoj_4cycle_disabled(config_override: Option<bool>) -> bool {
315    if let Some(v) = config_override {
316        return v;
317    }
318    std::env::var(ENV_DISABLE_WCOJ_4CYCLE)
319        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
320        .unwrap_or(false)
321}
322
323/// Resolved dispatch mode after consulting both gates.
324#[derive(Clone, Copy, Debug, PartialEq, Eq)]
325enum DispatchMode {
326    Force,
327    CostModel,
328}
329
330/// Two rel IDs and key positions extracted from a matched chain RIR.
331/// Inputs are in the promoter's left/right order.
332pub(super) struct ChainRirMatch {
333    pub rel_left: RelId,
334    pub rel_right: RelId,
335    pub left_key: usize,
336    pub right_key: usize,
337    pub output_columns: Vec<ProjectExpr>,
338}
339
340/// ChainJoin production matcher. The chain shape is encoded as a
341/// first-class `ChainJoin`; malformed non-scan inputs decline dispatch
342/// and execute the captured fallback.
343pub(super) fn match_chain_join(body: &RirNode) -> Option<ChainRirMatch> {
344    let RirNode::ChainJoin {
345        left,
346        right,
347        left_key,
348        right_key,
349        output_columns,
350        ..
351    } = body
352    else {
353        return None;
354    };
355    if *left_key >= 2 || *right_key >= 2 {
356        return None;
357    }
358    let rel_left = scan_rel(left)?;
359    let rel_right = scan_rel(right)?;
360    Some(ChainRirMatch {
361        rel_left,
362        rel_right,
363        left_key: *left_key,
364        right_key: *right_key,
365        output_columns: output_columns.clone(),
366    })
367}
368
369fn fallback_scan_filter_equivalents(node: &RirNode) -> (u64, u64) {
370    match node {
371        RirNode::Unit | RirNode::TensorMaskedJoin { .. } => (0, 0),
372        RirNode::Scan { .. } => (1, 0),
373        RirNode::Filter { input, .. } => {
374            let (scans, filters) = fallback_scan_filter_equivalents(input);
375            (scans, filters.saturating_add(1))
376        }
377        RirNode::Project { input, .. }
378        | RirNode::GroupBy { input, .. }
379        | RirNode::Distinct { input, .. } => fallback_scan_filter_equivalents(input),
380        RirNode::Join { left, right, .. } | RirNode::Diff { left, right } => {
381            let left = fallback_scan_filter_equivalents(left);
382            let right = fallback_scan_filter_equivalents(right);
383            (
384                left.0.saturating_add(right.0),
385                left.1.saturating_add(right.1),
386            )
387        }
388        RirNode::ChainJoin { fallback, .. } | RirNode::MultiWayJoin { fallback, .. } => {
389            fallback_scan_filter_equivalents(fallback)
390        }
391        RirNode::Union { inputs } => inputs.iter().fold((0u64, 0u64), |total, input| {
392            let current = fallback_scan_filter_equivalents(input);
393            (
394                total.0.saturating_add(current.0),
395                total.1.saturating_add(current.1),
396            )
397        }),
398        RirNode::Fixpoint {
399            base, recursive, ..
400        } => {
401            let base = fallback_scan_filter_equivalents(base);
402            let recursive = fallback_scan_filter_equivalents(recursive);
403            (
404                base.0.saturating_add(recursive.0),
405                base.1.saturating_add(recursive.1),
406            )
407        }
408    }
409}
410
411fn record_chain_fallback_equivalents(
412    body: &RirNode,
413    installed: bool,
414    scan_equivalents: &mut u64,
415    filter_equivalents: &mut u64,
416) {
417    if !installed {
418        return;
419    }
420    let RirNode::ChainJoin { fallback, .. } = body else {
421        return;
422    };
423    let (scans, filters) = fallback_scan_filter_equivalents(fallback);
424    *scan_equivalents = scan_equivalents.saturating_add(scans);
425    *filter_equivalents = filter_equivalents.saturating_add(filters);
426}
427
428/// Three rel IDs extracted from a matched triangle RIR. The
429/// names correspond to the WCOJ kernel's slot semantics.
430pub(super) struct TriangleRirMatch {
431    /// Rel for the (X, Y) edge — left subtree of the inner join,
432    /// joined with `rel_yz` on Y.
433    pub rel_xy: RelId,
434    /// Rel for the (Y, Z) edge — right subtree of the inner join.
435    pub rel_yz: RelId,
436    /// Rel for the (X, Z) closing edge — right subtree of the
437    /// outer join, joined with the inner join's output on (X, Z).
438    pub rel_xz: RelId,
439}
440
441/// Pattern-match a `RirNode::MultiWayJoin` whose structure is the
442/// canonical triangle shape. Returns the three scan rel IDs in
443/// WCOJ slot order on a successful match; `None` for any deviation.
444///
445/// The match is intentionally strict over `inputs`, `slot_vars`,
446/// AND `output_columns`. The current triangle matcher certifies the
447/// canonical (X, Y, Z) emit order; rotated head projections,
448/// non-Scan inputs, or non-canonical variable classes decline
449/// dispatch and the caller takes the embedded `fallback` path.
450///
451/// Future matcher work must generalize in tandem with kernel
452/// generalization (4-way, n-way) — never one without the other.
453pub(super) fn match_multiway_triangle(body: &RirNode) -> Option<TriangleRirMatch> {
454    let RirNode::MultiWayJoin {
455        inputs,
456        slot_vars,
457        output_columns,
458        ..
459    } = body
460    else {
461        return None;
462    };
463    if inputs.len() != 3 {
464        return None;
465    }
466    if !slot_vars_match_canonical_triangle(slot_vars) {
467        return None;
468    }
469    if !output_columns_match_canonical_triangle(output_columns) {
470        return None;
471    }
472    let rel_xy = scan_rel(&inputs[0])?;
473    let rel_yz = scan_rel(&inputs[1])?;
474    let rel_xz = scan_rel(&inputs[2])?;
475    Some(TriangleRirMatch {
476        rel_xy,
477        rel_yz,
478        rel_xz,
479    })
480}
481
482/// Confirm `slot_vars` is the canonical
483/// `[[A, B], [B, C], [A, C]]` triangle shape with three distinct
484/// variable-class ids. Anything else (rotated, dropped, repeated)
485/// fails the match.
486fn slot_vars_match_canonical_triangle(slot_vars: &[Vec<Option<u32>>]) -> bool {
487    if slot_vars.len() != 3 {
488        return false;
489    }
490    let s0 = &slot_vars[0];
491    let s1 = &slot_vars[1];
492    let s2 = &slot_vars[2];
493    if s0.len() != 2 || s1.len() != 2 || s2.len() != 2 {
494        return false;
495    }
496    let (a, b) = match (s0[0], s0[1]) {
497        (Some(a), Some(b)) if a != b => (a, b),
498        _ => return false,
499    };
500    let c = match (s1[0], s1[1]) {
501        (Some(b1), Some(c)) if b1 == b && c != a && c != b => c,
502        _ => return false,
503    };
504    matches!((s2[0], s2[1]), (Some(a2), Some(c2)) if a2 == a && c2 == c)
505}
506
507/// Confirm `output_columns` is one of the valid head-extraction
508/// layouts. The GPU kernel writes triples in canonical
509/// `(X, Y, Z)` order; the project columns describe the
510/// binary-join-intermediate layout the head extracts from.
511///
512/// Accepted triangle output-column layouts:
513///   * `[Column(0), Column(1), Column(3)]` — Y-shared /
514///     X-shared inner pair (binary intermediate cols
515///     [X, Y, Y, Z, X, Z] / [X, Y, X, Z, Y, Z]).
516///   * `[Column(0), Column(2), Column(3)]` — Z-shared inner
517///     pair (binary intermediate cols [X, Z, Y, Z, X, Y]).
518fn output_columns_match_canonical_triangle(cols: &[ProjectExpr]) -> bool {
519    if cols.len() != 3 {
520        return false;
521    }
522    let cols_pattern = (
523        matches!(cols[0], ProjectExpr::Column(0)),
524        matches!(cols[1], ProjectExpr::Column(1)) || matches!(cols[1], ProjectExpr::Column(2)),
525        matches!(cols[2], ProjectExpr::Column(3)),
526    );
527    cols_pattern == (true, true, true)
528}
529
530// -----------------------------------------------------------------
531// 4-cycle matcher.
532//
533// Mirrors the triangle matcher with a shape-locked qualifier.
534// -----------------------------------------------------------------
535
536/// Four rel IDs extracted from a matched 4-cycle RIR.
537pub(super) struct FourCycleRirMatch {
538    pub rel_e1: RelId,
539    pub rel_e2: RelId,
540    pub rel_e3: RelId,
541    pub rel_e4: RelId,
542}
543
544/// Pattern-match a `RirNode::MultiWayJoin` whose structure is the
545/// canonical 4-cycle shape. Returns the four scan rel IDs in WCOJ
546/// slot order on a successful match; `None` for any deviation.
547///
548/// The match is intentionally strict over `inputs`, `slot_vars`,
549/// AND `output_columns`. The current 4-cycle matcher certifies the
550/// canonical (W, X, Y, Z) emit order.
551pub(super) fn match_multiway_4cycle(body: &RirNode) -> Option<FourCycleRirMatch> {
552    let RirNode::MultiWayJoin {
553        inputs,
554        slot_vars,
555        output_columns,
556        ..
557    } = body
558    else {
559        return None;
560    };
561    if inputs.len() != 4 {
562        return None;
563    }
564    if !slot_vars_match_canonical_4cycle(slot_vars) {
565        return None;
566    }
567    if !output_columns_match_canonical_4cycle(output_columns) {
568        return None;
569    }
570    let rel_e1 = scan_rel(&inputs[0])?;
571    let rel_e2 = scan_rel(&inputs[1])?;
572    let rel_e3 = scan_rel(&inputs[2])?;
573    let rel_e4 = scan_rel(&inputs[3])?;
574    Some(FourCycleRirMatch {
575        rel_e1,
576        rel_e2,
577        rel_e3,
578        rel_e4,
579    })
580}
581
582/// Confirm `slot_vars` is the canonical
583/// `[[A, B], [B, C], [C, D], [D, A]]` 4-cycle shape with four
584/// distinct variable-class ids closing the cycle (slot 3's second
585/// var equals slot 0's first var).
586fn slot_vars_match_canonical_4cycle(slot_vars: &[Vec<Option<u32>>]) -> bool {
587    if slot_vars.len() != 4 {
588        return false;
589    }
590    for s in slot_vars {
591        if s.len() != 2 {
592            return false;
593        }
594    }
595    let (a, b) = match (slot_vars[0][0], slot_vars[0][1]) {
596        (Some(a), Some(b)) if a != b => (a, b),
597        _ => return false,
598    };
599    let c = match (slot_vars[1][0], slot_vars[1][1]) {
600        (Some(b1), Some(c)) if b1 == b && c != a && c != b => c,
601        _ => return false,
602    };
603    let d = match (slot_vars[2][0], slot_vars[2][1]) {
604        (Some(c1), Some(d)) if c1 == c && d != a && d != b && d != c => d,
605        _ => return false,
606    };
607    matches!(
608        (slot_vars[3][0], slot_vars[3][1]),
609        (Some(d2), Some(a2)) if d2 == d && a2 == a
610    )
611}
612
613/// Confirm `output_columns` is the certified `(W, X, Y, Z)` emit
614/// order. The GPU kernel writes quads in this order.
615/// Accepted 4-cycle output-column layouts:
616///   * `[Column(0), Column(1), Column(3), Column(5)]` —
617///     Default grouping `(WX⋈XY) + (YZ⋈ZW)`.
618///   * `[Column(5), Column(0), Column(1), Column(3)]` — Alt
619///     grouping `(XY⋈YZ) + (ZW⋈WX)` (binary intermediate
620///     col 5 = W from inner-right; (W, X, Y, Z) extracts
621///     from cols [5, 0, 1, 3]).
622fn output_columns_match_canonical_4cycle(cols: &[ProjectExpr]) -> bool {
623    if cols.len() != 4 {
624        return false;
625    }
626    let exact = |idx: usize, want: usize| matches!(cols[idx], ProjectExpr::Column(c) if c == want);
627    // Default layout.
628    let default_layout = exact(0, 0) && exact(1, 1) && exact(2, 3) && exact(3, 5);
629    // Alt layout.
630    let alt_layout = exact(0, 5) && exact(1, 0) && exact(2, 1) && exact(3, 3);
631    default_layout || alt_layout
632}
633
634/// Extract the `RelId` from a leaf `Scan` node, or `None` for
635/// any non-Scan child. The current matcher only admits Scan leaves;
636/// future matcher work may admit `Filter { Scan }` or projected
637/// scans, but always in tandem with kernel support.
638fn scan_rel(node: &RirNode) -> Option<RelId> {
639    match node {
640        RirNode::Scan { rel } => Some(*rel),
641        _ => None,
642    }
643}
644
645/// Physical key width for a WCOJ-eligible binary relation at
646/// the RIR-level dispatch. `FourByte` covers `U32` and `Symbol`
647/// (bit-identical layout); `EightByte` covers `U64`.
648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
649pub(super) enum WcojKeyWidth {
650    FourByte,
651    EightByte,
652}
653
654/// Classify a binary [`CudaBuffer`]'s key width for WCOJ
655/// dispatch, mirroring `xlog_integration::wcoj_dispatch`'s
656/// AST-level helper. Returns `Some(width)` for 2-column buffers
657/// whose columns are both 4-byte (U32/Symbol) or both 8-byte
658/// (U64); `None` for any other arity / type combination,
659/// including mixed-width within a single buffer.
660///
661/// Cross-relation type compatibility is enforced upstream by
662/// the planner via `analyze_typed`. The executor only sees
663/// lowered RIR at this point, so this classifier is the last
664/// width-uniformity check before the GPU launch — any
665/// divergence vs. the binary-join path is caught by the
666/// wiring/cert row-set-equality tests.
667fn classify_two_col_wcoj_width(buf: &CudaBuffer) -> Option<WcojKeyWidth> {
668    if buf.arity() != 2 {
669        return None;
670    }
671    let c0 = buf.schema().column_type(0)?;
672    let c1 = buf.schema().column_type(1)?;
673    let w0 = scalar_wcoj_width(c0)?;
674    let w1 = scalar_wcoj_width(c1)?;
675    if w0 != w1 {
676        return None;
677    }
678    Some(w0)
679}
680
681fn scalar_wcoj_width(ty: xlog_core::ScalarType) -> Option<WcojKeyWidth> {
682    match ty {
683        xlog_core::ScalarType::U32 | xlog_core::ScalarType::Symbol => Some(WcojKeyWidth::FourByte),
684        xlog_core::ScalarType::U64 => Some(WcojKeyWidth::EightByte),
685        _ => None,
686    }
687}
688
689/// Convert `kernel_output_cols` (a `Vec<ProjectExpr>`) into
690/// the `Vec<usize>` permutation that
691/// `wcoj_project_output_columns_recorded` consumes. Triangle and
692/// 4-cycle kernel_output_cols entries are always
693/// `ProjectExpr::Column(_)` per the locked permutation tables in
694/// `xlog_logic::wcoj_var_ordering`; anything else is a planner bug.
695/// Derive the slot-0 joined-with slot-1 feedback pair and the
696/// underlying-relation key columns from `var_order`.
697///
698/// Returns `(rel_a, rel_b, left_keys, right_keys)` where keys
699/// are NATIVE (pre-swap) column indices on the underlying
700/// relations — `record_join_result` stores keys against native
701/// indexing. For triangle non-default leaders, slot 1 is a
702/// 2-col SWAPPED view of the underlying relation; the kernel
703/// invariant `slot0.col1 ≡ slot1.col0` holds for the views
704/// but maps to native key index 1 on BOTH sides.
705///
706/// **Rotated-feedback table**:
707///
708/// | Shape    | Leader            | (rel_a, rel_b)      | (left_keys, right_keys) |
709/// |----------|-------------------|---------------------|-------------------------|
710/// | Triangle | 0 (e_xy default)  | (slot[0], slot[1])  | [1] / [0] (no swap) |
711/// | Triangle | 1 (e_yz)          | (slot[1], slot[2])  | **[1] / [1]** (slot 1 = e_xz↔) |
712/// | Triangle | 2 (e_xz)          | (slot[2], slot[1])  | **[1] / [1]** (slot 1 = e_yz↔) |
713/// | 4-cycle  | 0..3 (rotation)   | (slot[i], slot[i+1])| [1] / [0] (no swap) |
714///
715/// Returns `None` only if `slot_rels.len() < 2` (defensive).
716fn feedback_pair_from_var_order(
717    slot_rels: &[RelId],
718    var_order: Option<&VariableOrder>,
719) -> Option<(RelId, RelId, Vec<usize>, Vec<usize>)> {
720    if slot_rels.len() < 2 {
721        return None;
722    }
723    let Some(vo) = var_order else {
724        // Default config / no rotation: canonical feedback
725        // behavior: canonical (slot_rels[0], slot_rels[1]) with
726        // keys [1] / [0].
727        return Some((slot_rels[0], slot_rels[1], vec![1], vec![0]));
728    };
729    let leader_idx = vo.leader_idx as usize;
730    match slot_rels.len() {
731        3 => {
732            // Triangle rotated-feedback table.
733            match leader_idx {
734                0 => Some((slot_rels[0], slot_rels[1], vec![1], vec![0])),
735                1 => {
736                    // Leader e_yz: slot 0 = rel_yz native, slot 1 =
737                    // rel_xz **swapped** view. Native rel_xz has Z
738                    // at col1, so [1]/[1].
739                    Some((slot_rels[1], slot_rels[2], vec![1], vec![1]))
740                }
741                2 => {
742                    // Leader e_xz: slot 0 = rel_xz native, slot 1 =
743                    // rel_yz **swapped** view. Native rel_yz has Z
744                    // at col1, so [1]/[1].
745                    Some((slot_rels[2], slot_rels[1], vec![1], vec![1]))
746                }
747                _ => None,
748            }
749        }
750        4 => {
751            // 4-cycle: rotation-only, all slots in native layout,
752            // keys [1]/[0] for every leader.
753            if leader_idx >= 4 {
754                return None;
755            }
756            let slot1_input_idx = (leader_idx + 1) % 4;
757            Some((
758                slot_rels[leader_idx],
759                slot_rels[slot1_input_idx],
760                vec![1],
761                vec![0],
762            ))
763        }
764        _ => None,
765    }
766}
767
768fn perm_indices_from_kernel_output_cols(cols: &[ProjectExpr]) -> Result<Vec<usize>> {
769    let mut out = Vec::with_capacity(cols.len());
770    for c in cols {
771        match c {
772            ProjectExpr::Column(idx) => out.push(*idx),
773            other => {
774                return Err(xlog_core::XlogError::Kernel(format!(
775                    "perm_indices_from_kernel_output_cols: \
776                     kernel_output_cols must be ProjectExpr::Column(_), got {:?}",
777                    other
778                )));
779            }
780        }
781    }
782    Ok(out)
783}
784
785/// Build the canonical triangle head schema `(X, Y, Z)`
786/// from the canonical promoter inputs. Used as the
787/// `head_schema` argument to
788/// `wcoj_project_output_columns_recorded` on the leader-ordered path.
789fn build_triangle_head_schema(buf_xy: &CudaBuffer, buf_yz: &CudaBuffer) -> Result<Schema> {
790    let x_type = buf_xy.schema().column_type(0).ok_or_else(|| {
791        xlog_core::XlogError::Kernel("build_triangle_head_schema: e_xy.col0 type missing".into())
792    })?;
793    let y_type = buf_xy.schema().column_type(1).ok_or_else(|| {
794        xlog_core::XlogError::Kernel("build_triangle_head_schema: e_xy.col1 type missing".into())
795    })?;
796    let z_type = buf_yz.schema().column_type(1).ok_or_else(|| {
797        xlog_core::XlogError::Kernel("build_triangle_head_schema: e_yz.col1 type missing".into())
798    })?;
799    Schema::new(vec![
800        ("col0".to_string(), x_type),
801        ("col1".to_string(), y_type),
802        ("col2".to_string(), z_type),
803    ])
804    .with_sort_labels(vec![
805        buf_xy
806            .schema()
807            .column_sort_label(0)
808            .unwrap_or("col0")
809            .to_string(),
810        buf_xy
811            .schema()
812            .column_sort_label(1)
813            .unwrap_or("col1")
814            .to_string(),
815        buf_yz
816            .schema()
817            .column_sort_label(1)
818            .unwrap_or("col2")
819            .to_string(),
820    ])
821    .map_err(xlog_core::XlogError::Kernel)
822}
823
824/// Build the canonical 4-cycle head schema
825/// `(W, X, Y, Z)` from the canonical promoter inputs.
826fn build_4cycle_head_schema(
827    buf_e1: &CudaBuffer,
828    buf_e2: &CudaBuffer,
829    buf_e3: &CudaBuffer,
830) -> Result<Schema> {
831    // `[e_wx, e_xy, e_yz, e_zw]` — canonical promoter order.
832    // W = e_wx.col0, X = e_wx.col1 (= e_xy.col0), Y = e_xy.col1
833    // (= e_yz.col0), Z = e_yz.col1 (= e_zw.col0).
834    let w_type = buf_e1.schema().column_type(0).ok_or_else(|| {
835        xlog_core::XlogError::Kernel("build_4cycle_head_schema: e_wx.col0 type missing".into())
836    })?;
837    let x_type = buf_e1.schema().column_type(1).ok_or_else(|| {
838        xlog_core::XlogError::Kernel("build_4cycle_head_schema: e_wx.col1 type missing".into())
839    })?;
840    let y_type = buf_e2.schema().column_type(1).ok_or_else(|| {
841        xlog_core::XlogError::Kernel("build_4cycle_head_schema: e_xy.col1 type missing".into())
842    })?;
843    let z_type = buf_e3.schema().column_type(1).ok_or_else(|| {
844        xlog_core::XlogError::Kernel("build_4cycle_head_schema: e_yz.col1 type missing".into())
845    })?;
846    // Suppress the unused-import warning when ScalarType isn't
847    // referenced in this scope (kept here for explicitness in case
848    // a future change adds a width check).
849    let _: ScalarType = w_type;
850    Schema::new(vec![
851        ("col0".to_string(), w_type),
852        ("col1".to_string(), x_type),
853        ("col2".to_string(), y_type),
854        ("col3".to_string(), z_type),
855    ])
856    .with_sort_labels(vec![
857        buf_e1
858            .schema()
859            .column_sort_label(0)
860            .unwrap_or("col0")
861            .to_string(),
862        buf_e1
863            .schema()
864            .column_sort_label(1)
865            .unwrap_or("col1")
866            .to_string(),
867        buf_e2
868            .schema()
869            .column_sort_label(1)
870            .unwrap_or("col2")
871            .to_string(),
872        buf_e3
873            .schema()
874            .column_sort_label(1)
875            .unwrap_or("col3")
876            .to_string(),
877    ])
878    .map_err(xlog_core::XlogError::Kernel)
879}
880
881impl Executor {
882    /// Try to dispatch a single non-recursive rule through the
883    /// GPU WCOJ triangle kernel. Returns `Ok(Some(buffer))` if
884    /// the dispatch fires and produces a result; `Ok(None)`
885    /// otherwise (gate off, shape mismatch, missing buffer,
886    /// non-4-byte-key schema, missing runtime, or kernel error — every
887    /// failure mode is silent fallback).
888    ///
889    /// On `Ok(Some(_))`, the caller is responsible for installing
890    /// the buffer into the relation store via the same path the
891    /// existing binary-join branch uses.
892    pub(super) fn try_dispatch_wcoj_triangle(
893        &mut self,
894        rule: &CompiledRule,
895    ) -> Result<Option<CudaBuffer>> {
896        // Body-keyed entry. Rule-keyed callers stay
897        // byte-identical via this thin wrapper.
898        self.try_dispatch_wcoj_triangle_on_body(&rule.body)
899    }
900
901    /// Read the WCOJ output buffer's logical row count.
902    /// Returns `None` when the cache isn't populated. **Never
903    /// returns `Some(0)` for an unknown row count** — only for
904    /// an observed-empty output. The distinction matters for
905    /// `record_wcoj_feedback`: an unknown count must skip the
906    /// EMA update, not record selectivity 0.
907    fn wcoj_output_rows(buf: &CudaBuffer) -> Option<u64> {
908        // `CudaBuffer::cached_row_count` returns `Option<u32>`;
909        // widen to `u64` for the `StatsManager` API.
910        buf.cached_row_count().map(u64::from)
911    }
912
913    /// Wire successful WCOJ dispatches back into
914    /// `StatsManager` so the cardinality cost model's future
915    /// `binary_est` reads reflect observed selectivity.
916    ///
917    /// **Leader-aware routing**: the `(rel_a, rel_b, left_keys, right_keys)`
918    /// quadruple is derived from the dispatched plan's
919    /// `var_order` via `feedback_pair_from_var_order`, NOT
920    /// hardcoded:
921    ///
922    /// * `var_order = None` (default config): returns the
923    ///   canonical feedback pair, `(slot_rels[0], slot_rels[1])`
924    ///   with keys `[1] / [0]`.
925    /// * `var_order = Some(_)` (non-default leader): returns the rotated
926    ///   pair from the locked feedback table — triangle
927    ///   non-default leaders use rotated `(slot_rels[0],
928    ///   slot_rels[1])` with keys `[1] / [1]` (Z-shared edges
929    ///   in canonical layout join on col 1 of both rels);
930    ///   4-cycle is rotation-only with keys `[1] / [0]`.
931    ///
932    /// `CardinalityAwareCostModel::should_dispatch_*` still
933    /// reads via `estimate_join_cardinality` on the canonical
934    /// default-leader pair — but on a non-default-leader run
935    /// the dispatched layout's actual edge is what we observe,
936    /// and that's what gets recorded under the rotated key.
937    /// The leader-aware cost models look up rotated edges
938    /// correspondingly; the writer ↔ reader pair stays
939    /// coherent under each leader topology.
940    ///
941    /// Skips the recording when:
942    ///   * `slot_rels.len() < 2` — not enough slots for a
943    ///     binary inner pair (defensive).
944    ///   * `output_rows == None` — unknown logical row count;
945    ///     recording 0 would poison the EMA.
946    ///   * `feedback_pair_from_var_order` returns `None` — the
947    ///     leader rotation isn't in the locked feedback table
948    ///     (conservative; never write under uncertainty).
949    ///   * Any of `(rel_a, rel_b)` has missing or zero
950    ///     cardinality; unknown inputs would compute a meaningless
951    ///     `input_card_product`.
952    ///
953    /// Recording an observed-empty output (`Some(0)`) IS
954    /// correct — the EMA tightens future estimates toward zero,
955    /// so WCOJ becomes less likely on the same inputs next
956    /// call (the kernel produced nothing useful).
957    ///
958    /// The triangle / 4-cycle output is a strict subset of the
959    /// inner-join intermediate (the third / additional atoms
960    /// further filter it). The recorded selectivity is
961    /// therefore an UPPER BOUND on the true binary
962    /// selectivity, which is the correct conservative direction
963    /// for the cost model: it under-claims the WCOJ kernel's
964    /// win rather than over-claiming.
965    fn record_wcoj_feedback(
966        &mut self,
967        slot_rels: &[RelId],
968        var_order: Option<&VariableOrder>,
969        output_rows: Option<u64>,
970    ) {
971        if slot_rels.len() < 2 {
972            return;
973        }
974        let Some(out_rows) = output_rows else {
975            return;
976        };
977        // Derive the (slot 0, slot 1) feedback pair and the
978        // underlying-relation key columns from `var_order`.
979        // For `var_order = None` (default config), this returns
980        // the canonical pair + keys [1]/[0]. For Some(_), the pair may be
981        // rotated (triangle non-default leaders use rotated pair
982        // + [1]/[1] keys; 4-cycle is rotation-only [1]/[0]).
983        let Some((rel_a, rel_b, left_keys, right_keys)) =
984            feedback_pair_from_var_order(slot_rels, var_order)
985        else {
986            return;
987        };
988        let card_a = self
989            .stats
990            .get_relation_stats(rel_a)
991            .map(|s| s.cardinality)
992            .filter(|c| *c > 0);
993        let card_b = self
994            .stats
995            .get_relation_stats(rel_b)
996            .map(|s| s.cardinality)
997            .filter(|c| *c > 0);
998        let (Some(a), Some(b)) = (card_a, card_b) else {
999            return;
1000        };
1001        let input_rows = a.saturating_mul(b);
1002        // `record_join_result` takes owned `Vec<usize>` for the
1003        // key columns (signature predates this slice).
1004        self.stats
1005            .record_join_result(rel_a, rel_b, left_keys, right_keys, input_rows, out_rows);
1006    }
1007
1008    /// Body-keyed entry point: same gate / pattern-match / dispatch
1009    /// logic as `try_dispatch_wcoj_triangle`, keyed on `body`
1010    /// rather than `&CompiledRule`. The recursive engine calls
1011    /// this on the rewritten variant body (one Scan's RelId
1012    /// swapped to a delta RelId); the rule-keyed wrapper above
1013    /// preserves the rule-keyed surface for non-recursive callers.
1014    pub(super) fn try_dispatch_wcoj_triangle_on_body(
1015        &mut self,
1016        body: &RirNode,
1017    ) -> Result<Option<CudaBuffer>> {
1018        #[cfg(feature = "wcoj-phase-timing")]
1019        let wall_start = Instant::now();
1020        // 1. Gate resolution. Decision tree (highest → lowest):
1021        //
1022        //    a. Runtime disable flag → no dispatch.
1023        //    b. If `wcoj_triangle_dispatch` resolves to true
1024        //       (config Some(true) or env=1) → force WCOJ.
1025        //    c. Force = Some(false) → explicit off.
1026        //    d. Else if stats mode resolves to true, consult
1027        //       the cardinality model.
1028        //    e. Else → no dispatch.
1029        if self.config.wcoj_triangle_dispatch_disabled.unwrap_or(false) {
1030            return Ok(None);
1031        }
1032        let force_override = self.config.wcoj_triangle_dispatch;
1033        let force_on = wcoj_gate_enabled(force_override);
1034        let mode = if force_on {
1035            DispatchMode::Force
1036        } else {
1037            // Force-Some(false) is "explicitly off". Only when
1038            // force is None or env-default-off do we consult the
1039            // stats gate.
1040            let force_explicit_off = matches!(force_override, Some(false));
1041            if force_explicit_off {
1042                return Ok(None);
1043            }
1044            let adaptive_override = self.config.wcoj_triangle_dispatch_adaptive;
1045            if wcoj_adaptive_enabled(adaptive_override) {
1046                DispatchMode::CostModel
1047            } else {
1048                return Ok(None);
1049            }
1050        };
1051
1052        // 2. Pattern-match the canonical-triangle MultiWayJoin.
1053        let Some(matched) = match_multiway_triangle(body) else {
1054            return Ok(None);
1055        };
1056
1057        // 3. Resolve rel IDs to predicate names.
1058        // get_rel_name returns Option<&str> — bind to owned String
1059        // so the borrow doesn't conflict with later &mut self uses.
1060        let name_xy = match self.get_rel_name(matched.rel_xy) {
1061            Some(s) => s.to_string(),
1062            None => return Ok(None),
1063        };
1064        let name_yz = match self.get_rel_name(matched.rel_yz) {
1065            Some(s) => s.to_string(),
1066            None => return Ok(None),
1067        };
1068        let name_xz = match self.get_rel_name(matched.rel_xz) {
1069            Some(s) => s.to_string(),
1070            None => return Ok(None),
1071        };
1072
1073        // 4. Look up input buffers + classify their key widths.
1074        // All three slots must be WCOJ-eligible AND share the
1075        // same width — mixed-width triangles fall back here so
1076        // the binary-join path handles them.
1077        let buf_xy = match self.store.get(&name_xy) {
1078            Some(b) => b,
1079            None => return Ok(None),
1080        };
1081        let buf_yz = match self.store.get(&name_yz) {
1082            Some(b) => b,
1083            None => return Ok(None),
1084        };
1085        let buf_xz = match self.store.get(&name_xz) {
1086            Some(b) => b,
1087            None => return Ok(None),
1088        };
1089        let width = match (
1090            classify_two_col_wcoj_width(buf_xy),
1091            classify_two_col_wcoj_width(buf_yz),
1092            classify_two_col_wcoj_width(buf_xz),
1093        ) {
1094            (Some(a), Some(b), Some(c)) if a == b && b == c => a,
1095            _ => return Ok(None),
1096        };
1097
1098        // 5. Resolve the cached executor WCOJ launch stream.
1099        // Acquire-once / reuse-forever (mirrors
1100        // `CudaKernelProvider::recorded_op_stream`). Acquiring
1101        // per-invocation would silently drain the
1102        // `StreamPool` (default cap 16, grow-only) on long-
1103        // lived runtimes — once exhausted, subsequent
1104        // dispatches would silently fall back to binary-join
1105        // and the dispatch counter would stop incrementing.
1106        // Without a runtime-backed manager, the recorded WCOJ
1107        // primitives can't run — fall back silently.
1108        if self.provider.memory().runtime().is_none() {
1109            return Ok(None);
1110        }
1111        let launch_stream = match self.wcoj_dispatch_stream_or_init() {
1112            Some(s) => s,
1113            None => return Ok(None),
1114        };
1115
1116        // 6. Stats-backed mode only: resolve the WCOJ cost model
1117        // on the same launch stream as the eventual GPU pipeline.
1118        #[cfg(feature = "wcoj-phase-timing")]
1119        let mut classifier_ms: f32 = 0.0;
1120        if mode == DispatchMode::CostModel {
1121            #[cfg(feature = "wcoj-phase-timing")]
1122            let cls_start = Instant::now();
1123            let model = super::wcoj_cost_model::build_wcoj_cost_model(&self.config);
1124            let slot_rels = [matched.rel_xy, matched.rel_yz, matched.rel_xz];
1125            let ctx = super::wcoj_cost_model::WcojDispatchCtx {
1126                stats: &self.stats,
1127                launch_stream,
1128                width,
1129                slot_rels: &slot_rels,
1130            };
1131            let dispatch = model.should_dispatch_triangle(&ctx);
1132            #[cfg(feature = "wcoj-phase-timing")]
1133            {
1134                classifier_ms = cls_start.elapsed().as_secs_f64() as f32 * 1000.0;
1135            }
1136            if !dispatch {
1137                return Ok(None);
1138            }
1139        }
1140
1141        // Extract var_order from the matched MultiWayJoin body. None preserves
1142        // default-leader dispatch bit-identically.
1143        let var_order_opt: Option<&VariableOrder> = match body {
1144            RirNode::MultiWayJoin { var_order, .. } => var_order.as_ref(),
1145            _ => None,
1146        };
1147
1148        // 7. Run layout + triangle. Convert any kernel error to
1149        // silent fallback per dispatch contract ("failure must not
1150        // corrupt store state"). The WCOJ helpers don't write
1151        // to the store, so an error here only loses the work
1152        // we just did — the binary-join path picks it up.
1153        #[cfg(feature = "wcoj-phase-timing")]
1154        let mut layout_times: [f32; 3] = [0.0; 3];
1155        let dispatch_result = self.run_wcoj_triangle_pipeline(
1156            buf_xy,
1157            buf_yz,
1158            buf_xz,
1159            launch_stream,
1160            width,
1161            var_order_opt,
1162            #[cfg(feature = "wcoj-phase-timing")]
1163            &mut layout_times,
1164        );
1165        match dispatch_result {
1166            Ok(buf) => {
1167                // Record observed selectivity into
1168                // StatsManager for the cardinality cost model.
1169                // The (rel_a, rel_b, left_keys, right_keys) pair
1170                // is derived from `var_order_opt` via
1171                // `feedback_pair_from_var_order`:
1172                //   * `var_order = None` (default config) →
1173                //     canonical `(rel_xy, rel_yz)` keys
1174                //     `[1]/[0]`.
1175                //   * `var_order = Some(_)` (non-default leader) →
1176                //     rotated pair per the feedback table.
1177                //     Triangle non-default leaders use rotated
1178                //     `(slot_rels[0], slot_rels[1])` with keys
1179                //     `[1]/[1]` (Z-shared edges in canonical
1180                //     layout join on col 1 of both rels).
1181                // Helper handles skip-on-missing-data and is
1182                // called BEFORE the counter increment so a
1183                // helper panic doesn't advance the counter.
1184                let output_rows = Self::wcoj_output_rows(&buf);
1185                let slot_rels = [matched.rel_xy, matched.rel_yz, matched.rel_xz];
1186                self.record_wcoj_feedback(&slot_rels, var_order_opt, output_rows);
1187                self.wcoj_triangle_dispatch_count += 1;
1188                #[cfg(feature = "wcoj-phase-timing")]
1189                {
1190                    let triangle_timing = self
1191                        .provider
1192                        .take_wcoj_triangle_phase_timing()
1193                        .unwrap_or_default();
1194                    let wall_ms = wall_start.elapsed().as_secs_f64() as f32 * 1000.0;
1195                    let timing = super::wcoj_phase_timing::WcojDispatchPhaseTiming::new(
1196                        classifier_ms,
1197                        layout_times[0],
1198                        layout_times[1],
1199                        layout_times[2],
1200                        triangle_timing,
1201                        wall_ms,
1202                    );
1203                    if let Ok(mut g) = self.last_wcoj_phase_timing.lock() {
1204                        *g = Some(timing);
1205                    }
1206                }
1207                Ok(Some(buf))
1208            }
1209            Err(err) => wcoj_decline_on_error(&mut self.wcoj_error_decline_count, "triangle", err),
1210        }
1211    }
1212
1213    /// Inner pipeline: 3× layout construction + triangle kernel.
1214    /// Split out so [`try_dispatch_wcoj_triangle`] can map any
1215    /// error to `Ok(None)` cleanly. Branches by `width` between
1216    /// the parallel u32 and u64 provider entries.
1217    ///
1218    /// Under feature `wcoj-phase-timing`, fills the optional
1219    /// `layout_times_ms` slot with `[layout_xy, layout_yz, layout_xz]`
1220    /// wall times in milliseconds. The triangle's per-phase GPU
1221    /// times are pulled from the provider via
1222    /// `take_wcoj_triangle_phase_timing` after this returns.
1223    #[allow(clippy::too_many_arguments)]
1224    fn run_wcoj_triangle_pipeline(
1225        &self,
1226        buf_xy: &CudaBuffer,
1227        buf_yz: &CudaBuffer,
1228        buf_xz: &CudaBuffer,
1229        launch_stream: StreamId,
1230        width: WcojKeyWidth,
1231        var_order: Option<&VariableOrder>,
1232        #[cfg(feature = "wcoj-phase-timing")] layout_times_ms: &mut [f32; 3],
1233    ) -> Result<CudaBuffer> {
1234        // When the cost model selected a non-default leader,
1235        // run the rotated/swapped path. Layout helper sees the
1236        // (possibly col-swapped) leader-rotated inputs; kernel
1237        // emits in (a, b, c) order; final projection helper remaps
1238        // to the canonical (X, Y, Z) head order.
1239        if let Some(vo) = var_order {
1240            return self.run_wcoj_triangle_pipeline_with_leader_order(
1241                buf_xy,
1242                buf_yz,
1243                buf_xz,
1244                launch_stream,
1245                width,
1246                vo,
1247            );
1248        }
1249        #[cfg(feature = "wcoj-phase-timing")]
1250        let mut time_layout =
1251            |f: &dyn Fn() -> Result<CudaBuffer>, slot: usize| -> Result<CudaBuffer> {
1252                let s = Instant::now();
1253                let r = f()?;
1254                layout_times_ms[slot] = s.elapsed().as_secs_f64() as f32 * 1000.0;
1255                Ok(r)
1256            };
1257        match width {
1258            WcojKeyWidth::FourByte => {
1259                #[cfg(feature = "wcoj-phase-timing")]
1260                let (layout_xy, layout_yz, layout_xz) = {
1261                    let xy = time_layout(
1262                        &|| {
1263                            self.provider
1264                                .wcoj_layout_u32_recorded(buf_xy, launch_stream)
1265                        },
1266                        0,
1267                    )?;
1268                    let yz = time_layout(
1269                        &|| {
1270                            self.provider
1271                                .wcoj_layout_u32_recorded(buf_yz, launch_stream)
1272                        },
1273                        1,
1274                    )?;
1275                    let xz = time_layout(
1276                        &|| {
1277                            self.provider
1278                                .wcoj_layout_u32_recorded(buf_xz, launch_stream)
1279                        },
1280                        2,
1281                    )?;
1282                    (xy, yz, xz)
1283                };
1284                #[cfg(not(feature = "wcoj-phase-timing"))]
1285                let layout_xy = self
1286                    .provider
1287                    .wcoj_layout_u32_recorded(buf_xy, launch_stream)?;
1288                #[cfg(not(feature = "wcoj-phase-timing"))]
1289                let layout_yz = self
1290                    .provider
1291                    .wcoj_layout_u32_recorded(buf_yz, launch_stream)?;
1292                #[cfg(not(feature = "wcoj-phase-timing"))]
1293                let layout_xz = self
1294                    .provider
1295                    .wcoj_layout_u32_recorded(buf_xz, launch_stream)?;
1296                let out = self.provider.wcoj_triangle_hg_u32_recorded(
1297                    &layout_xy,
1298                    &layout_yz,
1299                    &layout_xz,
1300                    wcoj_block_work_unit(),
1301                    launch_stream,
1302                )?;
1303                self.provider.record_wcoj_triangle_hg_dispatch();
1304                Ok(out)
1305            }
1306            WcojKeyWidth::EightByte => {
1307                #[cfg(feature = "wcoj-phase-timing")]
1308                let (layout_xy, layout_yz, layout_xz) = {
1309                    let xy = time_layout(
1310                        &|| {
1311                            self.provider
1312                                .wcoj_layout_u64_recorded(buf_xy, launch_stream)
1313                        },
1314                        0,
1315                    )?;
1316                    let yz = time_layout(
1317                        &|| {
1318                            self.provider
1319                                .wcoj_layout_u64_recorded(buf_yz, launch_stream)
1320                        },
1321                        1,
1322                    )?;
1323                    let xz = time_layout(
1324                        &|| {
1325                            self.provider
1326                                .wcoj_layout_u64_recorded(buf_xz, launch_stream)
1327                        },
1328                        2,
1329                    )?;
1330                    (xy, yz, xz)
1331                };
1332                #[cfg(not(feature = "wcoj-phase-timing"))]
1333                let layout_xy = self
1334                    .provider
1335                    .wcoj_layout_u64_recorded(buf_xy, launch_stream)?;
1336                #[cfg(not(feature = "wcoj-phase-timing"))]
1337                let layout_yz = self
1338                    .provider
1339                    .wcoj_layout_u64_recorded(buf_yz, launch_stream)?;
1340                #[cfg(not(feature = "wcoj-phase-timing"))]
1341                let layout_xz = self
1342                    .provider
1343                    .wcoj_layout_u64_recorded(buf_xz, launch_stream)?;
1344                self.provider.wcoj_triangle_u64_recorded(
1345                    &layout_xy,
1346                    &layout_yz,
1347                    &layout_xz,
1348                    launch_stream,
1349                )
1350            }
1351        }
1352    }
1353
1354    /// Pipeline for non-default leaders. Uses the permutation tables on
1355    /// `var_order` to:
1356    /// 1. Rotate canonical inputs `[buf_xy, buf_yz, buf_xz]` so the
1357    ///    leader sits at slot 0.
1358    /// 2. Apply col-swap (via `wcoj_project_2col_swap_recorded`) to
1359    ///    any non-leader slot whose `LookupPerm.swap_cols` is true.
1360    ///    Triangle e_yz / e_xz leaders need swaps; 4-cycle is
1361    ///    rotation-only (no swap entries).
1362    /// 3. Run `wcoj_layout_*_recorded` on each slot input.
1363    /// 4. Run `wcoj_triangle_*_recorded`. Kernel emits 3 columns
1364    ///    in leader's `(a, b, c)` order.
1365    /// 5. Apply `wcoj_project_output_columns_recorded` with
1366    ///    `var_order.kernel_output_cols` to re-permute the
1367    ///    kernel-direct output into the canonical head order
1368    ///    `(X, Y, Z)`.
1369    ///
1370    /// Phase timing is intentionally NOT instrumented on this path; performance
1371    /// validation for the non-default leader threshold is handled by benchmark
1372    /// evidence outside this helper.
1373    fn run_wcoj_triangle_pipeline_with_leader_order(
1374        &self,
1375        buf_xy: &CudaBuffer,
1376        buf_yz: &CudaBuffer,
1377        buf_xz: &CudaBuffer,
1378        launch_stream: StreamId,
1379        width: WcojKeyWidth,
1380        var_order: &VariableOrder,
1381    ) -> Result<CudaBuffer> {
1382        let canonical: [&CudaBuffer; 3] = [buf_xy, buf_yz, buf_xz];
1383        let slot_inputs = self.prepare_leader_inputs(&canonical, var_order, launch_stream)?;
1384        if slot_inputs.len() != 3 {
1385            return Err(xlog_core::XlogError::Kernel(
1386                "run_wcoj_triangle_pipeline_with_leader_order: prepare_leader_inputs must return 3 slots"
1387                    .to_string(),
1388            ));
1389        }
1390
1391        // Build the canonical (X, Y, Z) head schema from the
1392        // canonical promoter inputs (NOT the rotated kernel
1393        // inputs). The kernel will emit in (a, b, c) order under
1394        // the rotated leader; the final projection helper maps
1395        // back to head order using kernel_output_cols.
1396        let head_schema = build_triangle_head_schema(buf_xy, buf_yz)?;
1397        let perm = perm_indices_from_kernel_output_cols(&var_order.kernel_output_cols)?;
1398
1399        let kernel_out: CudaBuffer = match width {
1400            WcojKeyWidth::FourByte => {
1401                let l0 = self
1402                    .provider
1403                    .wcoj_layout_u32_recorded(&slot_inputs[0], launch_stream)?;
1404                let l1 = self
1405                    .provider
1406                    .wcoj_layout_u32_recorded(&slot_inputs[1], launch_stream)?;
1407                let l2 = self
1408                    .provider
1409                    .wcoj_layout_u32_recorded(&slot_inputs[2], launch_stream)?;
1410                let out = self.provider.wcoj_triangle_hg_u32_recorded(
1411                    &l0,
1412                    &l1,
1413                    &l2,
1414                    wcoj_block_work_unit(),
1415                    launch_stream,
1416                )?;
1417                self.provider.record_wcoj_triangle_hg_dispatch();
1418                out
1419            }
1420            WcojKeyWidth::EightByte => {
1421                let l0 = self
1422                    .provider
1423                    .wcoj_layout_u64_recorded(&slot_inputs[0], launch_stream)?;
1424                let l1 = self
1425                    .provider
1426                    .wcoj_layout_u64_recorded(&slot_inputs[1], launch_stream)?;
1427                let l2 = self
1428                    .provider
1429                    .wcoj_layout_u64_recorded(&slot_inputs[2], launch_stream)?;
1430                self.provider
1431                    .wcoj_triangle_u64_recorded(&l0, &l1, &l2, launch_stream)?
1432            }
1433        };
1434
1435        self.provider.wcoj_project_output_columns_recorded(
1436            &kernel_out,
1437            &perm,
1438            head_schema,
1439            launch_stream,
1440        )
1441    }
1442
1443    /// Number of times the WCOJ triangle hook produced a result
1444    /// and the executor installed it. Used by tests to assert
1445    /// that the WCOJ path actually ran (vs. silently falling
1446    /// back to the existing binary-join path with the same
1447    /// answer).
1448    pub fn wcoj_triangle_dispatch_count(&self) -> u64 {
1449        self.wcoj_triangle_dispatch_count
1450    }
1451
1452    /// Number of WCOJ pipeline errors (layout or kernel failures, across
1453    /// triangle / 4-cycle / k-clique / chain hooks) that were converted
1454    /// into binary-join declines. Healthy dispatch keeps this at 0; a
1455    /// nonzero value is the signature of a regressed WCOJ pipeline hiding
1456    /// behind the silent-fallback contract. Set `XLOG_WCOJ_STRICT=1` to
1457    /// propagate such errors instead of declining.
1458    pub fn wcoj_error_decline_count(&self) -> u64 {
1459        self.wcoj_error_decline_count
1460    }
1461
1462    /// Count of times the generalized Free Join dispatch produced
1463    /// the installed result (vs. the embedded binary fallback).
1464    pub fn free_join_dispatch_count(&self) -> u64 {
1465        self.free_join_dispatch_count
1466    }
1467
1468    /// Count of times the factorized recursive-delta dispatch produced the
1469    /// installed novel set (vs. the legacy hash-join -> diff path).
1470    pub fn factorized_delta_dispatch_count(&self) -> u64 {
1471        self.factorized_delta_dispatch_count
1472    }
1473
1474    /// Layout-normalize one factorized-delta static side key-first:
1475    /// key column 0 feeds the layout helper directly; key column 1 is
1476    /// column-swapped through the recorded projection first.
1477    fn factorized_delta_normalize_static(
1478        &self,
1479        buf: &CudaBuffer,
1480        key_col: usize,
1481        launch_stream: StreamId,
1482    ) -> Result<CudaBuffer> {
1483        if key_col == 0 {
1484            return self.provider.wcoj_layout_u32_recorded(buf, launch_stream);
1485        }
1486        let ty = |i: usize| {
1487            buf.schema().column_type(i).ok_or_else(|| {
1488                xlog_core::XlogError::Execution(format!(
1489                    "factorized-delta: static column {i} type missing"
1490                ))
1491            })
1492        };
1493        let swapped = Schema::new(vec![("k".to_string(), ty(1)?), ("v".to_string(), ty(0)?)]);
1494        let projected = self.provider.wcoj_project_output_columns_recorded(
1495            buf,
1496            &[1, 0],
1497            swapped,
1498            launch_stream,
1499        )?;
1500        self.provider
1501            .wcoj_layout_u32_recorded(&projected, launch_stream)
1502    }
1503
1504    /// Dispatch one semi-naive delta step through the factorized novel-set
1505    /// pipeline (`fj_delta_novel_u32_recorded`). Accepts the
1506    /// per-occurrence delta-rewritten variant body when it is a
1507    /// `ChainJoin` over two Scans with exactly one scanning the delta
1508    /// relation; the returned buffer is the head-order novel set —
1509    /// already diffed against `head_pred`'s stable relation and
1510    /// full-row deduped, so the caller may skip the legacy diff when
1511    /// every contribution to the head went through this path.
1512    ///
1513    /// Declines (silent, `Ok(None)`): kill switch, non-ChainJoin or
1514    /// non-Scan children, zero/two delta occurrences, non-u32/Symbol
1515    /// or non-arity-2 schemas, missing store buffers, head projection
1516    /// that is not a permutation of {delta carry, static value},
1517    /// dense-domain bound over the cap (cached per fixpoint), and the
1518    /// per-iteration work floor. Pipeline errors route through
1519    /// [`wcoj_decline_on_error`] ("factorized-delta" stage).
1520    pub(super) fn try_dispatch_factorized_delta(
1521        &mut self,
1522        node: &RirNode,
1523        delta_rel: RelId,
1524        head_pred: &str,
1525        recursive_preds: &HashSet<String>,
1526        ctx: &mut FactorizedDeltaCtx,
1527    ) -> Result<Option<CudaBuffer>> {
1528        use xlog_cuda::provider::FjDeltaCols;
1529
1530        if factorized_delta_disabled() {
1531            return Ok(None);
1532        }
1533        let RirNode::ChainJoin {
1534            left,
1535            right,
1536            left_key,
1537            right_key,
1538            output_columns,
1539            ..
1540        } = node
1541        else {
1542            return Ok(None);
1543        };
1544        let (RirNode::Scan { rel: left_rel }, RirNode::Scan { rel: right_rel }) =
1545            (left.as_ref(), right.as_ref())
1546        else {
1547            return Ok(None);
1548        };
1549        // Exactly one side scans the delta (per-occurrence variant
1550        // rewriting guarantees one occurrence; a delta-delta chain or
1551        // a chain not touching the delta both decline).
1552        let delta_on_left = match (*left_rel == delta_rel, *right_rel == delta_rel) {
1553            (true, false) => true,
1554            (false, true) => false,
1555            _ => return Ok(None),
1556        };
1557        let (delta_key, static_rel, static_key) = if delta_on_left {
1558            (*left_key, *right_rel, *right_key)
1559        } else {
1560            (*right_key, *left_rel, *left_key)
1561        };
1562        if delta_key > 1 || static_key > 1 {
1563            return Ok(None);
1564        }
1565        let delta_carry = 1 - delta_key;
1566        let static_value = 1 - static_key;
1567
1568        // Head projection must be a permutation of {delta carry,
1569        // static value} in the combined left+right column space.
1570        let (delta_off, static_off) = if delta_on_left { (0, 2) } else { (2, 0) };
1571        let carry_global = delta_off + delta_carry;
1572        let value_global = static_off + static_value;
1573        let [ProjectExpr::Column(out0), ProjectExpr::Column(out1)] = output_columns.as_slice()
1574        else {
1575            return Ok(None);
1576        };
1577        let (r_carry, r_value) = if (*out0, *out1) == (carry_global, value_global) {
1578            (0, 1)
1579        } else if (*out0, *out1) == (value_global, carry_global) {
1580            (1, 0)
1581        } else {
1582            return Ok(None);
1583        };
1584
1585        // Resolve store buffers; all three must be arity-2 u32/Symbol.
1586        let binary_u32_class = |buf: &CudaBuffer| {
1587            buf.arity() == 2
1588                && (0..2).all(|i| {
1589                    matches!(
1590                        buf.schema().column_type(i),
1591                        Some(ScalarType::U32) | Some(ScalarType::Symbol)
1592                    )
1593                })
1594        };
1595        let Some(delta_name) = self.get_rel_name(delta_rel).map(str::to_string) else {
1596            return Ok(None);
1597        };
1598        let Some(static_name) = self.get_rel_name(static_rel).map(str::to_string) else {
1599            return Ok(None);
1600        };
1601        let Some(delta_buf) = self.store.get(&delta_name) else {
1602            return Ok(None);
1603        };
1604        let Some(static_buf) = self.store.get(&static_name) else {
1605            return Ok(None);
1606        };
1607        let Some(full_buf) = self.store.get(head_pred) else {
1608            return Ok(None);
1609        };
1610        if !binary_u32_class(delta_buf)
1611            || !binary_u32_class(static_buf)
1612            || !binary_u32_class(full_buf)
1613        {
1614            return Ok(None);
1615        }
1616        if self.provider.memory().runtime().is_none() {
1617            return Ok(None);
1618        }
1619        let Some(launch_stream) = self.wcoj_dispatch_stream_or_init() else {
1620            return Ok(None);
1621        };
1622
1623        // Dense-domain bound, computed once per (head, static) per
1624        // fixpoint at the first dispatch attempt (induction: every
1625        // derived id comes from the seeded delta, the stable head
1626        // relation, or the static side — exactly the iteration-1
1627        // sets). The in-kernel bounds check stays as the fail-closed
1628        // backstop.
1629        // Domain bound (max id + 1), computed once per (head, static)
1630        // per fixpoint (induction: every derived id comes from the
1631        // seeded delta, the stable head relation, or the static side).
1632        // `None` only when an id is u32::MAX — neither the dense
1633        // bitvector (domain overflow) nor the sparse hash set (the
1634        // forbidden (MAX,MAX) key) can pack it, so decline for the
1635        // whole fixpoint.
1636        let domain_key = (head_pred.to_string(), static_rel);
1637        let domain = match ctx.domain_by_key.get(&domain_key) {
1638            Some(Some(d)) => *d,
1639            Some(None) => return Ok(None),
1640            None => {
1641                let max_id = match self.provider.fj_delta_columns_max_u32(
1642                    &[
1643                        (delta_buf, &[0, 1][..]),
1644                        (static_buf, &[0, 1][..]),
1645                        (full_buf, &[0, 1][..]),
1646                    ],
1647                    launch_stream,
1648                ) {
1649                    Ok(m) => m,
1650                    Err(err) => {
1651                        return wcoj_decline_on_error(
1652                            &mut self.wcoj_error_decline_count,
1653                            "factorized-delta",
1654                            err,
1655                        );
1656                    }
1657                };
1658                let decided = if max_id == u32::MAX {
1659                    None
1660                } else {
1661                    Some(max_id + 1)
1662                };
1663                ctx.domain_by_key.insert(domain_key, decided);
1664                match decided {
1665                    Some(d) => d,
1666                    None => return Ok(None),
1667                }
1668            }
1669        };
1670
1671        let n_delta = u64::from(self.buffer_row_count(delta_buf)?);
1672        let n_static = u64::from(self.buffer_row_count(static_buf)?);
1673        if n_delta == 0 || n_static == 0 {
1674            return Ok(None);
1675        }
1676
1677        // Route: dense characteristic-bitvector when the domain fits
1678        // the cap (default 2¹⁴, env up to 2¹⁶); sparse hash set
1679        // otherwise. The bitvector's popcount+scan floor over
1680        // n_words = domain·⌈domain/32⌉ is a domain² term, so it gates
1681        // ONLY the dense route — applying it to a large-domain sparse
1682        // step would spuriously bail every iteration.
1683        let dense = domain <= factorized_delta_max_domain();
1684        if dense {
1685            let n_words = u64::from(domain.div_ceil(32)) * u64::from(domain);
1686            let work_est = n_delta.saturating_mul((n_static / u64::from(domain)).max(1));
1687            if work_est < n_words / factorized_delta_work_divisor() {
1688                return Ok(None);
1689            }
1690        }
1691
1692        // Static side key-first layout. EDB statics are normalized
1693        // once per fixpoint (cached); a recursive static (non-linear
1694        // self-join — the stable relation itself) changes every
1695        // iteration and is re-normalized (it is already sorted+deduped
1696        // from union_gpu, so the layout fast-path applies).
1697        let static_is_recursive = recursive_preds.contains(&static_name);
1698        let norm_owned;
1699        let static_norm: &CudaBuffer = if static_is_recursive {
1700            norm_owned =
1701                match self.factorized_delta_normalize_static(static_buf, static_key, launch_stream)
1702                {
1703                    Ok(b) => b,
1704                    Err(err) => {
1705                        return wcoj_decline_on_error(
1706                            &mut self.wcoj_error_decline_count,
1707                            "factorized-delta",
1708                            err,
1709                        );
1710                    }
1711                };
1712            &norm_owned
1713        } else {
1714            match ctx.static_norm_cache.entry((static_rel, static_key)) {
1715                std::collections::hash_map::Entry::Occupied(e) => &*e.into_mut(),
1716                std::collections::hash_map::Entry::Vacant(v) => {
1717                    let norm = match self.factorized_delta_normalize_static(
1718                        static_buf,
1719                        static_key,
1720                        launch_stream,
1721                    ) {
1722                        Ok(b) => b,
1723                        Err(err) => {
1724                            return wcoj_decline_on_error(
1725                                &mut self.wcoj_error_decline_count,
1726                                "factorized-delta",
1727                                err,
1728                            );
1729                        }
1730                    };
1731                    &*v.insert(norm)
1732                }
1733            }
1734        };
1735
1736        let cols = FjDeltaCols {
1737            delta_carry,
1738            delta_key,
1739            r_carry,
1740            r_value,
1741        };
1742        if dense {
1743            match self.provider.fj_delta_novel_u32_recorded(
1744                delta_buf,
1745                static_norm,
1746                full_buf,
1747                cols,
1748                domain,
1749                launch_stream,
1750            ) {
1751                Ok(novel) => {
1752                    self.factorized_delta_dispatch_count += 1;
1753                    Ok(Some(novel))
1754                }
1755                Err(err) => wcoj_decline_on_error(
1756                    &mut self.wcoj_error_decline_count,
1757                    "factorized-delta",
1758                    err,
1759                ),
1760            }
1761        } else {
1762            // Sparse route: cap the conservative hash table at half the
1763            // device budget; over that, the entry returns Ok(None) and
1764            // we fall back to the legacy hash-join → diff path.
1765            let max_table_bytes =
1766                factorized_delta_max_table_bytes(self.provider.memory().budget().device_bytes);
1767            match self.provider.fj_delta_sparse_novel_u32_recorded(
1768                delta_buf,
1769                static_norm,
1770                full_buf,
1771                cols,
1772                max_table_bytes,
1773                launch_stream,
1774            ) {
1775                Ok(Some(novel)) => {
1776                    self.factorized_delta_dispatch_count += 1;
1777                    Ok(Some(novel))
1778                }
1779                Ok(None) => Ok(None),
1780                Err(err) => wcoj_decline_on_error(
1781                    &mut self.wcoj_error_decline_count,
1782                    "factorized-delta",
1783                    err,
1784                ),
1785            }
1786        }
1787    }
1788
1789    /// Dispatch a general `MultiWayJoin` (any shape WITHOUT a
1790    /// dedicated kernel) through the Free Join frontier engine. Runs
1791    /// after the triangle/4-cycle/k-clique dispatchers in
1792    /// `execute_wcoj_or_fallback_node`, and accepts ONLY nodes
1793    /// carrying `MultiwayPlan::FreeJoin` — the general promoter's
1794    /// provenance marker guaranteeing `output_columns` lives in the
1795    /// concatenated-inputs column space (dedicated promoters reorder
1796    /// `inputs` canonically, so positional interpretation of their
1797    /// nodes would permute the head). The plan is derived
1798    /// `binary2fj`-style over the node's slot order with
1799    /// earliest-node probe pushing (paper §4.1); probe keys must form
1800    /// a PREFIX of each atom's column order (flat sorted tries
1801    /// consume columns physically left-to-right) — non-prefix bodies
1802    /// decline silently to the fallback. Pipeline errors route
1803    /// through [`wcoj_decline_on_error`] ("free-join" stage).
1804    pub(super) fn try_dispatch_free_join(&mut self, node: &RirNode) -> Result<Option<CudaBuffer>> {
1805        use xlog_cuda::provider::{FjNode, FjPlan, FjSubAtom};
1806
1807        if free_join_disabled() {
1808            return Ok(None);
1809        }
1810        let RirNode::MultiWayJoin {
1811            inputs,
1812            slot_vars,
1813            output_columns,
1814            plan,
1815            ..
1816        } = node
1817        else {
1818            return Ok(None);
1819        };
1820        // Provenance gate (design §3): accept ONLY nodes the general
1821        // multiway promoter marked `MultiwayPlan::FreeJoin`. Their
1822        // construction guarantees `inputs` are the fallback's Scan
1823        // leaves in traversal order, so `output_columns` (fallback
1824        // projection space, the universal MultiWayJoin convention)
1825        // coincides with the concatenated-inputs space this
1826        // dispatcher projects from. Dedicated-shape promoters
1827        // (triangle / 4-cycle / K-clique) reorder `inputs`
1828        // canonically — interpreting their `output_columns`
1829        // positionally would permute the head — and they carry
1830        // `None` / `WcojWithPlan` / `PlannedHashRoute`, so the gate
1831        // also subsumes the dedicated-shape carve-out.
1832        if !matches!(plan, Some(MultiwayPlan::FreeJoin)) {
1833            return Ok(None);
1834        }
1835        if inputs.len() < 3 {
1836            return Ok(None);
1837        }
1838        // Resolve scans -> store buffers; all columns across all
1839        // inputs must share ONE width class — u32/Symbol or u64
1840        // (mixed widths and other types decline; the engine's flat
1841        // sorted-range tries are width-uniform per execution).
1842        let mut bufs: Vec<&CudaBuffer> = Vec::with_capacity(inputs.len());
1843        let mut all_u32 = true;
1844        let mut all_u64 = true;
1845        for input in inputs {
1846            let RirNode::Scan { rel } = input else {
1847                return Ok(None);
1848            };
1849            let name = match self.get_rel_name(*rel) {
1850                Some(s) => s.to_string(),
1851                None => return Ok(None),
1852            };
1853            let Some(buf) = self.store.get(&name) else {
1854                return Ok(None);
1855            };
1856            for i in 0..buf.arity() {
1857                match buf.schema().column_type(i) {
1858                    Some(ScalarType::U32 | ScalarType::Symbol) => all_u64 = false,
1859                    Some(ScalarType::U64) => all_u32 = false,
1860                    _ => return Ok(None),
1861                }
1862            }
1863            bufs.push(buf);
1864        }
1865        if !all_u32 && !all_u64 {
1866            return Ok(None);
1867        }
1868        // Fail-open cost-model loss veto: decline Free Join to the binary
1869        // fallback only when the cost model has full stats AND the join
1870        // is provably small (largest input below the WCOJ-worthwhile
1871        // threshold), the measured 1.7–2.0× cost-of-generality region.
1872        // Stats absent / any large input → FJ proceeds (every measured
1873        // win preserved). Inputs are all Scans (checked above).
1874        {
1875            let slot_rels: Vec<RelId> = inputs
1876                .iter()
1877                .filter_map(|i| match i {
1878                    RirNode::Scan { rel } => Some(*rel),
1879                    _ => None,
1880                })
1881                .collect();
1882            let model = super::wcoj_cost_model::build_wcoj_cost_model(&self.config);
1883            let width = if all_u32 {
1884                WcojKeyWidth::FourByte
1885            } else {
1886                WcojKeyWidth::EightByte
1887            };
1888            let ctx = super::wcoj_cost_model::WcojDispatchCtx {
1889                stats: &self.stats,
1890                launch_stream: StreamId::DEFAULT,
1891                width,
1892                slot_rels: &slot_rels,
1893            };
1894            if model.factorized_loss_veto(&ctx) {
1895                return Ok(None);
1896            }
1897        }
1898        // Dense variable ids: remap slot_vars' class ids to 0..n.
1899        let mut class_to_var: Vec<u32> = Vec::new();
1900        let mut dense = |class: u32| -> usize {
1901            match class_to_var.iter().position(|c| *c == class) {
1902                Some(i) => i,
1903                None => {
1904                    class_to_var.push(class);
1905                    class_to_var.len() - 1
1906                }
1907            }
1908        };
1909        let mut atom_vars: Vec<Vec<usize>> = Vec::with_capacity(slot_vars.len());
1910        for (i, cols) in slot_vars.iter().enumerate() {
1911            if cols.len() != bufs[i].arity() {
1912                return Ok(None);
1913            }
1914            let mut vars = Vec::with_capacity(cols.len());
1915            for c in cols {
1916                let Some(class) = c else { return Ok(None) };
1917                vars.push(dense(*class));
1918            }
1919            atom_vars.push(vars);
1920        }
1921        let num_vars = class_to_var.len();
1922        // Prefix-key-joinable order planner (decline-or-reorder).
1923        // Free Join's probe-key rule forces a left-deep prefix in COLUMN
1924        // order, so a bad atom order can materialize a large intermediate even
1925        // when the result is tiny (a measured worst case is ~3x peak vs
1926        // binary). The planner is a
1927        // safety net: it keeps the traversal order when it is already
1928        // competitive with the binary plan (every winning fixture untouched),
1929        // reorders to a better prefix-key-joinable order when one exists, or
1930        // declines to the binary fallback when none is competitive.
1931        // Cardinalities are the ground-truth row counts of the buffers we are
1932        // about to join (NOT StatsManager — always available, never activates
1933        // the loss veto on statless winners); per-pair join estimates consult
1934        // StatsManager when stats are populated. Only the CardinalityAware
1935        // model plans (SkewClassifier opt-out keeps the traversal order).
1936        let order: Vec<usize> = {
1937            let slot_rels: Vec<RelId> = inputs
1938                .iter()
1939                .filter_map(|i| match i {
1940                    RirNode::Scan { rel } => Some(*rel),
1941                    _ => None,
1942                })
1943                .collect();
1944            let cards: Vec<u64> = bufs.iter().map(|b| b.num_rows()).collect();
1945            let model = super::wcoj_cost_model::build_wcoj_cost_model(&self.config);
1946            let width = if all_u32 {
1947                WcojKeyWidth::FourByte
1948            } else {
1949                WcojKeyWidth::EightByte
1950            };
1951            let ctx = super::wcoj_cost_model::WcojDispatchCtx {
1952                stats: &self.stats,
1953                launch_stream: StreamId::DEFAULT,
1954                width,
1955                slot_rels: &slot_rels,
1956            };
1957            match model.plan_free_join_order(&ctx, &atom_vars, &cards) {
1958                super::wcoj_cost_model::FjOrderDecision::Decline => return Ok(None),
1959                super::wcoj_cost_model::FjOrderDecision::Reorder(o) => o,
1960                super::wcoj_cost_model::FjOrderDecision::KeepDefault => {
1961                    (0..atom_vars.len()).collect()
1962                }
1963            }
1964        };
1965        // binary2fj over the planned order with earliest-node probe pushing:
1966        // each atom's bound-variable PREFIX probes the earliest node
1967        // after which its keys are available; the unbound suffix covers
1968        // a new node. Repeated variables within one cover decline (the
1969        // provider's rebind check would reject them).
1970        let mut bound_at: Vec<Option<usize>> = vec![None; num_vars]; // var -> node idx
1971        let mut nodes: Vec<FjNode> = Vec::new();
1972        // Process atoms in the planned order; `i` stays the ORIGINAL input
1973        // index so `input_idx`/`bufs` indexing and the head projection
1974        // (`col_to_var`, built in original order below) remain correct — only
1975        // the prefix-materialization order changes.
1976        for &i in &order {
1977            let vars = &atom_vars[i];
1978            let split = vars.iter().take_while(|v| bound_at[**v].is_some()).count();
1979            if vars[split..].iter().any(|v| bound_at[*v].is_some()) {
1980                // A bound variable after an unbound one: the trie order
1981                // cannot consume it as a key — non-prefix body.
1982                return Ok(None);
1983            }
1984            if split > 0 {
1985                let probe = FjSubAtom {
1986                    input_idx: i,
1987                    var_positions: vars[..split].to_vec(),
1988                };
1989                if nodes.is_empty() {
1990                    return Ok(None);
1991                }
1992                let target = vars[..split]
1993                    .iter()
1994                    .map(|v| bound_at[*v].expect("prefix vars are bound"))
1995                    .max()
1996                    .expect("split > 0");
1997                nodes[target].probes.push(probe);
1998            }
1999            if split < vars.len() {
2000                let cover_vars = vars[split..].to_vec();
2001                let mut seen = HashSet::new();
2002                if !cover_vars.iter().all(|v| seen.insert(*v)) {
2003                    return Ok(None);
2004                }
2005                let k = nodes.len();
2006                for v in &cover_vars {
2007                    bound_at[*v] = Some(k);
2008                }
2009                nodes.push(FjNode {
2010                    cover: FjSubAtom {
2011                        input_idx: i,
2012                        var_positions: cover_vars,
2013                    },
2014                    probes: Vec::new(),
2015                });
2016            } else if nodes.is_empty() {
2017                return Ok(None);
2018            }
2019        }
2020        // Head projection: map join-tree output columns (concatenated
2021        // input columns in slot order) to variable ids.
2022        let mut col_to_var: Vec<usize> = Vec::new();
2023        for vars in &atom_vars {
2024            col_to_var.extend(vars.iter().copied());
2025        }
2026        let mut output_vars: Vec<usize> = Vec::with_capacity(output_columns.len());
2027        for oc in output_columns {
2028            let ProjectExpr::Column(c) = oc else {
2029                return Ok(None);
2030            };
2031            let Some(v) = col_to_var.get(*c) else {
2032                return Ok(None);
2033            };
2034            output_vars.push(*v);
2035        }
2036        let fj_plan = FjPlan {
2037            num_vars,
2038            nodes,
2039            output_vars,
2040        };
2041        if self.provider.memory().runtime().is_none() {
2042            return Ok(None);
2043        }
2044        let Some(launch_stream) = self.wcoj_dispatch_stream_or_init() else {
2045            return Ok(None);
2046        };
2047        let outcome = if all_u32 {
2048            self.provider
2049                .free_join_execute_u32_recorded(&bufs, &fj_plan, launch_stream)
2050        } else {
2051            self.provider
2052                .free_join_execute_u64_recorded(&bufs, &fj_plan, launch_stream)
2053        };
2054        match outcome {
2055            Ok(buf) => {
2056                self.free_join_dispatch_count += 1;
2057                Ok(Some(buf))
2058            }
2059            Err(err) => wcoj_decline_on_error(&mut self.wcoj_error_decline_count, "free-join", err),
2060        }
2061    }
2062
2063    /// Factorized Free Join count-by-root: fused dispatch
2064    /// for `count` aggregates over FreeJoin-marked general multiway
2065    /// bodies. The plan is derived like [`Self::try_dispatch_free_join`],
2066    /// with one refinement:
2067    /// trailing cover variables PRIVATE to their atom (single global
2068    /// occurrence, not the group key) are left unconsumed — the
2069    /// engine multiplies their live trie-range lengths instead of
2070    /// expanding the frontier (the d-representation count). Count
2071    /// semantics match the unfused pipeline exactly: the lowered
2072    /// group input is a non-deduplicating projection of the join
2073    /// output, so both paths count distinct full body bindings.
2074    /// u32/Symbol width only (the recorded groupby's engine-wide key
2075    /// support); every decline silently leaves the unfused path to
2076    /// run.
2077    fn try_dispatch_free_join_count(
2078        &mut self,
2079        node: &RirNode,
2080        group_cols: &[ProjectExpr],
2081    ) -> Result<Option<CudaBuffer>> {
2082        use xlog_cuda::provider::{FjNode, FjPlan, FjSubAtom};
2083
2084        if free_join_disabled() {
2085            return Ok(None);
2086        }
2087        let RirNode::MultiWayJoin {
2088            inputs,
2089            slot_vars,
2090            plan,
2091            ..
2092        } = node
2093        else {
2094            return Ok(None);
2095        };
2096        if !matches!(plan, Some(MultiwayPlan::FreeJoin)) {
2097            return Ok(None);
2098        }
2099        if inputs.len() < 3 {
2100            return Ok(None);
2101        }
2102        let mut bufs: Vec<&CudaBuffer> = Vec::with_capacity(inputs.len());
2103        for input in inputs {
2104            let RirNode::Scan { rel } = input else {
2105                return Ok(None);
2106            };
2107            let name = match self.get_rel_name(*rel) {
2108                Some(s) => s.to_string(),
2109                None => return Ok(None),
2110            };
2111            let Some(buf) = self.store.get(&name) else {
2112                return Ok(None);
2113            };
2114            let four_byte = (0..buf.arity()).all(|i| {
2115                matches!(
2116                    buf.schema().column_type(i),
2117                    Some(ScalarType::U32 | ScalarType::Symbol)
2118                )
2119            });
2120            if !four_byte {
2121                return Ok(None);
2122            }
2123            bufs.push(buf);
2124        }
2125        // Dense variable ids (same scheme as the materialize
2126        // dispatcher).
2127        let mut class_to_var: Vec<u32> = Vec::new();
2128        let mut dense = |class: u32| -> usize {
2129            match class_to_var.iter().position(|c| *c == class) {
2130                Some(i) => i,
2131                None => {
2132                    class_to_var.push(class);
2133                    class_to_var.len() - 1
2134                }
2135            }
2136        };
2137        let mut atom_vars: Vec<Vec<usize>> = Vec::with_capacity(slot_vars.len());
2138        for (i, cols) in slot_vars.iter().enumerate() {
2139            if cols.len() != bufs[i].arity() {
2140                return Ok(None);
2141            }
2142            let mut vars = Vec::with_capacity(cols.len());
2143            for c in cols {
2144                let Some(class) = c else { return Ok(None) };
2145                vars.push(dense(*class));
2146            }
2147            atom_vars.push(vars);
2148        }
2149        let num_vars = class_to_var.len();
2150        // Group key: the group projection's column 0 through the
2151        // concatenated-inputs column space (FreeJoin provenance).
2152        let mut col_to_var: Vec<usize> = Vec::new();
2153        for vars in &atom_vars {
2154            col_to_var.extend(vars.iter().copied());
2155        }
2156        let Some(ProjectExpr::Column(key_col)) = group_cols.first() else {
2157            return Ok(None);
2158        };
2159        let Some(&group_var) = col_to_var.get(*key_col) else {
2160            return Ok(None);
2161        };
2162        // Variable occurrence counts: single-occurrence variables are
2163        // private to their atom and (unless they key the group)
2164        // prunable as trailing covers.
2165        let mut occurrences = vec![0usize; num_vars];
2166        for vars in &atom_vars {
2167            for &v in vars {
2168                occurrences[v] += 1;
2169            }
2170        }
2171        // binary2fj with trailing-private pruning.
2172        let mut bound_at: Vec<Option<usize>> = vec![None; num_vars];
2173        let mut nodes: Vec<FjNode> = Vec::new();
2174        for (i, vars) in atom_vars.iter().enumerate() {
2175            let split = vars.iter().take_while(|v| bound_at[**v].is_some()).count();
2176            if vars[split..].iter().any(|v| bound_at[*v].is_some()) {
2177                // Non-prefix body (see the materialize dispatcher).
2178                return Ok(None);
2179            }
2180            let mut keep_end = vars.len();
2181            while keep_end > split {
2182                let v = vars[keep_end - 1];
2183                if occurrences[v] == 1 && v != group_var {
2184                    keep_end -= 1;
2185                } else {
2186                    break;
2187                }
2188            }
2189            if split == 0 && keep_end == 0 {
2190                // Fully-private atom: nothing binds or probes it
2191                // (cannot arise from the promoter — keyless joins
2192                // are rejected there — but decline defensively).
2193                return Ok(None);
2194            }
2195            if split > 0 {
2196                let probe = FjSubAtom {
2197                    input_idx: i,
2198                    var_positions: vars[..split].to_vec(),
2199                };
2200                if nodes.is_empty() {
2201                    return Ok(None);
2202                }
2203                let target = vars[..split]
2204                    .iter()
2205                    .map(|v| bound_at[*v].expect("prefix vars are bound"))
2206                    .max()
2207                    .expect("split > 0");
2208                nodes[target].probes.push(probe);
2209            }
2210            if split < keep_end {
2211                let cover_vars = vars[split..keep_end].to_vec();
2212                let mut seen = HashSet::new();
2213                if !cover_vars.iter().all(|v| seen.insert(*v)) {
2214                    return Ok(None);
2215                }
2216                let k = nodes.len();
2217                for v in &cover_vars {
2218                    bound_at[*v] = Some(k);
2219                }
2220                nodes.push(FjNode {
2221                    cover: FjSubAtom {
2222                        input_idx: i,
2223                        var_positions: cover_vars,
2224                    },
2225                    probes: Vec::new(),
2226                });
2227            } else if nodes.is_empty() {
2228                return Ok(None);
2229            }
2230        }
2231        if bound_at[group_var].is_none() {
2232            return Ok(None);
2233        }
2234        let fj_plan = FjPlan {
2235            num_vars,
2236            nodes,
2237            output_vars: vec![group_var],
2238        };
2239        if self.provider.memory().runtime().is_none() {
2240            return Ok(None);
2241        }
2242        let Some(launch_stream) = self.wcoj_dispatch_stream_or_init() else {
2243            return Ok(None);
2244        };
2245        match self
2246            .provider
2247            .free_join_count_by_root_u32_recorded(&bufs, &fj_plan, launch_stream)
2248        {
2249            Ok(buf) => {
2250                self.free_join_dispatch_count += 1;
2251                self.wcoj_groupby_fusion_dispatch_count += 1;
2252                Ok(Some(buf))
2253            }
2254            Err(err) => {
2255                wcoj_decline_on_error(&mut self.wcoj_error_decline_count, "free-join-count", err)
2256            }
2257        }
2258    }
2259
2260    /// Count of times the fused group-by-root count hook produced a
2261    /// result and the executor installed it (vs. silently falling back to
2262    /// the materialize+groupby path with the same answer).
2263    pub fn wcoj_groupby_fusion_dispatch_count(&self) -> u64 {
2264        self.wcoj_groupby_fusion_dispatch_count
2265    }
2266
2267    /// Aggregate-fused WCOJ: dispatch
2268    /// `GroupBy { Project { MultiWayJoin(triangle) }, key_cols: [0],
2269    /// aggs: [(_, Count | Sum | Min | Max)] }` through the fused
2270    /// group-by-root kernels, which never materialize the triangle rows.
2271    /// The group key column 0 is the variable-order root X in the canonical
2272    /// triangle output, the condition under which one-pass aggregate
2273    /// propagation over the variable order is sound. For Sum/Min/Max the
2274    /// aggregate value column must itself map to a triangle output variable
2275    /// (Y or Z; plain U32 on the 4-byte path, uniform U64 on the 8-byte
2276    /// path) so the kernel can read it during traversal; Count ignores the
2277    /// value column. Every structural mismatch (other
2278    /// keys/aggs, computed projections, value column not Y/Z or not U32,
2279    /// non-triangle shape, non-4-byte width, missing buffers/runtime, kill
2280    /// switch) returns `Ok(None)` — silent decline to the existing
2281    /// materialize+groupby path. Pipeline errors route through
2282    /// [`wcoj_decline_on_error`] (counted; `XLOG_WCOJ_STRICT=1` propagates).
2283    pub(super) fn try_dispatch_wcoj_groupby_root_agg(
2284        &mut self,
2285        input: &RirNode,
2286        key_cols: &[usize],
2287        aggs: &[(usize, xlog_core::AggOp)],
2288    ) -> Result<Option<CudaBuffer>> {
2289        use xlog_core::AggOp;
2290        if wcoj_groupby_fusion_disabled() {
2291            return Ok(None);
2292        }
2293        if key_cols != [0] {
2294            return Ok(None);
2295        }
2296        if aggs.len() != 1 {
2297            return Ok(None);
2298        }
2299        let (agg_col, agg_op) = aggs[0];
2300        if !matches!(agg_op, AggOp::Count | AggOp::Sum | AggOp::Min | AggOp::Max) {
2301            return Ok(None);
2302        }
2303        let RirNode::Project {
2304            input: multiway,
2305            columns,
2306        } = input
2307        else {
2308            return Ok(None);
2309        };
2310        // The group projection must contain only plain column references.
2311        if columns.is_empty() || !columns.iter().all(|c| matches!(c, ProjectExpr::Column(_))) {
2312            return Ok(None);
2313        }
2314        // Triangle and 4-cycle place the variable-order root at output
2315        // position 0 by construction, so their group key must be
2316        // Column(0). The K-clique root is plan-dependent; its branch
2317        // validates the planned root itself.
2318        let key_is_col0 = matches!(columns[0], ProjectExpr::Column(0));
2319        // For value-reading aggregates the value column must map to a
2320        // non-key join output variable the per-shape kernel can see
2321        // (triangle: Y/Z; 4-cycle: X/Y/Z). Resolve the raw output column
2322        // here (the key itself and non-column refs decline); the
2323        // per-shape mapping happens after shape match. Count never reads
2324        // the value column, so any pass-through value columns are
2325        // admissible.
2326        let agg_value_col = if matches!(agg_op, AggOp::Count) {
2327            None
2328        } else {
2329            match columns.get(agg_col) {
2330                Some(ProjectExpr::Column(c)) if *c >= 1 => Some(*c),
2331                _ => return Ok(None),
2332            }
2333        };
2334        let Some(matched) = match_multiway_triangle(multiway) else {
2335            // 4-cycle sibling of the triangle fusion (count + sum/min/max).
2336            // The 4-cycle root is output column 0 by
2337            // construction, so gate on the key here like the triangle.
2338            if key_is_col0 {
2339                if let Some(buf) =
2340                    self.try_dispatch_wcoj_groupby_root_agg_4cycle(multiway, agg_op, agg_value_col)?
2341                {
2342                    return Ok(Some(buf));
2343                }
2344            }
2345            // K-clique (K = 5, 6) count sibling. The clique root is
2346            // plan-dependent, so the helper validates the group key against
2347            // the planned root itself instead of key_is_col0. Count-only
2348            // (no fused clique sum/min/max kernels).
2349            if !matches!(agg_op, AggOp::Count) {
2350                return Ok(None);
2351            }
2352            if let Some(buf) =
2353                self.try_dispatch_wcoj_groupby_root_count_clique(multiway, columns)?
2354            {
2355                return Ok(Some(buf));
2356            }
2357            // Factorized Free Join count-by-root for
2358            // FreeJoin-marked general multiway bodies (any shape the
2359            // dedicated fused kernels above declined).
2360            return self.try_dispatch_free_join_count(multiway, columns);
2361        };
2362        // Triangle output space: col 1 = Y, col 2 = Z. Anything else
2363        // (e.g. an out-of-range ref) declines.
2364        let agg_value = match agg_value_col {
2365            None => None,
2366            Some(1) => Some(WcojRootAggValue::Y),
2367            Some(2) => Some(WcojRootAggValue::Z),
2368            Some(_) => return Ok(None),
2369        };
2370        if !key_is_col0 {
2371            return Ok(None);
2372        }
2373        let name_xy = match self.get_rel_name(matched.rel_xy) {
2374            Some(s) => s.to_string(),
2375            None => return Ok(None),
2376        };
2377        let name_yz = match self.get_rel_name(matched.rel_yz) {
2378            Some(s) => s.to_string(),
2379            None => return Ok(None),
2380        };
2381        let name_xz = match self.get_rel_name(matched.rel_xz) {
2382            Some(s) => s.to_string(),
2383            None => return Ok(None),
2384        };
2385        let buf_xy = match self.store.get(&name_xy) {
2386            Some(b) => b,
2387            None => return Ok(None),
2388        };
2389        let buf_yz = match self.store.get(&name_yz) {
2390            Some(b) => b,
2391            None => return Ok(None),
2392        };
2393        let buf_xz = match self.store.get(&name_xz) {
2394            Some(b) => b,
2395            None => return Ok(None),
2396        };
2397        let width = match (
2398            classify_two_col_wcoj_width(buf_xy),
2399            classify_two_col_wcoj_width(buf_yz),
2400            classify_two_col_wcoj_width(buf_xz),
2401        ) {
2402            (
2403                Some(WcojKeyWidth::FourByte),
2404                Some(WcojKeyWidth::FourByte),
2405                Some(WcojKeyWidth::FourByte),
2406            ) => WcojKeyWidth::FourByte,
2407            (
2408                Some(WcojKeyWidth::EightByte),
2409                Some(WcojKeyWidth::EightByte),
2410                Some(WcojKeyWidth::EightByte),
2411            ) => WcojKeyWidth::EightByte,
2412            _ => return Ok(None),
2413        };
2414        // Fail-open cost-model loss veto: decline the FUSED triangle
2415        // aggregate to the unfused materialize+groupby only when the cost
2416        // model has stats AND the triangle is provably small — the
2417        // small-case the base triangle cost model already declines, which
2418        // the fused path otherwise bypasses. Fail-open: stats absent / any
2419        // large input → fuse (every measured fused-aggregate win preserved).
2420        // The rarer
2421        // fused 4-cycle/K-clique sub-paths inherit their base shapes'
2422        // gating posture and are not separately vetoed here.
2423        {
2424            let slot_rels = [matched.rel_xy, matched.rel_yz, matched.rel_xz];
2425            let model = super::wcoj_cost_model::build_wcoj_cost_model(&self.config);
2426            let ctx = super::wcoj_cost_model::WcojDispatchCtx {
2427                stats: &self.stats,
2428                launch_stream: StreamId::DEFAULT,
2429                width,
2430                slot_rels: &slot_rels,
2431            };
2432            if model.factorized_loss_veto(&ctx) {
2433                return Ok(None);
2434            }
2435        }
2436        // Sum/Min/Max are arithmetic: on the 4-byte path the columns
2437        // supplying the value must be plain U32 (Symbol ids are not
2438        // summable/orderable data — and the unfused groupby rejects Symbol
2439        // values too, so declining keeps both paths aligned). On the
2440        // 8-byte path the width classifier already guarantees uniform U64
2441        // columns, which the u64 fused kernels consume directly.
2442        if matches!(width, WcojKeyWidth::FourByte) {
2443            match agg_value {
2444                Some(WcojRootAggValue::Y) => {
2445                    if buf_xy.schema().column_type(1) != Some(xlog_core::ScalarType::U32) {
2446                        return Ok(None);
2447                    }
2448                }
2449                Some(WcojRootAggValue::Z) => {
2450                    if buf_yz.schema().column_type(1) != Some(xlog_core::ScalarType::U32)
2451                        || buf_xz.schema().column_type(1) != Some(xlog_core::ScalarType::U32)
2452                    {
2453                        return Ok(None);
2454                    }
2455                }
2456                None => {}
2457            }
2458        }
2459        if self.provider.memory().runtime().is_none() {
2460            return Ok(None);
2461        }
2462        let Some(launch_stream) = self.wcoj_dispatch_stream_or_init() else {
2463            return Ok(None);
2464        };
2465        let result = match (agg_value, width) {
2466            (None, WcojKeyWidth::FourByte) => {
2467                self.provider.wcoj_triangle_groupby_root_count_u32_recorded(
2468                    buf_xy,
2469                    buf_yz,
2470                    buf_xz,
2471                    wcoj_block_work_unit(),
2472                    launch_stream,
2473                )
2474            }
2475            (None, WcojKeyWidth::EightByte) => {
2476                self.provider.wcoj_triangle_groupby_root_count_u64_recorded(
2477                    buf_xy,
2478                    buf_yz,
2479                    buf_xz,
2480                    wcoj_block_work_unit(),
2481                    launch_stream,
2482                )
2483            }
2484            (Some(value), WcojKeyWidth::FourByte) => {
2485                self.provider.wcoj_triangle_groupby_root_agg_u32_recorded(
2486                    buf_xy,
2487                    buf_yz,
2488                    buf_xz,
2489                    agg_op,
2490                    value,
2491                    wcoj_block_work_unit(),
2492                    launch_stream,
2493                )
2494            }
2495            // U64-key sum/min/max through the u64 fused
2496            // kernels (value columns are uniform U64 by classification).
2497            (Some(value), WcojKeyWidth::EightByte) => {
2498                self.provider.wcoj_triangle_groupby_root_agg_u64_recorded(
2499                    buf_xy,
2500                    buf_yz,
2501                    buf_xz,
2502                    agg_op,
2503                    value,
2504                    wcoj_block_work_unit(),
2505                    launch_stream,
2506                )
2507            }
2508        };
2509        match result {
2510            Ok(buf) => {
2511                self.wcoj_groupby_fusion_dispatch_count += 1;
2512                Ok(Some(buf))
2513            }
2514            Err(err) => {
2515                wcoj_decline_on_error(&mut self.wcoj_error_decline_count, "groupby-fusion", err)
2516            }
2517        }
2518    }
2519
2520    /// Aggregate-fused WCOJ, 4-cycle: dispatch the inner
2521    /// `MultiWayJoin(4-cycle)` of a count/sum/min/max-by-root aggregate
2522    /// through the fused group-by-root kernels, which never materialize
2523    /// the 4-cycle rows. Both accepted `output_columns` layouts place the
2524    /// variable-order root W at output position 0, so the caller's
2525    /// `key_cols == [0]` + `columns[0] == Column(0)` checks pin the group
2526    /// key to W — the soundness condition for one-pass aggregate
2527    /// propagation.
2528    ///
2529    /// Gating decision: the fused path
2530    /// mirrors the triangle fusion — enabled by default behind the shared
2531    /// `XLOG_DISABLE_WCOJ_GROUPBY_FUSION` kill switch (checked by the
2532    /// caller). The `XLOG_USE_WCOJ_4CYCLE*` gates govern only the
2533    /// NON-aggregate 4-cycle materialize dispatch (opt-in pending its own
2534    /// default-on evidence); they are intentionally not consulted here,
2535    /// because a declined or kill-switched fusion falls back to that
2536    /// independently-gated path (default: embedded binary fallback).
2537    ///
2538    /// Value-column mapping (same rules as the triangle): for
2539    /// Sum/Min/Max the aggregate value must map to a 4-cycle output
2540    /// variable the kernel can read during traversal — X (col 1, from
2541    /// e1.col1), Y (col 2, from e2.col1) or Z (col 3, from e3.col1) —
2542    /// with plain U32 type. Symbol values decline (the unfused groupby
2543    /// rejects them with the same value-type error, so fused and
2544    /// kill-switch runs fail identically). Count admits any pass-through
2545    /// value column and, uniquely, uniform U64 keys.
2546    ///
2547    /// Sum/Min/Max are 4-byte-only; u64-key 4-cycle sum/min/max fusion is
2548    /// deferred and declines silently. Pipeline errors route through
2549    /// [`wcoj_decline_on_error`] (counted; `XLOG_WCOJ_STRICT=1`
2550    /// propagates).
2551    fn try_dispatch_wcoj_groupby_root_agg_4cycle(
2552        &mut self,
2553        multiway: &RirNode,
2554        agg_op: xlog_core::AggOp,
2555        agg_value_col: Option<usize>,
2556    ) -> Result<Option<CudaBuffer>> {
2557        use xlog_core::AggOp;
2558        let Some(matched) = match_multiway_4cycle(multiway) else {
2559            return Ok(None);
2560        };
2561        // 4-cycle output space: col 1 = X, col 2 = Y, col 3 = Z. Anything
2562        // else (e.g. an out-of-range ref) declines.
2563        let agg_value = match agg_value_col {
2564            None => None,
2565            Some(1) => Some(Wcoj4CycleRootAggValue::X),
2566            Some(2) => Some(Wcoj4CycleRootAggValue::Y),
2567            Some(3) => Some(Wcoj4CycleRootAggValue::Z),
2568            Some(_) => return Ok(None),
2569        };
2570        let name_e1 = match self.get_rel_name(matched.rel_e1) {
2571            Some(s) => s.to_string(),
2572            None => return Ok(None),
2573        };
2574        let name_e2 = match self.get_rel_name(matched.rel_e2) {
2575            Some(s) => s.to_string(),
2576            None => return Ok(None),
2577        };
2578        let name_e3 = match self.get_rel_name(matched.rel_e3) {
2579            Some(s) => s.to_string(),
2580            None => return Ok(None),
2581        };
2582        let name_e4 = match self.get_rel_name(matched.rel_e4) {
2583            Some(s) => s.to_string(),
2584            None => return Ok(None),
2585        };
2586        let buf_e1 = match self.store.get(&name_e1) {
2587            Some(b) => b,
2588            None => return Ok(None),
2589        };
2590        let buf_e2 = match self.store.get(&name_e2) {
2591            Some(b) => b,
2592            None => return Ok(None),
2593        };
2594        let buf_e3 = match self.store.get(&name_e3) {
2595            Some(b) => b,
2596            None => return Ok(None),
2597        };
2598        let buf_e4 = match self.store.get(&name_e4) {
2599            Some(b) => b,
2600            None => return Ok(None),
2601        };
2602        let width = match (
2603            classify_two_col_wcoj_width(buf_e1),
2604            classify_two_col_wcoj_width(buf_e2),
2605            classify_two_col_wcoj_width(buf_e3),
2606            classify_two_col_wcoj_width(buf_e4),
2607        ) {
2608            (
2609                Some(WcojKeyWidth::FourByte),
2610                Some(WcojKeyWidth::FourByte),
2611                Some(WcojKeyWidth::FourByte),
2612                Some(WcojKeyWidth::FourByte),
2613            ) => WcojKeyWidth::FourByte,
2614            (
2615                Some(WcojKeyWidth::EightByte),
2616                Some(WcojKeyWidth::EightByte),
2617                Some(WcojKeyWidth::EightByte),
2618                Some(WcojKeyWidth::EightByte),
2619            ) => WcojKeyWidth::EightByte,
2620            _ => return Ok(None),
2621        };
2622        // Sum/Min/Max are 4-byte-only (u64-key 4-cycle sum/min/max fusion
2623        // is deferred and declines to materialize+groupby).
2624        if agg_value.is_some() && width != WcojKeyWidth::FourByte {
2625            return Ok(None);
2626        }
2627        // Sum/Min/Max are arithmetic: the column supplying the value must
2628        // be plain U32 (Symbol ids are not summable/orderable data — and
2629        // the unfused groupby rejects Symbol values too, so declining
2630        // keeps both paths aligned). The checked column matches the
2631        // materialized (W, X, Y, Z) baseline schema's type source
2632        // (`build_4cycle_head_schema`): X from e1.col1, Y from e2.col1,
2633        // Z from e3.col1.
2634        let value_source = match agg_value {
2635            None => None,
2636            Some(Wcoj4CycleRootAggValue::X) => Some(buf_e1),
2637            Some(Wcoj4CycleRootAggValue::Y) => Some(buf_e2),
2638            Some(Wcoj4CycleRootAggValue::Z) => Some(buf_e3),
2639        };
2640        if let Some(src) = value_source {
2641            if src.schema().column_type(1) != Some(xlog_core::ScalarType::U32) {
2642                return Ok(None);
2643            }
2644        }
2645        if self.provider.memory().runtime().is_none() {
2646            return Ok(None);
2647        }
2648        let Some(launch_stream) = self.wcoj_dispatch_stream_or_init() else {
2649            return Ok(None);
2650        };
2651        debug_assert!(
2652            agg_value.is_some() || matches!(agg_op, AggOp::Count),
2653            "non-Count aggregates resolve a value column above"
2654        );
2655        let result = match (agg_value, width) {
2656            (None, WcojKeyWidth::FourByte) => {
2657                self.provider.wcoj_4cycle_groupby_root_count_u32_recorded(
2658                    buf_e1,
2659                    buf_e2,
2660                    buf_e3,
2661                    buf_e4,
2662                    wcoj_block_work_unit(),
2663                    launch_stream,
2664                )
2665            }
2666            // U64-key count through the metadata-driven
2667            // segment reduction (the recorded groupby is U32/Symbol-key
2668            // only).
2669            (None, WcojKeyWidth::EightByte) => {
2670                self.provider.wcoj_4cycle_groupby_root_count_u64_recorded(
2671                    buf_e1,
2672                    buf_e2,
2673                    buf_e3,
2674                    buf_e4,
2675                    wcoj_block_work_unit(),
2676                    launch_stream,
2677                )
2678            }
2679            (Some(value), _) => self.provider.wcoj_4cycle_groupby_root_agg_u32_recorded(
2680                buf_e1,
2681                buf_e2,
2682                buf_e3,
2683                buf_e4,
2684                agg_op,
2685                value,
2686                wcoj_block_work_unit(),
2687                launch_stream,
2688            ),
2689        };
2690        match result {
2691            Ok(buf) => {
2692                self.wcoj_groupby_fusion_dispatch_count += 1;
2693                Ok(Some(buf))
2694            }
2695            Err(err) => wcoj_decline_on_error(
2696                &mut self.wcoj_error_decline_count,
2697                "groupby-fusion-4cycle",
2698                err,
2699            ),
2700        }
2701    }
2702
2703    /// Count of times the WCOJ 4-cycle hook
2704    /// produced a result and the executor installed it. Tracked
2705    /// separately from triangle so tests can pin which shape
2706    /// dispatched.
2707    pub fn wcoj_4cycle_dispatch_count(&self) -> u64 {
2708        self.wcoj_4cycle_dispatch_count
2709    }
2710
2711    /// Count of times a two-atom `ChainJoin` routed through the chain
2712    /// dispatcher instead of the embedded binary fallback.
2713    pub fn chain_dispatch_count(&self) -> u64 {
2714        self.chain_dispatch_count
2715    }
2716
2717    /// Count of times `execute_join` routed an inner-join
2718    /// to the nested-loop provider entry point because the
2719    /// eligibility predicate + Cartesian-product threshold both
2720    /// held. Tests use this counter to assert that the nested-loop path
2721    /// actually fired vs. silently falling back to hash with the
2722    /// same answer.
2723    pub fn nested_loop_dispatch_count(&self) -> u64 {
2724        self.nested_loop_dispatch_count
2725    }
2726
2727    /// ChainJoin dispatch. Shape match is done on the production
2728    /// `ChainJoin` emitted by the promoter.
2729    ///
2730    /// Route order:
2731    ///   1. sorted eligible U32/Symbol inputs -> sort-merge
2732    ///   2. threshold eligible U32/Symbol inputs -> nested loop
2733    ///   3. otherwise -> existing hash_join_v2 provider path
2734    ///
2735    /// The final projection uses the captured `output_columns`, so
2736    /// row semantics match `MultiWayJoin.fallback`.
2737    pub(super) fn try_dispatch_chain_on_body(
2738        &mut self,
2739        body: &RirNode,
2740    ) -> Result<Option<CudaBuffer>> {
2741        if !chain_dispatch_enabled() {
2742            return Ok(None);
2743        }
2744        let Some(matched) = match_chain_join(body) else {
2745            return Ok(None);
2746        };
2747
2748        let name_left = match self.get_rel_name(matched.rel_left) {
2749            Some(s) => s.to_string(),
2750            None => return Ok(None),
2751        };
2752        let name_right = match self.get_rel_name(matched.rel_right) {
2753            Some(s) => s.to_string(),
2754            None => return Ok(None),
2755        };
2756        let left = match self.store.get(&name_left) {
2757            Some(buf) => buf,
2758            None => return Ok(None),
2759        };
2760        let right = match self.store.get(&name_right) {
2761            Some(buf) => buf,
2762            None => return Ok(None),
2763        };
2764
2765        let num_left = self.provider.device_row_count(left)? as u64;
2766        let num_right = self.provider.device_row_count(right)? as u64;
2767        let in_threshold = num_left
2768            .checked_mul(num_right)
2769            .map(|p| p <= NESTED_LOOP_TOTAL_THRESHOLD)
2770            .unwrap_or(false);
2771        let four_byte = matches!(
2772            classify_two_col_wcoj_width(left),
2773            Some(WcojKeyWidth::FourByte)
2774        ) && matches!(
2775            classify_two_col_wcoj_width(right),
2776            Some(WcojKeyWidth::FourByte)
2777        );
2778
2779        let mut used_nested_loop = false;
2780        let joined = if four_byte {
2781            let left_sorted = self
2782                .provider
2783                .is_sorted_ascending_u32(left, matched.left_key)
2784                .unwrap_or(false);
2785            let right_sorted = self
2786                .provider
2787                .is_sorted_ascending_u32(right, matched.right_key)
2788                .unwrap_or(false);
2789            if left_sorted && right_sorted {
2790                if in_threshold {
2791                    self.provider.sort_merge_join_v2_inner_u32_1key(
2792                        left,
2793                        right,
2794                        matched.left_key,
2795                        matched.right_key,
2796                    )
2797                } else {
2798                    let capacity = usize::try_from(num_left.min(num_right)).unwrap_or(usize::MAX);
2799                    self.provider.sort_merge_join_v2_inner_u32_1key_bounded(
2800                        left,
2801                        right,
2802                        matched.left_key,
2803                        matched.right_key,
2804                        capacity,
2805                    )
2806                }
2807            } else if in_threshold {
2808                used_nested_loop = true;
2809                self.provider.nested_loop_join_v2_inner_u32_1key(
2810                    left,
2811                    right,
2812                    matched.left_key,
2813                    matched.right_key,
2814                )
2815            } else {
2816                self.provider.hash_join_v2(
2817                    left,
2818                    right,
2819                    &[matched.left_key],
2820                    &[matched.right_key],
2821                    CudaJoinType::Inner,
2822                )
2823            }
2824        } else {
2825            self.provider.hash_join_v2(
2826                left,
2827                right,
2828                &[matched.left_key],
2829                &[matched.right_key],
2830                CudaJoinType::Inner,
2831            )
2832        };
2833
2834        let joined = match joined {
2835            Ok(buf) => buf,
2836            Err(err) => {
2837                record_chain_fallback_equivalents(
2838                    body,
2839                    false,
2840                    &mut self.chain_fallback_scan_equivalents,
2841                    &mut self.chain_fallback_filter_equivalents,
2842                );
2843                return wcoj_decline_on_error(
2844                    &mut self.wcoj_error_decline_count,
2845                    "chain-join",
2846                    err,
2847                );
2848            }
2849        };
2850        let projected = match self.execute_project(&joined, &matched.output_columns) {
2851            Ok(buf) => buf,
2852            Err(err) => {
2853                record_chain_fallback_equivalents(
2854                    body,
2855                    false,
2856                    &mut self.chain_fallback_scan_equivalents,
2857                    &mut self.chain_fallback_filter_equivalents,
2858                );
2859                return wcoj_decline_on_error(
2860                    &mut self.wcoj_error_decline_count,
2861                    "chain-join-project",
2862                    err,
2863                );
2864            }
2865        };
2866        self.stats.record_join_result(
2867            matched.rel_left,
2868            matched.rel_right,
2869            vec![matched.left_key],
2870            vec![matched.right_key],
2871            num_left.saturating_mul(num_right),
2872            joined.num_rows(),
2873        );
2874        if used_nested_loop {
2875            self.nested_loop_dispatch_count += 1;
2876        }
2877        record_chain_fallback_equivalents(
2878            body,
2879            true,
2880            &mut self.chain_fallback_scan_equivalents,
2881            &mut self.chain_fallback_filter_equivalents,
2882        );
2883        self.chain_dispatch_count += 1;
2884        Ok(Some(projected))
2885    }
2886
2887    /// Try to dispatch a non-recursive rule
2888    /// through the GPU 4-cycle WCOJ kernel.
2889    ///
2890    /// Decision tree (highest → lowest):
2891    ///   1. Hard kill switch (`wcoj_4cycle_dispatch_disabled` /
2892    ///      `XLOG_DISABLE_WCOJ_4CYCLE=1`) → no dispatch.
2893    ///   2. Force gate (`wcoj_4cycle_dispatch=Some(true)` /
2894    ///      `XLOG_USE_WCOJ_4CYCLE=1`) → kernel runs.
2895    ///   3. Force-Some(false) → no dispatch.
2896    ///   4. Stats opt-in (config / env, default off) →
2897    ///      cardinality model decides whether the kernel runs.
2898    ///
2899    /// Returns `Ok(Some(buffer))` on dispatch; `Ok(None)`
2900    /// silently otherwise. The caller installs the buffer or
2901    /// descends into `MultiWayJoin.fallback`.
2902    pub(super) fn try_dispatch_wcoj_4cycle(
2903        &mut self,
2904        rule: &CompiledRule,
2905    ) -> Result<Option<CudaBuffer>> {
2906        // Body-keyed entry. Rule-keyed callers stay
2907        // byte-identical via this thin wrapper.
2908        self.try_dispatch_wcoj_4cycle_on_body(&rule.body)
2909    }
2910
2911    /// Body-keyed entry point: same gate / pattern-match / dispatch
2912    /// logic as `try_dispatch_wcoj_4cycle`, keyed on `body` rather
2913    /// than `&CompiledRule`. See
2914    /// `try_dispatch_wcoj_triangle_on_body` for the rationale.
2915    pub(super) fn try_dispatch_wcoj_4cycle_on_body(
2916        &mut self,
2917        body: &RirNode,
2918    ) -> Result<Option<CudaBuffer>> {
2919        // 1. Kill switch.
2920        if wcoj_4cycle_disabled(self.config.wcoj_4cycle_dispatch_disabled) {
2921            return Ok(None);
2922        }
2923        // 2. Force gate.
2924        let force_override = self.config.wcoj_4cycle_dispatch;
2925        let force_on = wcoj_4cycle_gate_enabled(force_override);
2926        let mode = if force_on {
2927            DispatchMode::Force
2928        } else {
2929            // Force-Some(false) is explicit off — adaptive does
2930            // NOT resurrect it.
2931            if matches!(force_override, Some(false)) {
2932                return Ok(None);
2933            }
2934            let adaptive_override = self.config.wcoj_4cycle_dispatch_adaptive;
2935            if wcoj_4cycle_adaptive_enabled(adaptive_override) {
2936                DispatchMode::CostModel
2937            } else {
2938                return Ok(None);
2939            }
2940        };
2941
2942        // 3. Match the canonical 4-cycle MultiWayJoin.
2943        let Some(matched) = match_multiway_4cycle(body) else {
2944            return Ok(None);
2945        };
2946
2947        // 4. Resolve rel IDs to predicate names.
2948        let name_e1 = match self.get_rel_name(matched.rel_e1) {
2949            Some(s) => s.to_string(),
2950            None => return Ok(None),
2951        };
2952        let name_e2 = match self.get_rel_name(matched.rel_e2) {
2953            Some(s) => s.to_string(),
2954            None => return Ok(None),
2955        };
2956        let name_e3 = match self.get_rel_name(matched.rel_e3) {
2957            Some(s) => s.to_string(),
2958            None => return Ok(None),
2959        };
2960        let name_e4 = match self.get_rel_name(matched.rel_e4) {
2961            Some(s) => s.to_string(),
2962            None => return Ok(None),
2963        };
2964
2965        // 5. Look up input buffers + classify their key widths.
2966        // All four slots must share the same width.
2967        let buf_e1 = match self.store.get(&name_e1) {
2968            Some(b) => b,
2969            None => return Ok(None),
2970        };
2971        let buf_e2 = match self.store.get(&name_e2) {
2972            Some(b) => b,
2973            None => return Ok(None),
2974        };
2975        let buf_e3 = match self.store.get(&name_e3) {
2976            Some(b) => b,
2977            None => return Ok(None),
2978        };
2979        let buf_e4 = match self.store.get(&name_e4) {
2980            Some(b) => b,
2981            None => return Ok(None),
2982        };
2983        let width = match (
2984            classify_two_col_wcoj_width(buf_e1),
2985            classify_two_col_wcoj_width(buf_e2),
2986            classify_two_col_wcoj_width(buf_e3),
2987            classify_two_col_wcoj_width(buf_e4),
2988        ) {
2989            (Some(a), Some(b), Some(c), Some(d)) if a == b && b == c && c == d => a,
2990            _ => return Ok(None),
2991        };
2992
2993        // 6. Resolve the cached WCOJ launch stream (shared with
2994        // triangle dispatch; the stream resolver is now
2995        // shape-agnostic).
2996        if self.provider.memory().runtime().is_none() {
2997            return Ok(None);
2998        }
2999        let launch_stream = match self.wcoj_dispatch_stream_or_init() {
3000            Some(s) => s,
3001            None => return Ok(None),
3002        };
3003
3004        // 7. Stats-backed mode: route the decision through
3005        // the cardinality WCOJ cost model.
3006        if mode == DispatchMode::CostModel {
3007            // Factory selects per RuntimeConfig precedence.
3008            let model = super::wcoj_cost_model::build_wcoj_cost_model(&self.config);
3009            let slot_rels = [
3010                matched.rel_e1,
3011                matched.rel_e2,
3012                matched.rel_e3,
3013                matched.rel_e4,
3014            ];
3015            let ctx = super::wcoj_cost_model::WcojDispatchCtx {
3016                stats: &self.stats,
3017                launch_stream,
3018                width,
3019                slot_rels: &slot_rels,
3020            };
3021            let dispatch = model.should_dispatch_4cycle(&ctx);
3022            if !dispatch {
3023                return Ok(None);
3024            }
3025        }
3026
3027        // Extract var_order. None preserves default-leader dispatch
3028        // bit-identically.
3029        let var_order_opt: Option<&VariableOrder> = match body {
3030            RirNode::MultiWayJoin { var_order, .. } => var_order.as_ref(),
3031            _ => None,
3032        };
3033
3034        // 8. Run layout (4× per slot) + 4-cycle kernel. Failure
3035        // → silent fallback per slice contract.
3036        let dispatch_result = self.run_wcoj_4cycle_pipeline(
3037            buf_e1,
3038            buf_e2,
3039            buf_e3,
3040            buf_e4,
3041            launch_stream,
3042            width,
3043            var_order_opt,
3044        );
3045        match dispatch_result {
3046            Ok(buf) => {
3047                // Record observed selectivity.
3048                // The (rel_a, rel_b, left_keys, right_keys)
3049                // pair is derived from `var_order_opt` via
3050                // `feedback_pair_from_var_order`:
3051                //   * `var_order = None` (default config) →
3052                //     canonical `(rel_e1, rel_e2)` keys
3053                //     `[1]/[0]`.
3054                //   * `var_order = Some(_)` (non-default leader) →
3055                //     rotated pair from the feedback table. 4-cycle is
3056                //     rotation-only (every cycle edge is
3057                //     `[1]/[0]` in canonical layout), so the
3058                //     keys stay `[1]/[0]` while the pair
3059                //     itself rotates.
3060                let output_rows = Self::wcoj_output_rows(&buf);
3061                let slot_rels = [
3062                    matched.rel_e1,
3063                    matched.rel_e2,
3064                    matched.rel_e3,
3065                    matched.rel_e4,
3066                ];
3067                self.record_wcoj_feedback(&slot_rels, var_order_opt, output_rows);
3068                self.wcoj_4cycle_dispatch_count += 1;
3069                Ok(Some(buf))
3070            }
3071            Err(err) => wcoj_decline_on_error(&mut self.wcoj_error_decline_count, "4-cycle", err),
3072        }
3073    }
3074
3075    /// Inner pipeline for 4-cycle: 4× layout construction + kernel.
3076    #[allow(clippy::too_many_arguments)]
3077    fn run_wcoj_4cycle_pipeline(
3078        &self,
3079        buf_e1: &CudaBuffer,
3080        buf_e2: &CudaBuffer,
3081        buf_e3: &CudaBuffer,
3082        buf_e4: &CudaBuffer,
3083        launch_stream: StreamId,
3084        width: WcojKeyWidth,
3085        var_order: Option<&VariableOrder>,
3086    ) -> Result<CudaBuffer> {
3087        if let Some(vo) = var_order {
3088            return self.run_wcoj_4cycle_pipeline_with_leader_order(
3089                buf_e1,
3090                buf_e2,
3091                buf_e3,
3092                buf_e4,
3093                launch_stream,
3094                width,
3095                vo,
3096            );
3097        }
3098        match width {
3099            WcojKeyWidth::FourByte => {
3100                let layout_e1 = self
3101                    .provider
3102                    .wcoj_layout_u32_recorded(buf_e1, launch_stream)?;
3103                let layout_e2 = self
3104                    .provider
3105                    .wcoj_layout_u32_recorded(buf_e2, launch_stream)?;
3106                let layout_e3 = self
3107                    .provider
3108                    .wcoj_layout_u32_recorded(buf_e3, launch_stream)?;
3109                let layout_e4 = self
3110                    .provider
3111                    .wcoj_layout_u32_recorded(buf_e4, launch_stream)?;
3112                self.provider.wcoj_4cycle_u32_recorded(
3113                    &layout_e1,
3114                    &layout_e2,
3115                    &layout_e3,
3116                    &layout_e4,
3117                    launch_stream,
3118                )
3119            }
3120            WcojKeyWidth::EightByte => {
3121                let layout_e1 = self
3122                    .provider
3123                    .wcoj_layout_u64_recorded(buf_e1, launch_stream)?;
3124                let layout_e2 = self
3125                    .provider
3126                    .wcoj_layout_u64_recorded(buf_e2, launch_stream)?;
3127                let layout_e3 = self
3128                    .provider
3129                    .wcoj_layout_u64_recorded(buf_e3, launch_stream)?;
3130                let layout_e4 = self
3131                    .provider
3132                    .wcoj_layout_u64_recorded(buf_e4, launch_stream)?;
3133                self.provider.wcoj_4cycle_u64_recorded(
3134                    &layout_e1,
3135                    &layout_e2,
3136                    &layout_e3,
3137                    &layout_e4,
3138                    launch_stream,
3139                )
3140            }
3141        }
3142    }
3143
3144    /// Pipeline for non-default 4-cycle leaders. All
3145    /// 4-cycle leaders are rotation-only (no col-swap entries
3146    /// in `lookup_perms`); kernel emits in `(a, b, c, d)` order
3147    /// per the rotated leader; final projection helper remaps
3148    /// to canonical `(W, X, Y, Z)` head order.
3149    #[allow(clippy::too_many_arguments)]
3150    fn run_wcoj_4cycle_pipeline_with_leader_order(
3151        &self,
3152        buf_e1: &CudaBuffer,
3153        buf_e2: &CudaBuffer,
3154        buf_e3: &CudaBuffer,
3155        buf_e4: &CudaBuffer,
3156        launch_stream: StreamId,
3157        width: WcojKeyWidth,
3158        var_order: &VariableOrder,
3159    ) -> Result<CudaBuffer> {
3160        let canonical: [&CudaBuffer; 4] = [buf_e1, buf_e2, buf_e3, buf_e4];
3161        let slot_inputs = self.prepare_leader_inputs(&canonical, var_order, launch_stream)?;
3162        if slot_inputs.len() != 4 {
3163            return Err(xlog_core::XlogError::Kernel(
3164                "run_wcoj_4cycle_pipeline_with_leader_order: prepare_leader_inputs must return 4 slots"
3165                    .to_string(),
3166            ));
3167        }
3168
3169        let head_schema = build_4cycle_head_schema(buf_e1, buf_e2, buf_e3)?;
3170        let perm = perm_indices_from_kernel_output_cols(&var_order.kernel_output_cols)?;
3171
3172        let kernel_out: CudaBuffer = match width {
3173            WcojKeyWidth::FourByte => {
3174                let l0 = self
3175                    .provider
3176                    .wcoj_layout_u32_recorded(&slot_inputs[0], launch_stream)?;
3177                let l1 = self
3178                    .provider
3179                    .wcoj_layout_u32_recorded(&slot_inputs[1], launch_stream)?;
3180                let l2 = self
3181                    .provider
3182                    .wcoj_layout_u32_recorded(&slot_inputs[2], launch_stream)?;
3183                let l3 = self
3184                    .provider
3185                    .wcoj_layout_u32_recorded(&slot_inputs[3], launch_stream)?;
3186                self.provider
3187                    .wcoj_4cycle_u32_recorded(&l0, &l1, &l2, &l3, launch_stream)?
3188            }
3189            WcojKeyWidth::EightByte => {
3190                let l0 = self
3191                    .provider
3192                    .wcoj_layout_u64_recorded(&slot_inputs[0], launch_stream)?;
3193                let l1 = self
3194                    .provider
3195                    .wcoj_layout_u64_recorded(&slot_inputs[1], launch_stream)?;
3196                let l2 = self
3197                    .provider
3198                    .wcoj_layout_u64_recorded(&slot_inputs[2], launch_stream)?;
3199                let l3 = self
3200                    .provider
3201                    .wcoj_layout_u64_recorded(&slot_inputs[3], launch_stream)?;
3202                self.provider
3203                    .wcoj_4cycle_u64_recorded(&l0, &l1, &l2, &l3, launch_stream)?
3204            }
3205        };
3206
3207        self.provider.wcoj_project_output_columns_recorded(
3208            &kernel_out,
3209            &perm,
3210            head_schema,
3211            launch_stream,
3212        )
3213    }
3214
3215    /// Produce **owned, materialized** kernel slot inputs
3216    /// from a canonical-order input array and a `VariableOrder`.
3217    ///
3218    /// **Public** runtime helper. Production callers are
3219    /// `run_wcoj_*_pipeline_with_leader_order` (this module); runtime tests
3220    /// in `crates/xlog-runtime/tests/test_leader_input_permutation_tables.rs` invoke it
3221    /// directly to assert per-slot schema + content against a CPU
3222    /// reference. Public visibility is intentional: there is no
3223    /// other reasonable seam for tests to inspect rotation +
3224    /// col-swap behavior, and the helper has well-defined
3225    /// owned-buffer semantics that external callers can rely on.
3226    ///
3227    /// Returns a `Vec<CudaBuffer>` of length `canonical.len()` (3
3228    /// for triangle, 4 for 4-cycle). Slot 0 is the leader; slots
3229    /// 1.. follow `var_order.lookup_perms[i].input_idx` mapping.
3230    /// Triangle non-default leaders may col-swap selected slots
3231    /// per the locked permutation table; 4-cycle is rotation-only
3232    /// and rejects swap requests with a kernel error.
3233    ///
3234    /// Each returned `CudaBuffer` is owned: swapped slots are
3235    /// DtoD-copied via `wcoj_project_2col_swap_recorded`; non-
3236    /// swapped slots use the double-swap clone path below to give
3237    /// every slot a uniform owned-buffer return type.
3238    ///
3239    /// **Lifetime contract**: returned buffers are independent of
3240    /// `canonical[*]`. Callers may pass references through to
3241    /// `wcoj_layout_*_recorded` without aliasing concerns.
3242    pub fn prepare_leader_inputs(
3243        &self,
3244        canonical: &[&CudaBuffer],
3245        var_order: &VariableOrder,
3246        launch_stream: StreamId,
3247    ) -> Result<Vec<CudaBuffer>> {
3248        let n = canonical.len();
3249        if !(n == 3 || n == 4) {
3250            return Err(xlog_core::XlogError::Kernel(format!(
3251                "prepare_leader_inputs: canonical inputs must be 3 (triangle) or 4 (4-cycle), got {n}"
3252            )));
3253        }
3254        let leader_idx = var_order.leader_idx as usize;
3255        if leader_idx >= n {
3256            return Err(xlog_core::XlogError::Kernel(format!(
3257                "prepare_leader_inputs: leader_idx {leader_idx} out of range for arity {n}"
3258            )));
3259        }
3260        if var_order.lookup_perms.len() != n - 1 {
3261            return Err(xlog_core::XlogError::Kernel(format!(
3262                "prepare_leader_inputs: lookup_perms.len() = {} must equal {} (arity - 1)",
3263                var_order.lookup_perms.len(),
3264                n - 1
3265            )));
3266        }
3267        for (slot, lp) in var_order.lookup_perms.iter().enumerate() {
3268            let input_idx = lp.input_idx as usize;
3269            if input_idx >= n {
3270                return Err(xlog_core::XlogError::Kernel(format!(
3271                    "prepare_leader_inputs: lookup_perms[{slot}].input_idx {input_idx} out of range for arity {n}"
3272                )));
3273            }
3274        }
3275        // 4-cycle defense: no col-swaps allowed (locked table).
3276        if n == 4 {
3277            for lp in &var_order.lookup_perms {
3278                if lp.swap_cols {
3279                    return Err(xlog_core::XlogError::Kernel(
3280                        "prepare_leader_inputs: 4-cycle does not support col-swaps".to_string(),
3281                    ));
3282                }
3283            }
3284        }
3285
3286        // Slot 0: clone the leader via the swap helper called twice
3287        // (cancels out → owned pass-through). The simpler path for
3288        // production is just passing `canonical[leader_idx]` by
3289        // reference, but since the production callers consume the
3290        // returned `Vec<CudaBuffer>` by index, we materialize an
3291        // owned copy. Triangle leaders never have swap_cols on
3292        // their own slot; we use `wcoj_project_2col_swap_recorded`
3293        // twice to produce an owned copy with identical layout.
3294        //
3295        // For clarity and to avoid the extra DtoD: leader slot 0 is
3296        // produced by single swap-twice, lookups by either single
3297        // swap (when swap_cols) or single swap-twice (when not).
3298        //
3299        // Cost: one extra DtoD copy per slot vs. the previous
3300        // inline-references implementation. The leader-ordered path is opt-in,
3301        // and the DtoD overhead is small relative to the layout + kernel cost.
3302        let mut slots: Vec<CudaBuffer> = Vec::with_capacity(n);
3303        // Slot 0 = leader, no swap.
3304        slots.push(self.clone_buffer_via_swap(canonical[leader_idx], launch_stream)?);
3305        for lp in &var_order.lookup_perms {
3306            let src = canonical[lp.input_idx as usize];
3307            let buf = if lp.swap_cols {
3308                self.provider
3309                    .wcoj_project_2col_swap_recorded(src, launch_stream)?
3310            } else {
3311                self.clone_buffer_via_swap(src, launch_stream)?
3312            };
3313            slots.push(buf);
3314        }
3315        Ok(slots)
3316    }
3317
3318    /// Clone a 2-col `CudaBuffer` via a double-swap through the
3319    /// existing recorded helper. Two swaps cancel — the result is a
3320    /// fresh owned buffer with the same column order, schema, and
3321    /// content as `src`. Used by `prepare_leader_inputs` to give
3322    /// every slot a uniform owned-buffer return type.
3323    fn clone_buffer_via_swap(
3324        &self,
3325        src: &CudaBuffer,
3326        launch_stream: StreamId,
3327    ) -> Result<CudaBuffer> {
3328        let once = self
3329            .provider
3330            .wcoj_project_2col_swap_recorded(src, launch_stream)?;
3331        self.provider
3332            .wcoj_project_2col_swap_recorded(&once, launch_stream)
3333    }
3334
3335    /// Resolve the cached WCOJ launch stream, lazily initializing
3336    /// it on first call by acquiring one stream from the runtime
3337    /// pool. Subsequent calls reuse the same stream — mirrors
3338    /// [`xlog_cuda::CudaKernelProvider::recorded_op_stream`]
3339    /// (provider/mod.rs).
3340    ///
3341    /// **Shared across WCOJ shapes**: triangle
3342    /// and 4-cycle dispatch both go through this resolver and
3343    /// reuse the same stream. Renamed from
3344    /// `wcoj_triangle_stream_or_init` when 4-cycle dispatch
3345    /// landed.
3346    ///
3347    /// Returns `None` only when (a) the manager has no runtime,
3348    /// or (b) the very first acquisition fails (pool already
3349    /// at cap from other consumers). After that first success
3350    /// the cached id keeps resolving for the executor's lifetime.
3351    pub fn wcoj_dispatch_stream_or_init(&self) -> Option<StreamId> {
3352        if let Some(s) = self.wcoj_dispatch_stream.get() {
3353            return Some(*s);
3354        }
3355        let runtime = self.provider.memory().runtime()?;
3356        let stream = runtime.stream_pool().acquire().ok()?;
3357        let _ = self.wcoj_dispatch_stream.set(stream);
3358        self.wcoj_dispatch_stream.get().copied()
3359    }
3360}
3361
3362// ===============================================================
3363// K-clique dispatch (k = 5..8).
3364//
3365// Default-dispatch on shape match. No force / kill / adaptive
3366// knobs.
3367// Silent fallback to MultiWayJoin.fallback on dispatcher decline
3368// or kernel error.
3369//
3370// Counter accessors are public so xlog-integration
3371// certs can assert across the crate boundary.
3372// ===============================================================
3373
3374impl Executor {
3375    /// Number of times the WCOJ k=5-clique hook produced a
3376    /// result and the executor installed it. Counter does NOT
3377    /// advance on dispatcher decline / kernel-launch failure
3378    /// (silent fallback to `MultiWayJoin.fallback`).
3379    pub fn wcoj_clique5_dispatch_count(&self) -> u64 {
3380        self.wcoj_clique5_dispatch_count
3381    }
3382
3383    /// Number of times the WCOJ k=6-clique hook produced
3384    /// a result. Same observability contract as
3385    /// `wcoj_clique5_dispatch_count`.
3386    pub fn wcoj_clique6_dispatch_count(&self) -> u64 {
3387        self.wcoj_clique6_dispatch_count
3388    }
3389
3390    /// Number of times the WCOJ k=7-clique hook produced
3391    /// a result. Same observability contract as
3392    /// `wcoj_clique5_dispatch_count`.
3393    pub fn wcoj_clique7_dispatch_count(&self) -> u64 {
3394        self.wcoj_clique7_dispatch_count
3395    }
3396
3397    /// Number of times the WCOJ k=8-clique hook produced
3398    /// a result. Same observability contract as
3399    /// `wcoj_clique5_dispatch_count`.
3400    pub fn wcoj_clique8_dispatch_count(&self) -> u64 {
3401        self.wcoj_clique8_dispatch_count
3402    }
3403
3404    /// Number of recursive merge
3405    /// boundaries where K-clique metadata was marked for refresh.
3406    pub fn kclique_histogram_refresh_count(&self) -> u64 {
3407        self.kclique_histogram_refresh_count
3408    }
3409
3410    /// Cumulative recursive K-clique metadata refresh accounting
3411    /// time in nanoseconds.
3412    pub fn kclique_histogram_refresh_nanos(&self) -> u128 {
3413        self.kclique_histogram_refresh_nanos
3414    }
3415
3416    /// Try k=5-clique dispatch. Wrapper for rule-keyed
3417    /// callers (recursive engine + non-recursive scc).
3418    pub(super) fn try_dispatch_wcoj_clique5(
3419        &mut self,
3420        rule: &CompiledRule,
3421    ) -> Result<Option<CudaBuffer>> {
3422        self.try_dispatch_wcoj_clique5_on_body(&rule.body)
3423    }
3424
3425    /// Try k=6-clique dispatch.
3426    pub(super) fn try_dispatch_wcoj_clique6(
3427        &mut self,
3428        rule: &CompiledRule,
3429    ) -> Result<Option<CudaBuffer>> {
3430        self.try_dispatch_wcoj_clique6_on_body(&rule.body)
3431    }
3432
3433    /// Try k=7-clique dispatch.
3434    pub(super) fn try_dispatch_wcoj_clique7(
3435        &mut self,
3436        rule: &CompiledRule,
3437    ) -> Result<Option<CudaBuffer>> {
3438        self.try_dispatch_wcoj_clique7_on_body(&rule.body)
3439    }
3440
3441    /// Try k=8-clique dispatch.
3442    pub(super) fn try_dispatch_wcoj_clique8(
3443        &mut self,
3444        rule: &CompiledRule,
3445    ) -> Result<Option<CudaBuffer>> {
3446        self.try_dispatch_wcoj_clique8_on_body(&rule.body)
3447    }
3448
3449    /// Body-keyed k=5-clique dispatch.
3450    pub(super) fn try_dispatch_wcoj_clique5_on_body(
3451        &mut self,
3452        body: &RirNode,
3453    ) -> Result<Option<CudaBuffer>> {
3454        self.try_dispatch_wcoj_clique_k_on_body(body, 5)
3455    }
3456
3457    /// Body-keyed k=6-clique dispatch.
3458    pub(super) fn try_dispatch_wcoj_clique6_on_body(
3459        &mut self,
3460        body: &RirNode,
3461    ) -> Result<Option<CudaBuffer>> {
3462        self.try_dispatch_wcoj_clique_k_on_body(body, 6)
3463    }
3464
3465    /// Body-keyed k=7-clique dispatch.
3466    pub(super) fn try_dispatch_wcoj_clique7_on_body(
3467        &mut self,
3468        body: &RirNode,
3469    ) -> Result<Option<CudaBuffer>> {
3470        self.try_dispatch_wcoj_clique_k_on_body(body, 7)
3471    }
3472
3473    /// Body-keyed k=8-clique dispatch.
3474    pub(super) fn try_dispatch_wcoj_clique8_on_body(
3475        &mut self,
3476        body: &RirNode,
3477    ) -> Result<Option<CudaBuffer>> {
3478        self.try_dispatch_wcoj_clique_k_on_body(body, 8)
3479    }
3480
3481    /// Generic K-clique dispatch shared by k=5..8
3482    /// entries. Returns `Ok(Some(buffer))` on dispatch;
3483    /// `Ok(None)` on decline / fallback.
3484    fn try_dispatch_wcoj_clique_k_on_body(
3485        &mut self,
3486        body: &RirNode,
3487        k: usize,
3488    ) -> Result<Option<CudaBuffer>> {
3489        let expected_edges = k * (k - 1) / 2;
3490        // 1. Shape match: MultiWayJoin with inputs.len() == C(k, 2).
3491        let RirNode::MultiWayJoin {
3492            inputs,
3493            plan,
3494            var_order,
3495            ..
3496        } = body
3497        else {
3498            return Ok(None);
3499        };
3500        if matches!(plan, Some(MultiwayPlan::PlannedHashRoute { .. })) {
3501            return Ok(None);
3502        }
3503        if inputs.len() != expected_edges {
3504            return Ok(None);
3505        }
3506        let kclique = match var_order.as_ref().and_then(|order| order.kclique.as_ref()) {
3507            Some(plan) if usize::from(plan.k) == k => plan,
3508            _ => return Ok(None),
3509        };
3510        // 2. Extract RelIds from each input (must all be Scans).
3511        let mut rel_ids: Vec<RelId> = Vec::with_capacity(expected_edges);
3512        for input in inputs {
3513            let RirNode::Scan { rel } = input else {
3514                return Ok(None);
3515            };
3516            rel_ids.push(*rel);
3517        }
3518        // 3. Resolve each rel to a buffer in the relation store.
3519        let mut raw_bufs: Vec<&CudaBuffer> = Vec::with_capacity(expected_edges);
3520        for rid in &rel_ids {
3521            let name = match self.rel_names.get(rid) {
3522                Some(n) => n.clone(),
3523                None => return Ok(None),
3524            };
3525            match self.store.get(&name) {
3526                Some(b) => raw_bufs.push(b),
3527                None => return Ok(None),
3528            }
3529        }
3530        // 4. Acquire dispatch stream.
3531        let launch_stream = match self.wcoj_dispatch_stream_or_init() {
3532            Some(s) => s,
3533            None => return Ok(None),
3534        };
3535        // 5. Determine width-class from the first edge's column 0.
3536        // All edges must share the width-class; provider entries
3537        // re-validate.
3538        let first_ty = match raw_bufs[0].schema().column_type(0) {
3539            Some(t) => t,
3540            None => return Ok(None),
3541        };
3542        let is_u64 = matches!(first_ty, xlog_core::ScalarType::U64);
3543        let is_4byte = matches!(
3544            first_ty,
3545            xlog_core::ScalarType::U32 | xlog_core::ScalarType::Symbol
3546        );
3547        if !is_u64 && !is_4byte {
3548            return Ok(None);
3549        }
3550        let Some(plan_params) = kclique_dispatch_params(kclique, k) else {
3551            return Ok(None);
3552        };
3553        let head_schema = match build_kclique_head_schema(&raw_bufs, k) {
3554            Some(schema) => schema,
3555            None => return Ok(None),
3556        };
3557        let output_perm = match kclique_output_perm(kclique, k) {
3558            Some(perm) => perm,
3559            None => return Ok(None),
3560        };
3561        // 6. Orient edges according to KCliqueVariableOrder, then
3562        // layout only the plan-required physical slots through the
3563        // generic layout-sort helper. Remaining 2-column slots use
3564        // the narrower WCOJ layout entry, which preserves correctness
3565        // and can take the sorted-unique fast path.
3566        let laid_out = match self.orient_and_layout_kclique_edges(
3567            &raw_bufs,
3568            &plan_params,
3569            is_u64,
3570            launch_stream,
3571        ) {
3572            Ok(bufs) => bufs,
3573            Err(err) => {
3574                return wcoj_decline_on_error(
3575                    &mut self.wcoj_error_decline_count,
3576                    "k-clique-layout",
3577                    err,
3578                )
3579            }
3580        };
3581        // 7. Build the slice of buffer references the provider
3582        // expects.
3583        let edge_refs: Vec<&CudaBuffer> = laid_out.iter().collect();
3584        // 8. Dispatch the appropriate provider entry.
3585        let result = match (k, is_u64) {
3586            (5, false) => {
3587                let arr: &[&CudaBuffer; 10] = match edge_refs.as_slice().try_into() {
3588                    Ok(a) => a,
3589                    Err(_) => return Ok(None),
3590                };
3591                self.provider.wcoj_clique5_u32_recorded_planned(
3592                    arr,
3593                    plan_params.leader_edge_idx,
3594                    &plan_params.edge_order,
3595                    &plan_params.iteration_order,
3596                    launch_stream,
3597                )
3598            }
3599            (5, true) => {
3600                let arr: &[&CudaBuffer; 10] = match edge_refs.as_slice().try_into() {
3601                    Ok(a) => a,
3602                    Err(_) => return Ok(None),
3603                };
3604                self.provider.wcoj_clique5_u64_recorded_planned(
3605                    arr,
3606                    plan_params.leader_edge_idx,
3607                    &plan_params.edge_order,
3608                    &plan_params.iteration_order,
3609                    launch_stream,
3610                )
3611            }
3612            (6, false) => {
3613                let arr: &[&CudaBuffer; 15] = match edge_refs.as_slice().try_into() {
3614                    Ok(a) => a,
3615                    Err(_) => return Ok(None),
3616                };
3617                self.provider.wcoj_clique6_u32_recorded_planned(
3618                    arr,
3619                    plan_params.leader_edge_idx,
3620                    &plan_params.edge_order,
3621                    &plan_params.iteration_order,
3622                    launch_stream,
3623                )
3624            }
3625            (6, true) => {
3626                let arr: &[&CudaBuffer; 15] = match edge_refs.as_slice().try_into() {
3627                    Ok(a) => a,
3628                    Err(_) => return Ok(None),
3629                };
3630                self.provider.wcoj_clique6_u64_recorded_planned(
3631                    arr,
3632                    plan_params.leader_edge_idx,
3633                    &plan_params.edge_order,
3634                    &plan_params.iteration_order,
3635                    launch_stream,
3636                )
3637            }
3638            (7, false) => {
3639                let arr: &[&CudaBuffer; 21] = match edge_refs.as_slice().try_into() {
3640                    Ok(a) => a,
3641                    Err(_) => return Ok(None),
3642                };
3643                self.provider.wcoj_clique7_u32_recorded_planned(
3644                    arr,
3645                    plan_params.leader_edge_idx,
3646                    &plan_params.edge_order,
3647                    &plan_params.iteration_order,
3648                    launch_stream,
3649                )
3650            }
3651            (7, true) => {
3652                let arr: &[&CudaBuffer; 21] = match edge_refs.as_slice().try_into() {
3653                    Ok(a) => a,
3654                    Err(_) => return Ok(None),
3655                };
3656                self.provider.wcoj_clique7_u64_recorded_planned(
3657                    arr,
3658                    plan_params.leader_edge_idx,
3659                    &plan_params.edge_order,
3660                    &plan_params.iteration_order,
3661                    launch_stream,
3662                )
3663            }
3664            (8, false) => {
3665                let arr: &[&CudaBuffer; 28] = match edge_refs.as_slice().try_into() {
3666                    Ok(a) => a,
3667                    Err(_) => return Ok(None),
3668                };
3669                self.provider.wcoj_clique8_u32_recorded_planned(
3670                    arr,
3671                    plan_params.leader_edge_idx,
3672                    &plan_params.edge_order,
3673                    &plan_params.iteration_order,
3674                    launch_stream,
3675                )
3676            }
3677            (8, true) => {
3678                let arr: &[&CudaBuffer; 28] = match edge_refs.as_slice().try_into() {
3679                    Ok(a) => a,
3680                    Err(_) => return Ok(None),
3681                };
3682                self.provider.wcoj_clique8_u64_recorded_planned(
3683                    arr,
3684                    plan_params.leader_edge_idx,
3685                    &plan_params.edge_order,
3686                    &plan_params.iteration_order,
3687                    launch_stream,
3688                )
3689            }
3690            _ => return Ok(None),
3691        };
3692        // 9. On success: counter++, return Some. On error:
3693        // silent fallback (no counter advance).
3694        match result {
3695            Ok(buf) => {
3696                let buf = if output_perm.iter().copied().eq(0..output_perm.len()) {
3697                    buf
3698                } else {
3699                    self.provider.wcoj_project_output_columns_recorded(
3700                        &buf,
3701                        &output_perm,
3702                        head_schema,
3703                        launch_stream,
3704                    )?
3705                };
3706                match k {
3707                    5 => self.wcoj_clique5_dispatch_count += 1,
3708                    6 => self.wcoj_clique6_dispatch_count += 1,
3709                    7 => self.wcoj_clique7_dispatch_count += 1,
3710                    8 => self.wcoj_clique8_dispatch_count += 1,
3711                    _ => {}
3712                }
3713                Ok(Some(buf))
3714            }
3715            Err(err) => wcoj_decline_on_error(&mut self.wcoj_error_decline_count, "k-clique", err),
3716        }
3717    }
3718
3719    /// Orient edges according to a `KCliqueVariableOrder` (edge
3720    /// permutation + column swaps), then layout the plan-required
3721    /// physical slots through the generic layout-sort helper and the
3722    /// remaining 2-column slots through the narrower WCOJ layout entry
3723    /// (which preserves correctness and can take the sorted-unique fast
3724    /// path). Shared by the unfused K-clique dispatch and the fused
3725    /// count-by-root dispatch; callers wrap errors through
3726    /// [`wcoj_decline_on_error`].
3727    fn orient_and_layout_kclique_edges(
3728        &self,
3729        raw_bufs: &[&CudaBuffer],
3730        plan_params: &KCliqueDispatchParams,
3731        is_u64: bool,
3732        launch_stream: StreamId,
3733    ) -> Result<Vec<CudaBuffer>> {
3734        let mut laid_out: Vec<CudaBuffer> = Vec::with_capacity(plan_params.edge_permutation.len());
3735        for (slot, &input_idx) in plan_params.edge_permutation.iter().enumerate() {
3736            let src = raw_bufs[input_idx];
3737            let swapped = if plan_params.swap_slots.contains(&slot) {
3738                Some(
3739                    self.provider
3740                        .wcoj_project_2col_swap_recorded(src, launch_stream)?,
3741                )
3742            } else {
3743                None
3744            };
3745            let oriented = swapped.as_ref().unwrap_or(src);
3746            let res = if plan_params.required_sort_slots.contains(&slot) {
3747                if is_u64 {
3748                    self.provider
3749                        .wcoj_layout_sort_u64_recorded(oriented, launch_stream)
3750                } else {
3751                    self.provider
3752                        .wcoj_layout_sort_u32_recorded(oriented, launch_stream)
3753                }
3754            } else if is_u64 {
3755                self.provider
3756                    .wcoj_layout_u64_recorded(oriented, launch_stream)
3757            } else {
3758                self.provider
3759                    .wcoj_layout_u32_recorded(oriented, launch_stream)
3760            };
3761            laid_out.push(res?);
3762        }
3763        Ok(laid_out)
3764    }
3765
3766    /// Aggregate-fused WCOJ, K-clique count (K = 5, 6; 4-byte keys):
3767    /// dispatch the inner `MultiWayJoin(K-clique)` of a count-by-root
3768    /// aggregate through the fused group-by-root kernel, which never
3769    /// materializes the clique rows.
3770    ///
3771    /// CAREFUL — the root under `KCliqueVariableOrder` is plan-dependent
3772    /// (`variable_order[0]` + leader-edge orientation/swaps determine the
3773    /// physical root column). The fusion is sound only when the GroupBy
3774    /// key column references the head variable whose planned position is
3775    /// 0 (`variable_positions[r] == 0`); everything else declines
3776    /// silently to the embedded fallback + groupby path. K = 7/8 (no
3777    /// fused kernels), u64/mixed widths, planned-hash routes, and
3778    /// missing buffers/runtime also decline. Kill switch
3779    /// (`XLOG_DISABLE_WCOJ_GROUPBY_FUSION`) is checked by the caller.
3780    /// Pipeline errors route through [`wcoj_decline_on_error`] (counted;
3781    /// `XLOG_WCOJ_STRICT=1` propagates).
3782    fn try_dispatch_wcoj_groupby_root_count_clique(
3783        &mut self,
3784        multiway: &RirNode,
3785        group_cols: &[ProjectExpr],
3786    ) -> Result<Option<CudaBuffer>> {
3787        let RirNode::MultiWayJoin {
3788            inputs,
3789            plan,
3790            var_order,
3791            ..
3792        } = multiway
3793        else {
3794            return Ok(None);
3795        };
3796        if matches!(plan, Some(MultiwayPlan::PlannedHashRoute { .. })) {
3797            return Ok(None);
3798        }
3799        let kclique = match var_order.as_ref().and_then(|order| order.kclique.as_ref()) {
3800            Some(plan) => plan,
3801            None => return Ok(None),
3802        };
3803        let k = usize::from(kclique.k);
3804        if !matches!(k, 5 | 6) {
3805            return Ok(None);
3806        }
3807        let expected_edges = k * (k - 1) / 2;
3808        if inputs.len() != expected_edges {
3809            return Ok(None);
3810        }
3811        // Group key must be the planned position-0 root variable.
3812        let Some(ProjectExpr::Column(root_var)) = group_cols.first() else {
3813            return Ok(None);
3814        };
3815        let Some(positions) = live_kclique_variable_positions(kclique, k) else {
3816            return Ok(None);
3817        };
3818        if *root_var >= k || positions[*root_var] != 0 {
3819            return Ok(None);
3820        }
3821        // Resolve scans → buffers; only uniform 4-byte keys are fused.
3822        let mut rel_ids: Vec<RelId> = Vec::with_capacity(expected_edges);
3823        for input in inputs {
3824            let RirNode::Scan { rel } = input else {
3825                return Ok(None);
3826            };
3827            rel_ids.push(*rel);
3828        }
3829        let mut raw_bufs: Vec<&CudaBuffer> = Vec::with_capacity(expected_edges);
3830        for rid in &rel_ids {
3831            let name = match self.rel_names.get(rid) {
3832                Some(n) => n.clone(),
3833                None => return Ok(None),
3834            };
3835            match self.store.get(&name) {
3836                Some(b) => raw_bufs.push(b),
3837                None => return Ok(None),
3838            }
3839        }
3840        for buf in &raw_bufs {
3841            if classify_two_col_wcoj_width(buf) != Some(WcojKeyWidth::FourByte) {
3842                return Ok(None);
3843            }
3844        }
3845        if self.provider.memory().runtime().is_none() {
3846            return Ok(None);
3847        }
3848        let Some(launch_stream) = self.wcoj_dispatch_stream_or_init() else {
3849            return Ok(None);
3850        };
3851        let Some(plan_params) = kclique_dispatch_params(kclique, k) else {
3852            return Ok(None);
3853        };
3854        let laid_out = match self.orient_and_layout_kclique_edges(
3855            &raw_bufs,
3856            &plan_params,
3857            false,
3858            launch_stream,
3859        ) {
3860            Ok(bufs) => bufs,
3861            Err(err) => {
3862                return wcoj_decline_on_error(
3863                    &mut self.wcoj_error_decline_count,
3864                    "groupby-fusion-clique-layout",
3865                    err,
3866                )
3867            }
3868        };
3869        let edge_refs: Vec<&CudaBuffer> = laid_out.iter().collect();
3870        let result = match k {
3871            5 => {
3872                let arr: &[&CudaBuffer; 10] = match edge_refs.as_slice().try_into() {
3873                    Ok(a) => a,
3874                    Err(_) => return Ok(None),
3875                };
3876                self.provider
3877                    .wcoj_clique5_groupby_root_count_u32_recorded_planned(
3878                        arr,
3879                        plan_params.leader_edge_idx,
3880                        &plan_params.edge_order,
3881                        &plan_params.iteration_order,
3882                        launch_stream,
3883                    )
3884            }
3885            _ => {
3886                let arr: &[&CudaBuffer; 15] = match edge_refs.as_slice().try_into() {
3887                    Ok(a) => a,
3888                    Err(_) => return Ok(None),
3889                };
3890                self.provider
3891                    .wcoj_clique6_groupby_root_count_u32_recorded_planned(
3892                        arr,
3893                        plan_params.leader_edge_idx,
3894                        &plan_params.edge_order,
3895                        &plan_params.iteration_order,
3896                        launch_stream,
3897                    )
3898            }
3899        };
3900        match result {
3901            Ok(buf) => {
3902                self.wcoj_groupby_fusion_dispatch_count += 1;
3903                Ok(Some(buf))
3904            }
3905            Err(err) => wcoj_decline_on_error(
3906                &mut self.wcoj_error_decline_count,
3907                "groupby-fusion-clique",
3908                err,
3909            ),
3910        }
3911    }
3912}
3913
3914#[derive(Debug)]
3915struct KCliqueDispatchParams {
3916    edge_permutation: Vec<usize>,
3917    edge_order: Vec<u8>,
3918    iteration_order: Vec<u8>,
3919    leader_edge_idx: u32,
3920    swap_slots: HashSet<usize>,
3921    required_sort_slots: HashSet<usize>,
3922}
3923
3924fn kclique_dispatch_params(plan: &KCliqueVariableOrder, k: usize) -> Option<KCliqueDispatchParams> {
3925    let expected_edges = k * (k - 1) / 2;
3926    let edge_permutation = live_kclique_edge_permutation(plan, expected_edges)?;
3927    let positions = live_kclique_variable_positions(plan, k)?;
3928    let mut edge_order = vec![u8::MAX; expected_edges];
3929
3930    for (slot, &edge_idx) in edge_permutation.iter().enumerate() {
3931        let (left, right) = clique_edge_pair(edge_idx, k)?;
3932        let left_pos = positions[left];
3933        let right_pos = positions[right];
3934        let logical_edge =
3935            clique_edge_idx_runtime(left_pos.min(right_pos), left_pos.max(right_pos), k)?;
3936        edge_order[logical_edge] = u8::try_from(slot).ok()?;
3937    }
3938    if edge_order.contains(&u8::MAX) {
3939        return None;
3940    }
3941    let leader_edge_idx = u32::from(edge_order[clique_edge_idx_runtime(0, 1, k)?]);
3942    let iteration_order: Vec<u8> = (0..k)
3943        .map(|idx| u8::try_from(idx).ok())
3944        .collect::<Option<_>>()?;
3945
3946    let swap_slots: HashSet<usize> = plan
3947        .column_swaps
3948        .iter()
3949        .filter(|swap| swap.swap_cols)
3950        .map(|swap| usize::from(swap.edge_slot))
3951        .collect();
3952    if swap_slots.iter().any(|slot| *slot >= expected_edges) {
3953        return None;
3954    }
3955    let required_sort_slots: HashSet<usize> = plan
3956        .sorted_layout_requirements
3957        .edge_slots
3958        .iter()
3959        .copied()
3960        .map(usize::from)
3961        .collect();
3962    if required_sort_slots
3963        .iter()
3964        .any(|slot| *slot >= expected_edges)
3965    {
3966        return None;
3967    }
3968
3969    Some(KCliqueDispatchParams {
3970        edge_permutation,
3971        edge_order,
3972        iteration_order,
3973        leader_edge_idx,
3974        swap_slots,
3975        required_sort_slots,
3976    })
3977}
3978
3979fn live_kclique_edge_permutation(
3980    plan: &KCliqueVariableOrder,
3981    expected_edges: usize,
3982) -> Option<Vec<usize>> {
3983    let values: Vec<usize> = plan
3984        .edge_permutation
3985        .iter()
3986        .copied()
3987        .take_while(|value| *value != u8::MAX)
3988        .map(usize::from)
3989        .collect();
3990    if values.len() != expected_edges {
3991        return None;
3992    }
3993    let mut seen = vec![false; expected_edges];
3994    for &value in &values {
3995        if value >= expected_edges || seen[value] {
3996            return None;
3997        }
3998        seen[value] = true;
3999    }
4000    Some(values)
4001}
4002
4003fn live_kclique_variable_positions(plan: &KCliqueVariableOrder, k: usize) -> Option<Vec<usize>> {
4004    let mut positions = Vec::with_capacity(k);
4005    let mut seen = vec![false; k];
4006    for original_var in 0..k {
4007        let pos = usize::from(*plan.variable_positions.get(original_var)?);
4008        if pos >= k || seen[pos] {
4009            return None;
4010        }
4011        seen[pos] = true;
4012        positions.push(pos);
4013    }
4014    Some(positions)
4015}
4016
4017fn clique_edge_idx_runtime(i: usize, j: usize, k: usize) -> Option<usize> {
4018    if !(i < j && j < k) {
4019        return None;
4020    }
4021    Some(i * (k - 1) - i.saturating_sub(1) * i / 2 + (j - i - 1))
4022}
4023
4024fn clique_edge_pair(edge_idx: usize, k: usize) -> Option<(usize, usize)> {
4025    let mut idx = 0usize;
4026    for i in 0..k {
4027        for j in (i + 1)..k {
4028            if idx == edge_idx {
4029                return Some((i, j));
4030            }
4031            idx += 1;
4032        }
4033    }
4034    None
4035}
4036
4037fn build_kclique_head_schema(raw_bufs: &[&CudaBuffer], k: usize) -> Option<Schema> {
4038    let mut columns = Vec::with_capacity(k);
4039    for variable in 0..k {
4040        let (edge_idx, col_idx) = if variable == 0 {
4041            (clique_edge_idx_runtime(0, 1, k)?, 0)
4042        } else {
4043            (clique_edge_idx_runtime(0, variable, k)?, 1)
4044        };
4045        let ty = raw_bufs.get(edge_idx)?.schema().column_type(col_idx)?;
4046        columns.push((format!("col{}", variable), ty));
4047    }
4048    Some(Schema::new(columns))
4049}
4050
4051fn kclique_output_perm(plan: &KCliqueVariableOrder, k: usize) -> Option<Vec<usize>> {
4052    let positions = live_kclique_variable_positions(plan, k)?;
4053    Some(positions)
4054}
4055
4056#[cfg(test)]
4057mod tests {
4058    use std::sync::{Mutex, OnceLock};
4059
4060    use super::{
4061        chain_dispatch_enabled, match_chain_join, match_multiway_triangle,
4062        record_chain_fallback_equivalents, wcoj_adaptive_enabled, wcoj_gate_enabled,
4063        ENV_USE_WCOJ_TRIANGLE_U32, ENV_WCOJ_CHAIN_ENABLE,
4064    };
4065    use xlog_core::RelId;
4066    use xlog_ir::rir::ProjectExpr;
4067    use xlog_ir::RirNode;
4068
4069    fn canonical_multiway() -> RirNode {
4070        RirNode::MultiWayJoin {
4071            inputs: vec![
4072                RirNode::Scan { rel: RelId(1) },
4073                RirNode::Scan { rel: RelId(2) },
4074                RirNode::Scan { rel: RelId(3) },
4075            ],
4076            slot_vars: vec![
4077                vec![Some(0u32), Some(1)],
4078                vec![Some(1u32), Some(2)],
4079                vec![Some(0u32), Some(2)],
4080            ],
4081            output_columns: vec![
4082                ProjectExpr::Column(0),
4083                ProjectExpr::Column(1),
4084                ProjectExpr::Column(3),
4085            ],
4086            fallback: Box::new(RirNode::Unit),
4087            plan: None,
4088            var_order: None,
4089        }
4090    }
4091
4092    fn canonical_chain_join() -> RirNode {
4093        RirNode::ChainJoin {
4094            left: Box::new(RirNode::Scan { rel: RelId(1) }),
4095            right: Box::new(RirNode::Scan { rel: RelId(2) }),
4096            left_key: 1,
4097            right_key: 0,
4098            output_columns: vec![ProjectExpr::Column(0), ProjectExpr::Column(3)],
4099            fallback: Box::new(RirNode::Unit),
4100        }
4101    }
4102
4103    #[test]
4104    fn match_chain_returns_two_rels_and_keys() {
4105        let node = canonical_chain_join();
4106        let m = match_chain_join(&node).expect("must match canonical chain");
4107        assert_eq!(m.rel_left, RelId(1));
4108        assert_eq!(m.rel_right, RelId(2));
4109        assert_eq!(m.left_key, 1);
4110        assert_eq!(m.right_key, 0);
4111        assert_eq!(
4112            m.output_columns,
4113            vec![ProjectExpr::Column(0), ProjectExpr::Column(3)]
4114        );
4115    }
4116
4117    #[test]
4118    fn match_chain_rejects_non_scan_inputs() {
4119        let mut node = canonical_chain_join();
4120        if let RirNode::ChainJoin { left, .. } = &mut node {
4121            **left = RirNode::Unit;
4122        }
4123        assert!(match_chain_join(&node).is_none());
4124    }
4125
4126    #[test]
4127    fn match_chain_rejects_multiway_triangle() {
4128        let node = canonical_multiway();
4129        assert!(match_chain_join(&node).is_none());
4130    }
4131
4132    fn chain_with_scan_filter_fallback() -> RirNode {
4133        let mut node = canonical_chain_join();
4134        let RirNode::ChainJoin { fallback, .. } = &mut node else {
4135            unreachable!("canonical chain shape")
4136        };
4137        **fallback = RirNode::Union {
4138            inputs: vec![
4139                RirNode::Filter {
4140                    input: Box::new(RirNode::Scan { rel: RelId(1) }),
4141                    predicate: xlog_ir::Expr::And(Vec::new()),
4142                },
4143                RirNode::Project {
4144                    input: Box::new(RirNode::Filter {
4145                        input: Box::new(RirNode::Scan { rel: RelId(2) }),
4146                        predicate: xlog_ir::Expr::And(Vec::new()),
4147                    }),
4148                    columns: vec![ProjectExpr::Column(0)],
4149                },
4150            ],
4151        };
4152        node
4153    }
4154
4155    #[test]
4156    fn matched_chain_success_records_embedded_fallback_equivalents() {
4157        let node = chain_with_scan_filter_fallback();
4158        let mut scans = 7;
4159        let mut filters = 11;
4160
4161        record_chain_fallback_equivalents(&node, true, &mut scans, &mut filters);
4162
4163        assert_eq!(scans, 9);
4164        assert_eq!(filters, 13);
4165    }
4166
4167    #[test]
4168    fn matched_chain_decline_records_no_embedded_fallback_equivalents() {
4169        let node = chain_with_scan_filter_fallback();
4170        let mut scans = 7;
4171        let mut filters = 11;
4172
4173        record_chain_fallback_equivalents(&node, false, &mut scans, &mut filters);
4174
4175        assert_eq!(scans, 7);
4176        assert_eq!(filters, 11);
4177    }
4178
4179    #[test]
4180    fn chain_dispatch_env_defaults_on_and_can_disable() {
4181        static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4182        let _guard = ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
4183        let old = std::env::var(ENV_WCOJ_CHAIN_ENABLE).ok();
4184        // SAFETY: This test holds a local mutex while mutating the
4185        // process-global chain-dispatch env var, and restores it before unlock.
4186        unsafe {
4187            std::env::remove_var(ENV_WCOJ_CHAIN_ENABLE);
4188        }
4189        assert!(chain_dispatch_enabled());
4190        unsafe {
4191            std::env::set_var(ENV_WCOJ_CHAIN_ENABLE, "0");
4192        }
4193        assert!(!chain_dispatch_enabled());
4194        unsafe {
4195            std::env::set_var(ENV_WCOJ_CHAIN_ENABLE, "false");
4196        }
4197        assert!(!chain_dispatch_enabled());
4198        unsafe {
4199            std::env::set_var(ENV_WCOJ_CHAIN_ENABLE, "1");
4200        }
4201        assert!(chain_dispatch_enabled());
4202        unsafe {
4203            match old {
4204                Some(v) => std::env::set_var(ENV_WCOJ_CHAIN_ENABLE, v),
4205                None => std::env::remove_var(ENV_WCOJ_CHAIN_ENABLE),
4206            }
4207        }
4208    }
4209
4210    #[test]
4211    fn match_canonical_returns_three_rels() {
4212        let node = canonical_multiway();
4213        let m = match_multiway_triangle(&node).expect("must match canonical triangle");
4214        assert_eq!(m.rel_xy, RelId(1));
4215        assert_eq!(m.rel_yz, RelId(2));
4216        assert_eq!(m.rel_xz, RelId(3));
4217    }
4218
4219    #[test]
4220    fn match_rejects_non_multiway_body() {
4221        let node = RirNode::Scan { rel: RelId(1) };
4222        assert!(match_multiway_triangle(&node).is_none());
4223    }
4224
4225    #[test]
4226    fn match_rejects_rotated_output_columns() {
4227        let mut node = canonical_multiway();
4228        if let RirNode::MultiWayJoin { output_columns, .. } = &mut node {
4229            *output_columns = vec![
4230                ProjectExpr::Column(1),
4231                ProjectExpr::Column(0),
4232                ProjectExpr::Column(3),
4233            ];
4234        }
4235        assert!(match_multiway_triangle(&node).is_none());
4236    }
4237
4238    /// Triangle with Z-shared output_columns layout
4239    /// `[Column(0), Column(2), Column(3)]` must match. The
4240    /// matcher's output-column relaxation accepts both
4241    /// `[0, 1, 3]` (Y/X-shared) and `[0, 2, 3]` (Z-shared).
4242    #[test]
4243    fn match_accepts_z_shared_triangle_output_columns() {
4244        let mut node = canonical_multiway();
4245        if let RirNode::MultiWayJoin { output_columns, .. } = &mut node {
4246            *output_columns = vec![
4247                ProjectExpr::Column(0),
4248                ProjectExpr::Column(2),
4249                ProjectExpr::Column(3),
4250            ];
4251        }
4252        let m = match_multiway_triangle(&node)
4253            .expect("matcher must accept the Z-shared output-column layout");
4254        assert_eq!(m.rel_xy, RelId(1));
4255        assert_eq!(m.rel_yz, RelId(2));
4256        assert_eq!(m.rel_xz, RelId(3));
4257    }
4258
4259    /// Triangle output_columns `[Column(0), Column(3), Column(3)]`
4260    /// MUST be rejected — second col must be 1 or 2, not 3.
4261    #[test]
4262    fn match_rejects_invalid_triangle_output_columns() {
4263        let mut node = canonical_multiway();
4264        if let RirNode::MultiWayJoin { output_columns, .. } = &mut node {
4265            *output_columns = vec![
4266                ProjectExpr::Column(0),
4267                ProjectExpr::Column(3),
4268                ProjectExpr::Column(3),
4269            ];
4270        }
4271        assert!(match_multiway_triangle(&node).is_none());
4272    }
4273
4274    #[test]
4275    fn match_rejects_arity_mismatched_output_columns() {
4276        let mut node = canonical_multiway();
4277        if let RirNode::MultiWayJoin { output_columns, .. } = &mut node {
4278            *output_columns = vec![ProjectExpr::Column(0), ProjectExpr::Column(1)];
4279        }
4280        assert!(match_multiway_triangle(&node).is_none());
4281    }
4282
4283    #[test]
4284    fn match_rejects_malformed_slot_vars() {
4285        // [[A,B],[B,C],[A,B]] — last slot is wrong (should be [A,C]).
4286        let mut node = canonical_multiway();
4287        if let RirNode::MultiWayJoin { slot_vars, .. } = &mut node {
4288            *slot_vars = vec![
4289                vec![Some(0u32), Some(1)],
4290                vec![Some(1u32), Some(2)],
4291                vec![Some(0u32), Some(1)],
4292            ];
4293        }
4294        assert!(match_multiway_triangle(&node).is_none());
4295    }
4296
4297    #[test]
4298    fn match_rejects_repeated_var_in_slot() {
4299        let mut node = canonical_multiway();
4300        if let RirNode::MultiWayJoin { slot_vars, .. } = &mut node {
4301            // [[A, A], …] — repeated var in slot 0.
4302            *slot_vars = vec![
4303                vec![Some(0u32), Some(0)],
4304                vec![Some(1u32), Some(2)],
4305                vec![Some(0u32), Some(2)],
4306            ];
4307        }
4308        assert!(match_multiway_triangle(&node).is_none());
4309    }
4310
4311    #[test]
4312    fn match_rejects_non_scan_input() {
4313        let mut node = canonical_multiway();
4314        if let RirNode::MultiWayJoin { inputs, .. } = &mut node {
4315            inputs[0] = RirNode::Unit;
4316        }
4317        assert!(match_multiway_triangle(&node).is_none());
4318    }
4319
4320    #[test]
4321    fn match_rejects_input_arity_mismatch() {
4322        let mut node = canonical_multiway();
4323        if let RirNode::MultiWayJoin { inputs, .. } = &mut node {
4324            inputs.pop();
4325        }
4326        assert!(match_multiway_triangle(&node).is_none());
4327    }
4328
4329    fn env_lock() -> &'static Mutex<()> {
4330        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4331        LOCK.get_or_init(|| Mutex::new(()))
4332    }
4333
4334    struct EnvSnapshot {
4335        force: Option<String>,
4336    }
4337
4338    impl EnvSnapshot {
4339        fn capture_and_clear() -> Self {
4340            let snapshot = Self {
4341                force: std::env::var(ENV_USE_WCOJ_TRIANGLE_U32).ok(),
4342            };
4343
4344            // SAFETY: The caller holds `env_lock`, serializing mutation of
4345            // this process-global WCOJ env var.
4346            unsafe {
4347                std::env::remove_var(ENV_USE_WCOJ_TRIANGLE_U32);
4348            }
4349
4350            snapshot
4351        }
4352    }
4353
4354    impl Drop for EnvSnapshot {
4355        fn drop(&mut self) {
4356            // SAFETY: The snapshot is dropped before `env_lock` is released,
4357            // so restoration is serialized even if the test body panics.
4358            unsafe {
4359                match self.force.take() {
4360                    Some(v) => std::env::set_var(ENV_USE_WCOJ_TRIANGLE_U32, v),
4361                    None => std::env::remove_var(ENV_USE_WCOJ_TRIANGLE_U32),
4362                }
4363            }
4364        }
4365    }
4366
4367    fn with_wcoj_env<R>(f: impl FnOnce() -> R) -> R {
4368        let _guard = env_lock().lock().expect("WCOJ env lock poisoned");
4369        let _snapshot = EnvSnapshot::capture_and_clear();
4370        f()
4371    }
4372
4373    fn set_env(name: &str, value: &str) {
4374        // SAFETY: Callers are inside `with_wcoj_env`, which serializes and
4375        // restores these process-global WCOJ env vars.
4376        unsafe {
4377            std::env::set_var(name, value);
4378        }
4379    }
4380
4381    #[test]
4382    fn stats_gate_defaults_on_when_env_unset() {
4383        with_wcoj_env(|| {
4384            assert!(wcoj_adaptive_enabled(None));
4385            assert!(wcoj_adaptive_enabled(Some(true)));
4386            assert!(!wcoj_adaptive_enabled(Some(false)));
4387        });
4388    }
4389
4390    #[test]
4391    fn config_controls_stats_gate() {
4392        with_wcoj_env(|| {
4393            assert!(wcoj_adaptive_enabled(Some(true)));
4394            assert!(!wcoj_adaptive_enabled(Some(false)));
4395        });
4396    }
4397
4398    #[test]
4399    fn force_resolver_config_still_overrides_env() {
4400        with_wcoj_env(|| {
4401            set_env(ENV_USE_WCOJ_TRIANGLE_U32, "1");
4402            assert!(wcoj_gate_enabled(None));
4403            assert!(!wcoj_gate_enabled(Some(false)));
4404
4405            set_env(ENV_USE_WCOJ_TRIANGLE_U32, "0");
4406            assert!(!wcoj_gate_enabled(None));
4407            assert!(wcoj_gate_enabled(Some(true)));
4408        });
4409    }
4410
4411    // -------------------------------------------------------------
4412    // WCOJ error-decline observability (counter + XLOG_WCOJ_STRICT).
4413    // -------------------------------------------------------------
4414
4415    #[test]
4416    fn error_decline_counts_and_falls_back_by_default() {
4417        with_wcoj_env(|| {
4418            let mut counter = 0u64;
4419            let err = xlog_core::XlogError::Kernel("synthetic layout failure".to_string());
4420            let out = super::wcoj_decline_on_error(&mut counter, "triangle", err)
4421                .expect("default mode must decline to the binary-join fallback, not error");
4422            assert!(out.is_none(), "decline must hand control to the fallback");
4423            assert_eq!(counter, 1, "every error decline must be counted");
4424        });
4425    }
4426
4427    #[test]
4428    fn error_decline_propagates_under_strict_env() {
4429        with_wcoj_env(|| {
4430            set_env(super::ENV_WCOJ_STRICT, "1");
4431            let mut counter = 0u64;
4432            let err = xlog_core::XlogError::Kernel("synthetic layout failure".to_string());
4433            let out = super::wcoj_decline_on_error(&mut counter, "triangle", err);
4434            // SAFETY: serialized + restored under `with_wcoj_env`'s lock.
4435            unsafe {
4436                std::env::remove_var(super::ENV_WCOJ_STRICT);
4437            }
4438            match out {
4439                Err(err) => assert!(
4440                    err.to_string().contains("synthetic layout failure"),
4441                    "strict mode must surface the original error: {err}"
4442                ),
4443                Ok(_) => panic!("XLOG_WCOJ_STRICT=1 must propagate the pipeline error"),
4444            }
4445            assert_eq!(counter, 1, "strict mode still counts the decline");
4446        });
4447    }
4448
4449    // -------------------------------------------------------------
4450    // 4-cycle env-resolver + matcher tests.
4451    // -------------------------------------------------------------
4452
4453    use super::{
4454        match_multiway_4cycle, wcoj_4cycle_adaptive_enabled, wcoj_4cycle_disabled,
4455        wcoj_4cycle_gate_enabled, ENV_DISABLE_WCOJ_4CYCLE, ENV_USE_WCOJ_4CYCLE,
4456        ENV_USE_WCOJ_4CYCLE_ADAPTIVE,
4457    };
4458
4459    struct EnvSnapshot4Cycle {
4460        force: Option<String>,
4461        adaptive: Option<String>,
4462        disable: Option<String>,
4463    }
4464
4465    impl EnvSnapshot4Cycle {
4466        fn capture_and_clear() -> Self {
4467            let snap = Self {
4468                force: std::env::var(ENV_USE_WCOJ_4CYCLE).ok(),
4469                adaptive: std::env::var(ENV_USE_WCOJ_4CYCLE_ADAPTIVE).ok(),
4470                disable: std::env::var(ENV_DISABLE_WCOJ_4CYCLE).ok(),
4471            };
4472            // SAFETY: caller holds env_lock.
4473            unsafe {
4474                std::env::remove_var(ENV_USE_WCOJ_4CYCLE);
4475                std::env::remove_var(ENV_USE_WCOJ_4CYCLE_ADAPTIVE);
4476                std::env::remove_var(ENV_DISABLE_WCOJ_4CYCLE);
4477            }
4478            snap
4479        }
4480    }
4481
4482    impl Drop for EnvSnapshot4Cycle {
4483        fn drop(&mut self) {
4484            // SAFETY: caller holds env_lock.
4485            unsafe {
4486                match self.force.take() {
4487                    Some(v) => std::env::set_var(ENV_USE_WCOJ_4CYCLE, v),
4488                    None => std::env::remove_var(ENV_USE_WCOJ_4CYCLE),
4489                }
4490                match self.adaptive.take() {
4491                    Some(v) => std::env::set_var(ENV_USE_WCOJ_4CYCLE_ADAPTIVE, v),
4492                    None => std::env::remove_var(ENV_USE_WCOJ_4CYCLE_ADAPTIVE),
4493                }
4494                match self.disable.take() {
4495                    Some(v) => std::env::set_var(ENV_DISABLE_WCOJ_4CYCLE, v),
4496                    None => std::env::remove_var(ENV_DISABLE_WCOJ_4CYCLE),
4497                }
4498            }
4499        }
4500    }
4501
4502    fn with_4cycle_env<R>(f: impl FnOnce() -> R) -> R {
4503        let _guard = env_lock().lock().expect("4-cycle env lock poisoned");
4504        let _snap = EnvSnapshot4Cycle::capture_and_clear();
4505        f()
4506    }
4507
4508    #[test]
4509    fn force_4cycle_resolver_defaults_off_when_env_unset() {
4510        with_4cycle_env(|| {
4511            assert!(!wcoj_4cycle_gate_enabled(None));
4512            assert!(wcoj_4cycle_gate_enabled(Some(true)));
4513            assert!(!wcoj_4cycle_gate_enabled(Some(false)));
4514        });
4515    }
4516
4517    #[test]
4518    fn force_4cycle_resolver_env_can_enable() {
4519        with_4cycle_env(|| {
4520            set_env(ENV_USE_WCOJ_4CYCLE, "1");
4521            assert!(wcoj_4cycle_gate_enabled(None));
4522            set_env(ENV_USE_WCOJ_4CYCLE, "true");
4523            assert!(wcoj_4cycle_gate_enabled(None));
4524            set_env(ENV_USE_WCOJ_4CYCLE, "0");
4525            assert!(!wcoj_4cycle_gate_enabled(None));
4526        });
4527    }
4528
4529    /// **Locks the 4-cycle adaptive contract**: adaptive opt-in
4530    /// defaults OFF, unlike triangle's default-on. If a future
4531    /// default flips, that change must update this test
4532    /// explicitly with bench evidence.
4533    #[test]
4534    fn adaptive_4cycle_resolver_defaults_off_when_env_unset() {
4535        with_4cycle_env(|| {
4536            assert!(
4537                !wcoj_4cycle_adaptive_enabled(None),
4538                "4-cycle adaptive must be OPT-IN by default (unlike triangle's default-on)"
4539            );
4540            assert!(wcoj_4cycle_adaptive_enabled(Some(true)));
4541            assert!(!wcoj_4cycle_adaptive_enabled(Some(false)));
4542        });
4543    }
4544
4545    #[test]
4546    fn adaptive_4cycle_resolver_env_can_enable() {
4547        with_4cycle_env(|| {
4548            set_env(ENV_USE_WCOJ_4CYCLE_ADAPTIVE, "1");
4549            assert!(wcoj_4cycle_adaptive_enabled(None));
4550            set_env(ENV_USE_WCOJ_4CYCLE_ADAPTIVE, "0");
4551            assert!(!wcoj_4cycle_adaptive_enabled(None));
4552            set_env(ENV_USE_WCOJ_4CYCLE_ADAPTIVE, "true");
4553            assert!(wcoj_4cycle_adaptive_enabled(None));
4554        });
4555    }
4556
4557    #[test]
4558    fn kill_4cycle_resolver_honors_env_and_config() {
4559        with_4cycle_env(|| {
4560            assert!(!wcoj_4cycle_disabled(None));
4561            set_env(ENV_DISABLE_WCOJ_4CYCLE, "1");
4562            assert!(wcoj_4cycle_disabled(None));
4563            assert!(!wcoj_4cycle_disabled(Some(false)));
4564            set_env(ENV_DISABLE_WCOJ_4CYCLE, "0");
4565            assert!(wcoj_4cycle_disabled(Some(true)));
4566        });
4567    }
4568
4569    fn canonical_4cycle_multiway() -> RirNode {
4570        RirNode::MultiWayJoin {
4571            inputs: vec![
4572                RirNode::Scan { rel: RelId(1) },
4573                RirNode::Scan { rel: RelId(2) },
4574                RirNode::Scan { rel: RelId(3) },
4575                RirNode::Scan { rel: RelId(4) },
4576            ],
4577            slot_vars: vec![
4578                vec![Some(0u32), Some(1)],
4579                vec![Some(1u32), Some(2)],
4580                vec![Some(2u32), Some(3)],
4581                vec![Some(3u32), Some(0)],
4582            ],
4583            output_columns: vec![
4584                ProjectExpr::Column(0),
4585                ProjectExpr::Column(1),
4586                ProjectExpr::Column(3),
4587                ProjectExpr::Column(5),
4588            ],
4589            fallback: Box::new(RirNode::Unit),
4590            plan: None,
4591            var_order: None,
4592        }
4593    }
4594
4595    #[test]
4596    fn match_4cycle_canonical_returns_four_rels() {
4597        let node = canonical_4cycle_multiway();
4598        let m = match_multiway_4cycle(&node).expect("must match canonical 4-cycle");
4599        assert_eq!(m.rel_e1, RelId(1));
4600        assert_eq!(m.rel_e2, RelId(2));
4601        assert_eq!(m.rel_e3, RelId(3));
4602        assert_eq!(m.rel_e4, RelId(4));
4603    }
4604
4605    #[test]
4606    fn match_4cycle_rejects_non_multiway() {
4607        assert!(match_multiway_4cycle(&RirNode::Scan { rel: RelId(1) }).is_none());
4608    }
4609
4610    #[test]
4611    fn match_4cycle_rejects_triangle_shape() {
4612        // Triangle is 3 inputs — 4-cycle matcher must reject.
4613        let triangle = RirNode::MultiWayJoin {
4614            inputs: vec![
4615                RirNode::Scan { rel: RelId(1) },
4616                RirNode::Scan { rel: RelId(2) },
4617                RirNode::Scan { rel: RelId(3) },
4618            ],
4619            slot_vars: vec![
4620                vec![Some(0u32), Some(1)],
4621                vec![Some(1u32), Some(2)],
4622                vec![Some(0u32), Some(2)],
4623            ],
4624            output_columns: vec![
4625                ProjectExpr::Column(0),
4626                ProjectExpr::Column(1),
4627                ProjectExpr::Column(3),
4628            ],
4629            fallback: Box::new(RirNode::Unit),
4630            plan: None,
4631            var_order: None,
4632        };
4633        assert!(match_multiway_4cycle(&triangle).is_none());
4634    }
4635
4636    #[test]
4637    fn match_4cycle_rejects_rotated_output_columns() {
4638        let mut node = canonical_4cycle_multiway();
4639        if let RirNode::MultiWayJoin { output_columns, .. } = &mut node {
4640            output_columns.swap(0, 1);
4641        }
4642        assert!(match_multiway_4cycle(&node).is_none());
4643    }
4644
4645    /// 4-cycle Alt-grouping output_columns
4646    /// `[Column(5), Column(0), Column(1), Column(3)]` must
4647    /// match. The matcher relaxation accepts both
4648    /// Default `[0, 1, 3, 5]` and Alt `[5, 0, 1, 3]`.
4649    #[test]
4650    fn match_4cycle_accepts_alt_grouping_output_columns() {
4651        let mut node = canonical_4cycle_multiway();
4652        if let RirNode::MultiWayJoin { output_columns, .. } = &mut node {
4653            *output_columns = vec![
4654                ProjectExpr::Column(5),
4655                ProjectExpr::Column(0),
4656                ProjectExpr::Column(1),
4657                ProjectExpr::Column(3),
4658            ];
4659        }
4660        let m = match_multiway_4cycle(&node)
4661            .expect("matcher must accept the Alt-grouping output-column layout");
4662        // RelIds preserved positionally from the body's
4663        // MultiWayJoin.inputs (which are in canonical
4664        // semantic order [WX, XY, YZ, ZW]).
4665        assert_eq!(m.rel_e1, RelId(1));
4666        assert_eq!(m.rel_e2, RelId(2));
4667        assert_eq!(m.rel_e3, RelId(3));
4668        assert_eq!(m.rel_e4, RelId(4));
4669    }
4670
4671    /// 4-cycle output_columns `[1, 0, 3, 5]` (only swap
4672    /// of cols 0 and 1 vs Default) must STILL be rejected —
4673    /// it's neither Default nor Alt.
4674    #[test]
4675    fn match_4cycle_rejects_invalid_output_columns() {
4676        let mut node = canonical_4cycle_multiway();
4677        if let RirNode::MultiWayJoin { output_columns, .. } = &mut node {
4678            *output_columns = vec![
4679                ProjectExpr::Column(1),
4680                ProjectExpr::Column(0),
4681                ProjectExpr::Column(3),
4682                ProjectExpr::Column(5),
4683            ];
4684        }
4685        assert!(match_multiway_4cycle(&node).is_none());
4686    }
4687
4688    #[test]
4689    fn match_4cycle_rejects_arity_mismatched_output_columns() {
4690        let mut node = canonical_4cycle_multiway();
4691        if let RirNode::MultiWayJoin { output_columns, .. } = &mut node {
4692            output_columns.pop();
4693        }
4694        assert!(match_multiway_4cycle(&node).is_none());
4695    }
4696
4697    #[test]
4698    fn match_4cycle_rejects_unclosed_cycle() {
4699        // Slot 3's second var is supposed to equal slot 0's first
4700        // var (closing the cycle). Replace with a fresh id.
4701        let mut node = canonical_4cycle_multiway();
4702        if let RirNode::MultiWayJoin { slot_vars, .. } = &mut node {
4703            slot_vars[3] = vec![Some(3), Some(99)];
4704        }
4705        assert!(match_multiway_4cycle(&node).is_none());
4706    }
4707
4708    #[test]
4709    fn match_4cycle_rejects_non_scan_input() {
4710        let mut node = canonical_4cycle_multiway();
4711        if let RirNode::MultiWayJoin { inputs, .. } = &mut node {
4712            inputs[0] = RirNode::Unit;
4713        }
4714        assert!(match_multiway_4cycle(&node).is_none());
4715    }
4716
4717    #[test]
4718    fn match_4cycle_rejects_input_arity_mismatch() {
4719        let mut node = canonical_4cycle_multiway();
4720        if let RirNode::MultiWayJoin { inputs, .. } = &mut node {
4721            inputs.push(RirNode::Scan { rel: RelId(5) });
4722        }
4723        assert!(match_multiway_4cycle(&node).is_none());
4724    }
4725}