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