Skip to main content

xlog_logic/
promote.rs

1//! `MultiWayJoin` promotion pass.
2//! `ChainJoin` promotion for 2-atom chains.
3//! Recursive-SCC promotion for occurrence-level recursive bodies.
4//!
5//! Walks an [`ExecutionPlan`] (post-lowering, post-optimizer) and
6//! rewrites recognized triangle / 4-cycle / K-clique subtrees in
7//! `rule.body` to [`RirNode::MultiWayJoin`], plus recognized 2-atom
8//! chains to [`RirNode::ChainJoin`]. Idempotent.
9//!
10//! ## Eligibility
11//!
12//! Exact-match against the canonical lowered-and-optimized triangle
13//! shape — the same tree shape recognized by the legacy triangle
14//! executor matcher:
15//!
16//! ```text
17//! Project {
18//!     input: Join {
19//!         left: Join {
20//!             left: Scan(rel_xy),
21//!             right: Scan(rel_yz),
22//!             left_keys: [1],
23//!             right_keys: [0],
24//!             join_type: Inner,
25//!         },
26//!         right: Scan(rel_xz),
27//!         left_keys: [0, 3],
28//!         right_keys: [0, 1],
29//!         join_type: Inner,
30//!     },
31//!     columns: [Column(0), Column(1), Column(3)],
32//! }
33//! ```
34//!
35//! 4-cycle has the analogous canonical lowered shape. Any
36//! deviation in shape, predicate-pushdown-altered Join, or
37//! computed-projection variants is left untouched.
38//!
39//! ## Recursive SCC handling
40//!
41//! The promoter does not blanket-skip recursive SCCs. It gates
42//! per-rule on the number of body Scans whose RelId resolves to a
43//! predicate inside the rule's head SCC:
44//!
45//! | Recursive Scans in body | Behavior                                 |
46//! |-------------------------|------------------------------------------|
47//! | 0 (stable rule)         | Promote                                  |
48//! | 1 (linear recursion)    | Promote                                  |
49//! | ≥ 2 (multi-recursion)   | Promote                                  |
50//!
51//! Per arXiv:2604.20073, semi-naïve evaluation reasons over
52//! body-clause OCCURRENCES, not predicate names. Multi-recursive
53//! bodies including same-predicate self-recursive
54//! occurrences (e.g. `tri(X,Y,Z) :- p(X,Y), p(Y,Z), q(X,Z)` with
55//! `p` recursive) are admitted. The recursive engine
56//! (`Executor::execute_recursive_scc`) consumes the resulting
57//! `MultiWayJoin` via `execute_wcoj_or_fallback_node`, dispatching
58//! WCOJ kernels on the seeding pass and on each iteration's variant.
59//! The runtime occurrence-identity rewrite at
60//! `crates/xlog-runtime/src/executor/rewrite.rs:303-311 + :477-504`
61//! ensures per-variant rewrites preserve the N-th occurrence
62//! independently in `MultiWayJoin.inputs` and `MultiWayJoin.fallback`.
63//!
64//! ## Fallback identity invariant
65//!
66//! The promoter captures the exact post-optimizer subtree as
67//! `MultiWayJoin.fallback`. Executing `fallback` produces the same
68//! row set as the pre-promotion tree — guaranteed by being the
69//! identical [`RirNode`].
70//!
71//! ## Out of scope
72//!
73//! * Cost model, selectivity reordering, variable-ordering choices.
74//! * Stream-aligned multiplexing and adaptive histogram resolution
75//!   for recursive cliques.
76//! * 4-way / general-arity admission beyond triangle / 4-cycle /
77//!   supported K-clique and generic Free Join paths.
78
79use std::collections::HashMap;
80use xlog_core::RelId;
81use xlog_ir::rir::{
82    ColumnSwap, CostPredictionRecord as RirCostPredictionRecord, KCliqueVariableOrder,
83    MultiwayPlan, PlannedHashReason, ProjectExpr, SortedLayoutSpec, StreamGroupId, VariableOrder,
84    K_CLIQUE_MAX_EDGES, K_CLIQUE_MAX_K,
85};
86use xlog_ir::{ExecutionPlan, JoinType, RirNode};
87use xlog_stats::{StatsManager, StatsSnapshot};
88
89use crate::compiler_config::CompilerConfig;
90use crate::hypergraph::var_order::{
91    plan_kclique_var_order, FullVariableOrder, KCliqueEdge, KCliqueShape,
92};
93use crate::hypergraph::VertexId;
94use crate::wcoj_var_ordering::{wcoj_cost_gate_predicts_wcoj, WcojVariableOrderingModel};
95
96/// Walk an `ExecutionPlan` and rewrite eligible triangle / 4-cycle
97/// subtrees in each rule body to `RirNode::MultiWayJoin`. Idempotent.
98///
99/// `CompiledRule.meta` is preserved unchanged — the metadata is
100/// rule-level (head schema, row estimates, layout hints), not
101/// node-level, and the promoter does not alter rule semantics.
102///
103/// `rel_ids` is the canonical predicate-name → RelId map used to
104/// resolve body Scans against the head SCC's predicate set. Pass
105/// `Compiler::rel_ids()` (or `Lowerer::rel_ids()`) at the call site.
106///
107/// **Recursive SCC bodies.** Multi-recursive
108/// bodies are admitted, including same-predicate self-recursive
109/// occurrences (per arXiv:2604.20073 — semi-naïve evaluation reasons
110/// over body-clause OCCURRENCES, not predicate names). The triangle /
111/// 4-cycle shape gates already cap atom count at 3 / 4, so the
112/// recursive-Scan count is implicitly bounded; the runtime's
113/// per-variant rewrite + dispatch loop in `execute_recursive_scc`
114/// handles N variants correctly with the occurrence-identity rewrite.
115pub fn promote_multiway(
116    plan: &mut ExecutionPlan,
117    _rel_ids: &HashMap<String, RelId>,
118    stats: &StatsManager,
119    config: &CompilerConfig,
120) {
121    // Generic Free Join: the general multiway promoter sizes Scan leaves
122    // from relation arities (the clique walker's hardcoded arity-2
123    // assumption does not generalize). Cloned up front because the
124    // per-rule loop holds `rules_by_scc` mutably.
125    let rel_arities = plan.rel_arities.clone();
126    for (scc_id, rules) in plan.rules_by_scc.iter_mut().enumerate() {
127        if plan.sccs.get(scc_id).is_none() {
128            continue;
129        }
130        for rule in rules.iter_mut() {
131            // Occurrence-level recursive gate: the previous
132            // `recursive_scan_count > 1` cutoff is absent. The
133            // triangle / 4-cycle shape gates (try_promote_*) cap atom
134            // count at 3 / 4, implicitly bounding the recursive-Scan
135            // count at the rule's atom count. Multi-recursive bodies
136            // (count >= 2), including same-predicate self-recursive
137            // (e.g. `tri(X,Y,Z) :- p(X,Y), p(Y,Z), q(X,Z)`), are
138            // admitted; the runtime's per-variant rewrite + dispatch
139            // loop in `execute_recursive_scc` handles N variants
140            // correctly after the occurrence-identity rewrite at
141            // `crates/xlog-runtime/src/executor/rewrite.rs:303-311 +
142            // :477-504`.
143            // ChainJoin promotion: a 2-atom chain is not
144            // paper-prescribed WCOJ; it is xlog-original routing
145            // motivated by profiler traces. Production emits a
146            // first-class `ChainJoin` so walkers and dispatchers can
147            // distinguish the chain route from paper-derived
148            // `MultiWayJoin` shapes.
149            // Aggregate group-by fusion: aggregate rules wrap the join tree as
150            // Project{final} -> GroupBy -> Project{group} -> <join tree>,
151            // so the shape gates below (which see only the outer Project)
152            // never promote triangle bodies under aggregate heads. Descend
153            // through the wrapper and promote the inner triangle in place;
154            // the executor's fused group-by-root count hook dispatches it,
155            // and a declined dispatch still executes the embedded binary
156            // fallback unchanged.
157            if try_promote_triangle_inside_aggregate(&mut rule.body, stats, config) {
158                continue;
159            }
160            // 4-cycle aggregate descent: sibling of the descent above (atom-count gates
161            // make the two matchers disjoint). The executor's fused hook
162            // dispatches Count only; declined dispatches still execute the
163            // embedded binary fallback unchanged.
164            if try_promote_4cycle_inside_aggregate(&mut rule.body, stats, config) {
165                continue;
166            }
167            // K-clique aggregate descent: k = 5, 6 sibling of the descents above
168            // (scan-count gates keep all three matchers disjoint). The
169            // executor's fused hook dispatches Count grouped by the
170            // plan's root variable only; declined dispatches still
171            // execute the embedded binary fallback unchanged.
172            if try_promote_clique_inside_aggregate(&mut rule.body, stats) {
173                continue;
174            }
175            // Generic multiway aggregate descent: sibling of the descents above
176            // — any >=3-atom inner-join tree under an aggregate
177            // wrapper that no dedicated descent recognized becomes a
178            // FreeJoin-marked MultiWayJoin. The executor's fused hook
179            // dispatches factorized count-by-root only; declined
180            // dispatches still execute the embedded binary fallback
181            // unchanged.
182            if try_promote_general_multiway_inside_aggregate(&mut rule.body, &rel_arities) {
183                continue;
184            }
185            if let Some(promoted) = try_promote_chain(&rule.body) {
186                rule.body = promoted;
187                continue;
188            }
189            // Canonical-shape robustness: the lowerer's bushy DP planner may
190            // emit a right-deep `Project(Join(Scan, Join(Scan,
191            // Scan)))` triangle for small-card inputs (snapshot-driven
192            // recompile flow) — semantically a triangle, but the original
193            // canonical-shape matcher rejects it. `normalize_*_to_left_deep`
194            // detects those alternative-but-equivalent shapes and
195            // commutativity-rewrites them to the canonical left-deep
196            // form before matching. Idempotent on already-canonical
197            // bodies — left-deep input passes through unchanged.
198            let normalized_tri = normalize_triangle_to_left_deep(&rule.body);
199            let body_for_tri = normalized_tri.as_ref().unwrap_or(&rule.body);
200            if let Some(promoted) = try_promote_triangle(body_for_tri, stats, config) {
201                rule.body = promoted;
202                continue;
203            }
204            let normalized_4c = normalize_4cycle_to_bushy(&rule.body);
205            let body_for_4c = normalized_4c.as_ref().unwrap_or(&rule.body);
206            if let Some(promoted) = try_promote_4cycle(body_for_4c, stats, config) {
207                rule.body = promoted;
208                continue;
209            }
210            // K-clique promotion for k=5..k=8. Tree-flatten +
211            // complete-K_k validation. Robust to left-deep /
212            // right-deep / bushy. Order is doc anchor only;
213            // a body matching one K cannot also match another
214            // (different scan count). Recursive clique bodies are
215            // admitted so the runtime can rebuild leader-edge
216            // metadata from the current semi-naive store state.
217            if let Some(promoted) = try_promote_clique_k(&rule.body, 5, stats)
218                .or_else(|| try_promote_clique_k(&rule.body, 6, stats))
219                .or_else(|| try_promote_clique_k(&rule.body, 7, stats))
220                .or_else(|| try_promote_clique_k(&rule.body, 8, stats))
221            {
222                rule.body = promoted;
223                continue;
224            }
225            // Generic Free Join — general >=3-atom inner-join bodies that
226            // no dedicated shape promoter recognized become a generic
227            // `MultiWayJoin` (plan: None). The executor derives a Free
228            // Join plan from `slot_vars` at dispatch time
229            // (`try_dispatch_free_join`); structural declines
230            // (non-prefix bound columns, non-u32/Symbol inputs,
231            // repeated cover vars, dedicated-shape carve-outs) execute
232            // the embedded binary fallback unchanged.
233            if let Some(promoted) = try_promote_general_multiway(&rule.body, &rel_arities) {
234                rule.body = promoted;
235                continue;
236            }
237        }
238    }
239}
240
241/// Triangle semantic-slot encoding: encode each atom-column slot as a u8 in `0..6` —
242/// `(atom_idx * 2) + col_idx` where `atom_idx` is `0` =
243/// inner-left, `1` = inner-right, `2` = outer-third.
244fn ac_idx(atom_idx: u8, col_idx: u8) -> u8 {
245    debug_assert!(atom_idx < 3);
246    debug_assert!(col_idx < 2);
247    atom_idx * 2 + col_idx
248}
249
250/// Map an inner-output column index `0..4` to the underlying
251/// atom-column. `0..2` come from the inner-left scan;
252/// `2..4` from the inner-right scan.
253fn inner_output_ac(k: usize) -> Option<(u8, u8)> {
254    match k {
255        0 => Some((0, 0)),
256        1 => Some((0, 1)),
257        2 => Some((1, 0)),
258        3 => Some((1, 1)),
259        _ => None,
260    }
261}
262
263/// Map an outer-output column index `0..6` to the underlying
264/// atom-column. `0..4` come from the inner subtree;
265/// `4..6` from the outer-third scan.
266fn outer_output_ac(k: usize) -> Option<(u8, u8)> {
267    match k {
268        0..=3 => inner_output_ac(k),
269        4 => Some((2, 0)),
270        5 => Some((2, 1)),
271        _ => None,
272    }
273}
274
275/// Tiny union-find over `0..6`. Returns the root of `x` after
276/// path compression.
277fn uf_find(parent: &mut [u8; 6], x: u8) -> u8 {
278    let mut root = x;
279    while parent[root as usize] != root {
280        root = parent[root as usize];
281    }
282    let mut cur = x;
283    while parent[cur as usize] != root {
284        let next = parent[cur as usize];
285        parent[cur as usize] = root;
286        cur = next;
287    }
288    root
289}
290
291fn uf_union(parent: &mut [u8; 6], a: u8, b: u8) {
292    let ra = uf_find(parent, a);
293    let rb = uf_find(parent, b);
294    if ra != rb {
295        parent[rb as usize] = ra;
296    }
297}
298
299/// Semantic-slot inference for triangle bodies. Given
300/// the inner-pair scans + outer scan + key shapes + project
301/// columns, deduce which atom is the XY-edge / YZ-edge /
302/// XZ-edge from the variable-equivalence graph. Returns
303/// `(rel_xy, rel_yz, rel_xz)` regardless of the body's
304/// positional layout.
305///
306/// Returns `None` when:
307///   * Key arities don't match the canonical lowered triangle
308///     (1-element inner keys, 2-element outer keys).
309///   * Project doesn't have exactly 3 `Column(_)` entries.
310///   * The variable-equivalence graph doesn't yield exactly 3
311///     classes of size 2 (i.e., not a triangle's 3-vars-each-
312///     bound-twice signature).
313///   * The 3 head columns don't pick 3 distinct equivalence
314///     classes.
315///   * Any atom doesn't bind exactly 2 of the 3 head variables.
316#[expect(
317    clippy::too_many_arguments,
318    reason = "triangle inference validates three relation slots and four lowered key vectors without manufacturing an aggregate input type"
319)]
320fn infer_triangle_semantics(
321    inner_left_rel: RelId,
322    inner_right_rel: RelId,
323    outer_third_rel: RelId,
324    lk2: &[usize],
325    rk2: &[usize],
326    lk1: &[usize],
327    rk1: &[usize],
328    project_cols: &[ProjectExpr],
329) -> Option<(RelId, RelId, RelId)> {
330    if lk2.len() != 1 || rk2.len() != 1 {
331        return None;
332    }
333    if lk1.len() != 2 || rk1.len() != 2 {
334        return None;
335    }
336    if project_cols.len() != 3 {
337        return None;
338    }
339    if lk2[0] >= 2 || rk2[0] >= 2 {
340        return None;
341    }
342    if lk1.iter().any(|k| *k >= 4) || rk1.iter().any(|k| *k >= 2) {
343        return None;
344    }
345
346    // Build equivalence classes via union-find over the 6
347    // atom-column slots.
348    let mut parent = [0u8, 1, 2, 3, 4, 5];
349
350    // Inner join: inner_left.col[lk2[0]] ≡ inner_right.col[rk2[0]]
351    uf_union(
352        &mut parent,
353        ac_idx(0, lk2[0] as u8),
354        ac_idx(1, rk2[0] as u8),
355    );
356
357    // Outer join: 2 equivalences from (lk1, rk1).
358    for i in 0..2 {
359        let (inner_atom, inner_col) = inner_output_ac(lk1[i])?;
360        uf_union(
361            &mut parent,
362            ac_idx(inner_atom, inner_col),
363            ac_idx(2, rk1[i] as u8),
364        );
365    }
366
367    // Roots define classes. We need exactly 3 distinct roots
368    // covering all 6 slots, with each class of size 2.
369    let roots: [u8; 6] = std::array::from_fn(|i| uf_find(&mut parent, i as u8));
370    let mut counts: HashMap<u8, u8> = HashMap::new();
371    for r in &roots {
372        *counts.entry(*r).or_insert(0) += 1;
373    }
374    if counts.len() != 3 || counts.values().any(|c| *c != 2) {
375        return None;
376    }
377
378    // Map head columns (project_cols) to their equivalence
379    // classes.
380    let mut head_classes: [u8; 3] = [0; 3];
381    for (i, pc) in project_cols.iter().enumerate() {
382        let ProjectExpr::Column(k) = pc else {
383            return None;
384        };
385        let (atom, col) = outer_output_ac(*k)?;
386        head_classes[i] = uf_find(&mut parent, ac_idx(atom, col));
387    }
388    // Head must pick 3 distinct classes (X, Y, Z).
389    if head_classes[0] == head_classes[1]
390        || head_classes[0] == head_classes[2]
391        || head_classes[1] == head_classes[2]
392    {
393        return None;
394    }
395    let x_class = head_classes[0];
396    let y_class = head_classes[1];
397    let z_class = head_classes[2];
398
399    // For each atom (0=inner_left, 1=inner_right, 2=third),
400    // determine which two head-vars it binds.
401    let atom_classes = |atom_idx: u8| -> (u8, u8) {
402        (
403            roots[ac_idx(atom_idx, 0) as usize],
404            roots[ac_idx(atom_idx, 1) as usize],
405        )
406    };
407
408    let atom_rels = [inner_left_rel, inner_right_rel, outer_third_rel];
409    let mut rel_xy: Option<RelId> = None;
410    let mut rel_yz: Option<RelId> = None;
411    let mut rel_xz: Option<RelId> = None;
412
413    for atom_idx in 0..3u8 {
414        let (c0, c1) = atom_classes(atom_idx);
415        let binds_x = c0 == x_class || c1 == x_class;
416        let binds_y = c0 == y_class || c1 == y_class;
417        let binds_z = c0 == z_class || c1 == z_class;
418        match (binds_x, binds_y, binds_z) {
419            (true, true, false) => rel_xy = Some(atom_rels[atom_idx as usize]),
420            (false, true, true) => rel_yz = Some(atom_rels[atom_idx as usize]),
421            (true, false, true) => rel_xz = Some(atom_rels[atom_idx as usize]),
422            _ => return None,
423        }
424    }
425
426    Some((rel_xy?, rel_yz?, rel_xz?))
427}
428
429/// Right-deep triangle normalization: detect a right-deep triangle body
430/// `Project(Join(Scan(third), Join(Scan(inner_l), Scan(inner_r))))`
431/// and commutativity-rewrite it to the canonical left-deep
432/// form `Project(Join(Join(Scan(inner_l), Scan(inner_r)),
433/// Scan(third)))`. Returns `None` for any non-matching shape
434/// (left-deep / fully unknown / nested deeper) — caller falls
435/// back to the original body.
436///
437/// The rewrite preserves semantics under inner-join
438/// commutativity. Project columns must be remapped because
439/// the output column layout swaps:
440///   * Right-deep: `[third.0, third.1, inner_l.0, inner_l.1,
441///     inner_r.0, inner_r.1]`
442///   * Left-deep:  `[inner_l.0, inner_l.1, inner_r.0, inner_r.1,
443///     third.0, third.1]`
444///   * Remap formula: `new_k = (old_k + 4) % 6`.
445///     The outer Join's `(left_keys, right_keys)` swap correspondingly
446///     (the side they reference switches). Inner Join's keys are
447///     unchanged.
448fn normalize_triangle_to_left_deep(node: &RirNode) -> Option<RirNode> {
449    let RirNode::Project {
450        input: outer_input,
451        columns,
452    } = node
453    else {
454        return None;
455    };
456    let RirNode::Join {
457        left: outer_l,
458        right: outer_r,
459        left_keys: outer_lk,
460        right_keys: outer_rk,
461        join_type: outer_jt,
462    } = outer_input.as_ref()
463    else {
464        return None;
465    };
466    if !matches!(outer_jt, JoinType::Inner) {
467        return None;
468    }
469    // Right-deep triangle requires outer.left = Scan, outer.right = Join.
470    let RirNode::Scan { rel: _ } = outer_l.as_ref() else {
471        return None;
472    };
473    let RirNode::Join { .. } = outer_r.as_ref() else {
474        return None;
475    };
476    // Verify outer.right's structure (inner Join of two Scans) so
477    // we don't mistakenly rewrite non-triangle right-deep trees.
478    let RirNode::Join {
479        left: inner_l,
480        right: inner_r,
481        ..
482    } = outer_r.as_ref()
483    else {
484        return None;
485    };
486    if !matches!(inner_l.as_ref(), RirNode::Scan { .. })
487        || !matches!(inner_r.as_ref(), RirNode::Scan { .. })
488    {
489        return None;
490    }
491    // Swap: new outer.left = old outer.right (inner Join);
492    //       new outer.right = old outer.left (third Scan);
493    //       new outer.left_keys = old outer.right_keys;
494    //       new outer.right_keys = old outer.left_keys.
495    let new_outer = RirNode::Join {
496        left: outer_r.clone(),
497        right: outer_l.clone(),
498        left_keys: outer_rk.clone(),
499        right_keys: outer_lk.clone(),
500        join_type: JoinType::Inner,
501    };
502    // Remap project columns: (k + 4) % 6.
503    let new_columns: Vec<ProjectExpr> = columns
504        .iter()
505        .map(|expr| match expr {
506            ProjectExpr::Column(k) => ProjectExpr::Column((*k + 4) % 6),
507            other => other.clone(),
508        })
509        .collect();
510    Some(RirNode::Project {
511        input: Box::new(new_outer),
512        columns: new_columns,
513    })
514}
515
516/// Right-deep 4-cycle normalization: detect a fully right-deep 4-cycle body
517/// `Project(Join(Scan(R0), Join(Scan(R1), Join(Scan(R2), Scan(R3)))))`
518/// (the lowerer's bushy DP can pick this shape at small
519/// cardinalities) and rewrite to the canonical bushy form
520/// `Project(Join(Join(Scan(R0), Scan(R1)), Join(Scan(R2), Scan(R3))))`
521/// that the canonical 4-cycle promoter matches.
522///
523/// The output column layout is preserved (both forms produce
524/// `[R0.0, R0.1, R1.0, R1.1, R2.0, R2.1, R3.0, R3.1]`), so
525/// project columns pass through unchanged.
526///
527/// Validation: only rewrites when the right-deep keys exactly
528/// match the canonical 4-cycle topology
529/// (rotation-only `[1]/[0]` on each inner Join + outer keys
530/// `[0,1]/[5,0]`). Other shapes return `None` — caller falls
531/// back to the original body (and previous canonical-shape behavior).
532fn normalize_4cycle_to_bushy(node: &RirNode) -> Option<RirNode> {
533    let RirNode::Project {
534        input: outer_input,
535        columns,
536    } = node
537    else {
538        return None;
539    };
540    let RirNode::Join {
541        left: outer_l,
542        right: outer_r,
543        left_keys: outer_lk,
544        right_keys: outer_rk,
545        join_type: outer_jt,
546    } = outer_input.as_ref()
547    else {
548        return None;
549    };
550    if !matches!(outer_jt, JoinType::Inner) {
551        return None;
552    }
553    // Right-deep canonical-cycle pattern: outer.left = Scan(R0);
554    // outer.right = Join(Scan(R1), Join(Scan(R2), Scan(R3))).
555    let RirNode::Scan { rel: r0 } = outer_l.as_ref() else {
556        return None;
557    };
558    let RirNode::Join {
559        left: middle_l,
560        right: middle_r,
561        left_keys: middle_lk,
562        right_keys: middle_rk,
563        join_type: middle_jt,
564    } = outer_r.as_ref()
565    else {
566        return None;
567    };
568    if !matches!(middle_jt, JoinType::Inner) {
569        return None;
570    }
571    let RirNode::Scan { rel: r1 } = middle_l.as_ref() else {
572        return None;
573    };
574    let RirNode::Join {
575        left: deep_l,
576        right: deep_r,
577        left_keys: deep_lk,
578        right_keys: deep_rk,
579        join_type: deep_jt,
580    } = middle_r.as_ref()
581    else {
582        return None;
583    };
584    if !matches!(deep_jt, JoinType::Inner) {
585        return None;
586    }
587    let RirNode::Scan { rel: r2 } = deep_l.as_ref() else {
588        return None;
589    };
590    let RirNode::Scan { rel: r3 } = deep_r.as_ref() else {
591        return None;
592    };
593    // Validate canonical-cycle keys.
594    if outer_lk.as_slice() != [0, 1] || outer_rk.as_slice() != [5, 0] {
595        return None;
596    }
597    if middle_lk.as_slice() != [1] || middle_rk.as_slice() != [0] {
598        return None;
599    }
600    if deep_lk.as_slice() != [1] || deep_rk.as_slice() != [0] {
601        return None;
602    }
603    // Reconstruct bushy form. Inner pairs use the canonical
604    // (e1,e2) and (e3,e4) edge keys [1]/[0]. Outer connects the
605    // two subtrees on (e2,e3) [Y] and (e4,e1) [W] cycle edges:
606    //   left_subtree output: [R0.0, R0.1, R1.0, R1.1]
607    //   right_subtree output: [R2.0, R2.1, R3.0, R3.1]
608    //   left_keys [3, 0]: R1.1 (Y), R0.0 (W)
609    //   right_keys [0, 3]: R2.0 (Y), R3.1 (W)
610    let inner_left = RirNode::Join {
611        left: Box::new(RirNode::Scan { rel: *r0 }),
612        right: Box::new(RirNode::Scan { rel: *r1 }),
613        left_keys: vec![1],
614        right_keys: vec![0],
615        join_type: JoinType::Inner,
616    };
617    let inner_right = RirNode::Join {
618        left: Box::new(RirNode::Scan { rel: *r2 }),
619        right: Box::new(RirNode::Scan { rel: *r3 }),
620        left_keys: vec![1],
621        right_keys: vec![0],
622        join_type: JoinType::Inner,
623    };
624    let new_outer = RirNode::Join {
625        left: Box::new(inner_left),
626        right: Box::new(inner_right),
627        left_keys: vec![3, 0],
628        right_keys: vec![0, 3],
629        join_type: JoinType::Inner,
630    };
631    Some(RirNode::Project {
632        input: Box::new(new_outer),
633        columns: columns.clone(),
634    })
635}
636
637/// Semantic triangle recognition: recognize the canonical triangle in any valid
638/// inner-key combination (`[1]/[0]`, `[1]/[1]`, `[0]/[0]`)
639/// and produce the equivalent `MultiWayJoin` with
640/// `inputs` arranged in canonical semantic order
641/// Aggregate group-by fusion: promote a triangle join tree sitting inside an
642/// aggregate rule's `Project{ GroupBy { Project { <join tree> } } }`
643/// wrapper. The inner tree is matched exactly like a top-level triangle
644/// body (via a synthesized canonical (X, Y, Z) projection), and on success
645/// the group projection's column indices are remapped from join-output
646/// space into the MultiWayJoin's (X, Y, Z) output space. Returns false —
647/// leaving the rule untouched — on any structural mismatch.
648fn try_promote_triangle_inside_aggregate(
649    body: &mut RirNode,
650    stats: &StatsManager,
651    config: &CompilerConfig,
652) -> bool {
653    let RirNode::Project { input: gb, .. } = body else {
654        return false;
655    };
656    let RirNode::GroupBy {
657        input: group_input, ..
658    } = gb.as_mut()
659    else {
660        return false;
661    };
662    let RirNode::Project {
663        input: inner,
664        columns: group_cols,
665    } = group_input.as_mut()
666    else {
667        return false;
668    };
669    // The matcher expects the canonical left-deep triangle projection
670    // (X, Y, Z) = join-output columns [0, 1, 3].
671    let canonical = RirNode::Project {
672        input: inner.clone(),
673        columns: vec![
674            ProjectExpr::Column(0),
675            ProjectExpr::Column(1),
676            ProjectExpr::Column(3),
677        ],
678    };
679    let normalized = normalize_triangle_to_left_deep(&canonical);
680    let candidate = normalized.as_ref().unwrap_or(&canonical);
681    let Some(promoted) = try_promote_triangle(candidate, stats, config) else {
682        return false;
683    };
684    let RirNode::MultiWayJoin { output_columns, .. } = &promoted else {
685        return false;
686    };
687    let mut remapped: Vec<ProjectExpr> = Vec::with_capacity(group_cols.len());
688    for col in group_cols.iter() {
689        let ProjectExpr::Column(c) = col else {
690            return false;
691        };
692        let Some(pos) = output_columns
693            .iter()
694            .position(|oc| matches!(oc, ProjectExpr::Column(x) if x == c))
695        else {
696            return false;
697        };
698        remapped.push(ProjectExpr::Column(pos));
699    }
700    *group_cols = remapped;
701    **inner = promoted;
702    true
703}
704
705/// 4-cycle aggregate group-by fusion: sibling of
706/// [`try_promote_triangle_inside_aggregate`]. Promotes a 4-cycle join tree
707/// sitting inside an aggregate rule's
708/// `Project{ GroupBy { Project { <join tree> } } }` wrapper. The inner tree
709/// is matched exactly like a top-level 4-cycle body via a synthesized
710/// canonical (W, X, Y, Z) projection (join-output columns [0, 1, 3, 5] of
711/// the canonical-cycle tree), and on success the group projection's column
712/// indices are remapped from join-output space into the MultiWayJoin's
713/// (W, X, Y, Z) output space — output position 0 is the variable-order
714/// root W by construction, which is what the executor's fused dispatch
715/// requires for the group key. Returns false — leaving the rule
716/// untouched — on any structural mismatch.
717fn try_promote_4cycle_inside_aggregate(
718    body: &mut RirNode,
719    stats: &StatsManager,
720    config: &CompilerConfig,
721) -> bool {
722    let RirNode::Project { input: gb, .. } = body else {
723        return false;
724    };
725    let RirNode::GroupBy {
726        input: group_input, ..
727    } = gb.as_mut()
728    else {
729        return false;
730    };
731    let RirNode::Project {
732        input: inner,
733        columns: group_cols,
734    } = group_input.as_mut()
735    else {
736        return false;
737    };
738    // The matcher expects the canonical 4-cycle projection
739    // (W, X, Y, Z) = join-output columns [0, 1, 3, 5].
740    let canonical = RirNode::Project {
741        input: inner.clone(),
742        columns: vec![
743            ProjectExpr::Column(0),
744            ProjectExpr::Column(1),
745            ProjectExpr::Column(3),
746            ProjectExpr::Column(5),
747        ],
748    };
749    let normalized = normalize_4cycle_to_bushy(&canonical);
750    let candidate = normalized.as_ref().unwrap_or(&canonical);
751    let Some(promoted) = try_promote_4cycle(candidate, stats, config) else {
752        return false;
753    };
754    let RirNode::MultiWayJoin { output_columns, .. } = &promoted else {
755        return false;
756    };
757    let mut remapped: Vec<ProjectExpr> = Vec::with_capacity(group_cols.len());
758    for col in group_cols.iter() {
759        let ProjectExpr::Column(c) = col else {
760            return false;
761        };
762        let Some(pos) = output_columns
763            .iter()
764            .position(|oc| matches!(oc, ProjectExpr::Column(x) if x == c))
765        else {
766            return false;
767        };
768        remapped.push(ProjectExpr::Column(pos));
769    }
770    *group_cols = remapped;
771    **inner = promoted;
772    true
773}
774
775/// K-clique aggregate group-by fusion: k = 5, 6 sibling of
776/// [`try_promote_triangle_inside_aggregate`]. Promotes a complete-K_k
777/// join tree sitting inside an aggregate rule's
778/// `Project{ GroupBy { Project { <join tree> } } }` wrapper.
779///
780/// Unlike triangle/4-cycle, the clique matcher derives head variables
781/// from a head projection over the join tree. The aggregate wrapper has
782/// no such projection, so this descent synthesizes one: union-find over
783/// the flattened global slot space yields the k variable classes, and
784/// one representative slot per class (in first-appearance order) forms
785/// the canonical k-variable projection handed to
786/// [`try_promote_clique_k`]. On success the group projection's column
787/// indices are remapped from join-output (global slot) space into the
788/// clique head-variable space via the same classes. K = 7/8 bodies are
789/// left untouched (no fused count kernels exist for them — the
790/// non-aggregate clique route would buy nothing under an aggregate
791/// wrapper that the executor cannot fuse). Returns false — leaving the
792/// rule untouched — on any structural mismatch.
793fn try_promote_clique_inside_aggregate(body: &mut RirNode, stats: &StatsManager) -> bool {
794    let RirNode::Project { input: gb, .. } = body else {
795        return false;
796    };
797    let RirNode::GroupBy {
798        input: group_input, ..
799    } = gb.as_mut()
800    else {
801        return false;
802    };
803    let RirNode::Project {
804        input: inner,
805        columns: group_cols,
806    } = group_input.as_mut()
807    else {
808        return false;
809    };
810    // Flatten the inner join tree directly (no head projection exists
811    // under the aggregate wrapper).
812    let mut scans: Vec<RelId> = Vec::new();
813    let mut key_pairs: Vec<(usize, usize)> = Vec::new();
814    if walk_clique_node(inner, &mut scans, &mut key_pairs).is_none() {
815        return false;
816    }
817    let k = match scans.len() {
818        10 => 5,
819        15 => 6,
820        _ => return false,
821    };
822    // Union-find on the global slot space; representative slot per
823    // class in first-appearance order defines the head variables.
824    let n_slots = 2 * scans.len();
825    let mut parent: Vec<usize> = (0..n_slots).collect();
826    for (a, b) in &key_pairs {
827        if *a >= n_slots || *b >= n_slots {
828            return false;
829        }
830        uf_union_clique(&mut parent, *a, *b);
831    }
832    let mut class_roots: Vec<usize> = Vec::new();
833    let mut first_slot_of_class: HashMap<usize, usize> = HashMap::new();
834    for slot in 0..n_slots {
835        let root = uf_find_clique(&mut parent, slot);
836        if let std::collections::hash_map::Entry::Vacant(entry) = first_slot_of_class.entry(root) {
837            entry.insert(slot);
838            class_roots.push(root);
839        }
840    }
841    if class_roots.len() != k {
842        return false;
843    }
844    // The clique matcher's orientation invariant requires every atom's
845    // col0 class to precede its col1 class in head order. The class
846    // digraph of a canonical complete-K_k body (one atom per unordered
847    // pair, consistent orientation) is a transitive tournament, whose
848    // unique topological order sorts classes by in-degree: head index h
849    // = the class with in-degree h. Duplicate in-degrees mean the
850    // orientation is not transitive (the top-level matcher would reject
851    // it too) — leave the rule untouched. NOTE: first-appearance slot
852    // order is NOT usable here because the lowerer's bushy DP plan
853    // reorders the scan leaves.
854    let class_idx: HashMap<usize, usize> = class_roots
855        .iter()
856        .enumerate()
857        .map(|(idx, root)| (*root, idx))
858        .collect();
859    let mut in_degree = vec![0usize; k];
860    for atom in 0..scans.len() {
861        let cls_a = uf_find_clique(&mut parent, 2 * atom);
862        let cls_b = uf_find_clique(&mut parent, 2 * atom + 1);
863        if cls_a == cls_b {
864            return false;
865        }
866        in_degree[class_idx[&cls_b]] += 1;
867    }
868    let mut class_pos_by_head = vec![usize::MAX; k];
869    for (pos, deg) in in_degree.iter().enumerate() {
870        if *deg >= k || class_pos_by_head[*deg] != usize::MAX {
871            return false;
872        }
873        class_pos_by_head[*deg] = pos;
874    }
875    let representative_slots: Vec<usize> = class_pos_by_head
876        .iter()
877        .map(|pos| first_slot_of_class[&class_roots[*pos]])
878        .collect();
879    let class_to_head: HashMap<usize, usize> = class_pos_by_head
880        .iter()
881        .enumerate()
882        .map(|(head_idx, pos)| (class_roots[*pos], head_idx))
883        .collect();
884    let canonical = RirNode::Project {
885        input: inner.clone(),
886        columns: representative_slots
887            .iter()
888            .map(|slot| ProjectExpr::Column(*slot))
889            .collect(),
890    };
891    let Some(promoted) = try_promote_clique_k(&canonical, k, stats) else {
892        return false;
893    };
894    // Remap the group projection from join-output (global slot) space
895    // into the clique head-variable space via the slot classes.
896    let mut remapped: Vec<ProjectExpr> = Vec::with_capacity(group_cols.len());
897    for col in group_cols.iter() {
898        let ProjectExpr::Column(c) = col else {
899            return false;
900        };
901        if *c >= n_slots {
902            return false;
903        }
904        let root = uf_find_clique(&mut parent, *c);
905        let Some(head_idx) = class_to_head.get(&root) else {
906            return false;
907        };
908        remapped.push(ProjectExpr::Column(*head_idx));
909    }
910    *group_cols = remapped;
911    **inner = promoted;
912    true
913}
914
915/// `[XY, YZ, XZ]` regardless of positional layout. Returns
916/// `None` for shape deviations.
917fn try_promote_triangle(
918    node: &RirNode,
919    stats: &StatsManager,
920    config: &CompilerConfig,
921) -> Option<RirNode> {
922    let RirNode::Project {
923        input: outer_input,
924        columns,
925    } = node
926    else {
927        return None;
928    };
929    let RirNode::Join {
930        left: l1,
931        right: r1,
932        left_keys: lk1,
933        right_keys: rk1,
934        join_type: jt1,
935    } = outer_input.as_ref()
936    else {
937        return None;
938    };
939    if !matches!(jt1, JoinType::Inner) {
940        return None;
941    }
942    let RirNode::Scan { rel: rel_third } = r1.as_ref() else {
943        return None;
944    };
945    let RirNode::Join {
946        left: l2,
947        right: r2,
948        left_keys: lk2,
949        right_keys: rk2,
950        join_type: jt2,
951    } = l1.as_ref()
952    else {
953        return None;
954    };
955    if !matches!(jt2, JoinType::Inner) {
956        return None;
957    }
958    let RirNode::Scan { rel: rel_inner_l } = l2.as_ref() else {
959        return None;
960    };
961    let RirNode::Scan { rel: rel_inner_r } = r2.as_ref() else {
962        return None;
963    };
964
965    // Variable-graph deduction of which atom is XY / YZ / XZ.
966    let (rel_xy, rel_yz, rel_xz) = infer_triangle_semantics(
967        *rel_inner_l,
968        *rel_inner_r,
969        *rel_third,
970        lk2,
971        rk2,
972        lk1,
973        rk1,
974        columns,
975    )?;
976
977    let inputs = vec![
978        RirNode::Scan { rel: rel_xy },
979        RirNode::Scan { rel: rel_yz },
980        RirNode::Scan { rel: rel_xz },
981    ];
982    // Canonical triangle slot_vars: [[V_X, V_Y], [V_Y, V_Z],
983    // [V_X, V_Z]] with V_X=0, V_Y=1, V_Z=2. Shape-fixed
984    // regardless of the body's positional layout.
985    let slot_vars = vec![
986        vec![Some(0u32), Some(1)],
987        vec![Some(1u32), Some(2)],
988        vec![Some(0u32), Some(2)],
989    ];
990    let output_columns = columns.clone();
991    let fallback = Box::new(node.clone());
992    // Optional variable ordering: dispatch to the cost model selected by
993    // `config.wcoj_variable_ordering`. With
994    // `CompilerConfig::default()` (Disabled), no cost model
995    // runs and default promotion behavior is bit-identical.
996    use crate::compiler_config::WcojVarOrderingKind;
997    use crate::wcoj_var_ordering::{
998        build_triangle_var_order, HeatAwareLeaderModel, LeaderCardinalityModel,
999    };
1000    let leader_idx = match config.wcoj_variable_ordering {
1001        WcojVarOrderingKind::Disabled => None,
1002        WcojVarOrderingKind::LeaderCardinality => {
1003            LeaderCardinalityModel.pick_triangle_leader([rel_xy, rel_yz, rel_xz], stats, config)
1004        }
1005        WcojVarOrderingKind::HeatAware => {
1006            HeatAwareLeaderModel.pick_triangle_leader([rel_xy, rel_yz, rel_xz], stats, config)
1007        }
1008    };
1009    let var_order = leader_idx.map(build_triangle_var_order);
1010    Some(RirNode::MultiWayJoin {
1011        inputs,
1012        slot_vars,
1013        output_columns,
1014        fallback,
1015        plan: None,
1016        var_order,
1017    })
1018}
1019
1020/// Recognize a 2-atom inner chain
1021/// `Project(Join(Scan, Scan))` with exactly one shared key column
1022/// and wrap it as a production `ChainJoin`.
1023fn try_promote_chain(node: &RirNode) -> Option<RirNode> {
1024    let RirNode::Project { input, columns } = node else {
1025        return None;
1026    };
1027    let RirNode::Join {
1028        left,
1029        right,
1030        left_keys,
1031        right_keys,
1032        join_type,
1033    } = input.as_ref()
1034    else {
1035        return None;
1036    };
1037    if !matches!(join_type, JoinType::Inner) {
1038        return None;
1039    }
1040    if left_keys.len() != 1 || right_keys.len() != 1 {
1041        return None;
1042    }
1043    let left_key = left_keys[0];
1044    let right_key = right_keys[0];
1045    if left_key >= 2 || right_key >= 2 {
1046        return None;
1047    }
1048    let RirNode::Scan { rel: rel_left } = left.as_ref() else {
1049        return None;
1050    };
1051    let RirNode::Scan { rel: rel_right } = right.as_ref() else {
1052        return None;
1053    };
1054
1055    Some(RirNode::ChainJoin {
1056        left: Box::new(RirNode::Scan { rel: *rel_left }),
1057        right: Box::new(RirNode::Scan { rel: *rel_right }),
1058        left_key,
1059        right_key,
1060        output_columns: columns.clone(),
1061        fallback: Box::new(node.clone()),
1062    })
1063}
1064
1065/// 4-cycle semantic-slot encoding: 4-cycle has 4 atoms × 2 cols = 8 slots. Encode as
1066/// `(atom_idx * 2) + col_idx` where `atom_idx` is `0` =
1067/// outer-left's left, `1` = outer-left's right, `2` =
1068/// outer-right's left, `3` = outer-right's right.
1069fn ac_idx_4(atom_idx: u8, col_idx: u8) -> u8 {
1070    debug_assert!(atom_idx < 4);
1071    debug_assert!(col_idx < 2);
1072    atom_idx * 2 + col_idx
1073}
1074
1075fn outer_left_inner_output_ac(k: usize) -> Option<(u8, u8)> {
1076    match k {
1077        0 => Some((0, 0)),
1078        1 => Some((0, 1)),
1079        2 => Some((1, 0)),
1080        3 => Some((1, 1)),
1081        _ => None,
1082    }
1083}
1084
1085fn outer_right_inner_output_ac(k: usize) -> Option<(u8, u8)> {
1086    match k {
1087        0 => Some((2, 0)),
1088        1 => Some((2, 1)),
1089        2 => Some((3, 0)),
1090        3 => Some((3, 1)),
1091        _ => None,
1092    }
1093}
1094
1095fn outer_4cycle_output_ac(k: usize) -> Option<(u8, u8)> {
1096    match k {
1097        0..=3 => outer_left_inner_output_ac(k),
1098        4..=7 => outer_right_inner_output_ac(k - 4),
1099        _ => None,
1100    }
1101}
1102
1103fn uf_find_8(parent: &mut [u8; 8], x: u8) -> u8 {
1104    let mut root = x;
1105    while parent[root as usize] != root {
1106        root = parent[root as usize];
1107    }
1108    let mut cur = x;
1109    while parent[cur as usize] != root {
1110        let next = parent[cur as usize];
1111        parent[cur as usize] = root;
1112        cur = next;
1113    }
1114    root
1115}
1116
1117fn uf_union_8(parent: &mut [u8; 8], a: u8, b: u8) {
1118    let ra = uf_find_8(parent, a);
1119    let rb = uf_find_8(parent, b);
1120    if ra != rb {
1121        parent[rb as usize] = ra;
1122    }
1123}
1124
1125/// Semantic-slot inference for 4-cycle bodies. Given
1126/// the four scans + key shapes + project columns, deduce
1127/// which atom is the WX-edge / XY-edge / YZ-edge / ZW-edge
1128/// from the variable-equivalence graph. Returns
1129/// `(rel_wx, rel_xy, rel_yz, rel_zw)` regardless of the
1130/// body's positional layout.
1131#[expect(
1132    clippy::too_many_arguments,
1133    reason = "four-cycle inference validates four relation slots and six lowered key vectors as one fixed physical shape"
1134)]
1135fn infer_4cycle_semantics(
1136    rel_ll: RelId,
1137    rel_lr: RelId,
1138    rel_rl: RelId,
1139    rel_rr: RelId,
1140    ilk_l: &[usize],
1141    irk_l: &[usize],
1142    ilk_r: &[usize],
1143    irk_r: &[usize],
1144    olk: &[usize],
1145    ork: &[usize],
1146    project_cols: &[ProjectExpr],
1147) -> Option<(RelId, RelId, RelId, RelId)> {
1148    if ilk_l.len() != 1 || irk_l.len() != 1 {
1149        return None;
1150    }
1151    if ilk_r.len() != 1 || irk_r.len() != 1 {
1152        return None;
1153    }
1154    if olk.len() != 2 || ork.len() != 2 {
1155        return None;
1156    }
1157    if project_cols.len() != 4 {
1158        return None;
1159    }
1160    if ilk_l[0] >= 2 || irk_l[0] >= 2 || ilk_r[0] >= 2 || irk_r[0] >= 2 {
1161        return None;
1162    }
1163    if olk.iter().any(|k| *k >= 4) || ork.iter().any(|k| *k >= 4) {
1164        return None;
1165    }
1166
1167    let mut parent = [0u8, 1, 2, 3, 4, 5, 6, 7];
1168
1169    // Outer-left inner: ll.col[ilk_l[0]] ≡ lr.col[irk_l[0]].
1170    uf_union_8(
1171        &mut parent,
1172        ac_idx_4(0, ilk_l[0] as u8),
1173        ac_idx_4(1, irk_l[0] as u8),
1174    );
1175
1176    // Outer-right inner: rl.col[ilk_r[0]] ≡ rr.col[irk_r[0]].
1177    uf_union_8(
1178        &mut parent,
1179        ac_idx_4(2, ilk_r[0] as u8),
1180        ac_idx_4(3, irk_r[0] as u8),
1181    );
1182
1183    // Outer join: 2 equivalences from (olk, ork). olk indexes
1184    // outer-left's inner output (cols 0..4); ork indexes
1185    // outer-right's inner output (cols 0..4).
1186    for i in 0..2 {
1187        let (la, lc) = outer_left_inner_output_ac(olk[i])?;
1188        let (ra, rc) = outer_right_inner_output_ac(ork[i])?;
1189        uf_union_8(&mut parent, ac_idx_4(la, lc), ac_idx_4(ra, rc));
1190    }
1191
1192    let roots: [u8; 8] = std::array::from_fn(|i| uf_find_8(&mut parent, i as u8));
1193    let mut counts: HashMap<u8, u8> = HashMap::new();
1194    for r in &roots {
1195        *counts.entry(*r).or_insert(0) += 1;
1196    }
1197    if counts.len() != 4 || counts.values().any(|c| *c != 2) {
1198        return None;
1199    }
1200
1201    // Map head columns to equivalence classes.
1202    let mut head_classes: [u8; 4] = [0; 4];
1203    for (i, pc) in project_cols.iter().enumerate() {
1204        let ProjectExpr::Column(k) = pc else {
1205            return None;
1206        };
1207        let (atom, col) = outer_4cycle_output_ac(*k)?;
1208        head_classes[i] = uf_find_8(&mut parent, ac_idx_4(atom, col));
1209    }
1210    // Head must pick 4 distinct classes.
1211    for i in 0..4 {
1212        for j in (i + 1)..4 {
1213            if head_classes[i] == head_classes[j] {
1214                return None;
1215            }
1216        }
1217    }
1218    let w_class = head_classes[0];
1219    let x_class = head_classes[1];
1220    let y_class = head_classes[2];
1221    let z_class = head_classes[3];
1222
1223    let atom_classes = |atom_idx: u8| -> (u8, u8) {
1224        (
1225            roots[ac_idx_4(atom_idx, 0) as usize],
1226            roots[ac_idx_4(atom_idx, 1) as usize],
1227        )
1228    };
1229
1230    let atom_rels = [rel_ll, rel_lr, rel_rl, rel_rr];
1231    let mut rel_wx: Option<RelId> = None;
1232    let mut rel_xy: Option<RelId> = None;
1233    let mut rel_yz: Option<RelId> = None;
1234    let mut rel_zw: Option<RelId> = None;
1235
1236    for atom_idx in 0..4u8 {
1237        let (c0, c1) = atom_classes(atom_idx);
1238        let binds_w = c0 == w_class || c1 == w_class;
1239        let binds_x = c0 == x_class || c1 == x_class;
1240        let binds_y = c0 == y_class || c1 == y_class;
1241        let binds_z = c0 == z_class || c1 == z_class;
1242        match (binds_w, binds_x, binds_y, binds_z) {
1243            (true, true, false, false) => rel_wx = Some(atom_rels[atom_idx as usize]),
1244            (false, true, true, false) => rel_xy = Some(atom_rels[atom_idx as usize]),
1245            (false, false, true, true) => rel_yz = Some(atom_rels[atom_idx as usize]),
1246            (true, false, false, true) => rel_zw = Some(atom_rels[atom_idx as usize]),
1247            _ => return None,
1248        }
1249    }
1250
1251    Some((rel_wx?, rel_xy?, rel_yz?, rel_zw?))
1252}
1253
1254/// Recognize the canonical 4-cycle subtree and
1255/// produce the equivalent `MultiWayJoin`.
1256///
1257/// Target rule:
1258///
1259/// ```text
1260/// cycle4(W, X, Y, Z) :- e1(W, X), e2(X, Y), e3(Y, Z), e4(Z, W).
1261/// ```
1262///
1263/// Lowered + optimized shape (verified against
1264/// `Compiler::compile()`): bushy `Project { Join { Join, Join } }`
1265/// with `output_columns = [Column(0), Column(1), Column(3), Column(5)]`,
1266/// outer Join keys `[0, 3] / [3, 0]`, inner Join keys `[1] / [0]`.
1267/// This differs from triangle's left-deep shape; both promoters
1268/// coexist by matching their respective canonical trees.
1269///
1270/// Returns `None` for any deviation. Strict by design: only
1271/// matchers/promoters with explicit shape qualifiers may require an
1272/// exact shape, and this matcher requires 4-cycle specifically.
1273fn try_promote_4cycle(
1274    node: &RirNode,
1275    stats: &StatsManager,
1276    config: &CompilerConfig,
1277) -> Option<RirNode> {
1278    let RirNode::Project {
1279        input: outer_input,
1280        columns,
1281    } = node
1282    else {
1283        return None;
1284    };
1285    let RirNode::Join {
1286        left: outer_l,
1287        right: outer_r,
1288        left_keys: olk,
1289        right_keys: ork,
1290        join_type: ojt,
1291    } = outer_input.as_ref()
1292    else {
1293        return None;
1294    };
1295    if !matches!(ojt, JoinType::Inner) {
1296        return None;
1297    }
1298    let RirNode::Join {
1299        left: ll,
1300        right: lr,
1301        left_keys: ilk_l,
1302        right_keys: irk_l,
1303        join_type: ijt_l,
1304    } = outer_l.as_ref()
1305    else {
1306        return None;
1307    };
1308    if !matches!(ijt_l, JoinType::Inner) {
1309        return None;
1310    }
1311    let RirNode::Scan { rel: rel_ll } = ll.as_ref() else {
1312        return None;
1313    };
1314    let RirNode::Scan { rel: rel_lr } = lr.as_ref() else {
1315        return None;
1316    };
1317    let RirNode::Join {
1318        left: rl,
1319        right: rr,
1320        left_keys: ilk_r,
1321        right_keys: irk_r,
1322        join_type: ijt_r,
1323    } = outer_r.as_ref()
1324    else {
1325        return None;
1326    };
1327    if !matches!(ijt_r, JoinType::Inner) {
1328        return None;
1329    }
1330    let RirNode::Scan { rel: rel_rl } = rl.as_ref() else {
1331        return None;
1332    };
1333    let RirNode::Scan { rel: rel_rr } = rr.as_ref() else {
1334        return None;
1335    };
1336
1337    // Variable-graph deduction of which atom is
1338    // WX / XY / YZ / ZW.
1339    let (rel_wx, rel_xy, rel_yz, rel_zw) = infer_4cycle_semantics(
1340        *rel_ll, *rel_lr, *rel_rl, *rel_rr, ilk_l, irk_l, ilk_r, irk_r, olk, ork, columns,
1341    )?;
1342
1343    let inputs = vec![
1344        RirNode::Scan { rel: rel_wx },
1345        RirNode::Scan { rel: rel_xy },
1346        RirNode::Scan { rel: rel_yz },
1347        RirNode::Scan { rel: rel_zw },
1348    ];
1349    // Shape-fixed canonical 4-cycle slot_vars:
1350    // [[V_W, V_X], [V_X, V_Y], [V_Y, V_Z], [V_Z, V_W]]
1351    // with V_W=0, V_X=1, V_Y=2, V_Z=3. Encoded in head-label
1352    // space (head[0]=W=0, head[1]=X=1, head[2]=Y=2, head[3]=Z=3).
1353    let slot_vars = vec![
1354        vec![Some(0u32), Some(1)],
1355        vec![Some(1u32), Some(2)],
1356        vec![Some(2u32), Some(3)],
1357        vec![Some(3u32), Some(0)],
1358    ];
1359    let output_columns = columns.clone();
1360    let fallback = Box::new(node.clone());
1361    // Ask the cost model whether to set a non-default
1362    // leader. With `CompilerConfig::default()` (Disabled), this
1363    // always returns None.
1364    use crate::compiler_config::WcojVarOrderingKind;
1365    use crate::wcoj_var_ordering::{
1366        build_cycle4_var_order, HeatAwareLeaderModel, LeaderCardinalityModel,
1367    };
1368    let leader_idx_4 = match config.wcoj_variable_ordering {
1369        WcojVarOrderingKind::Disabled => None,
1370        WcojVarOrderingKind::LeaderCardinality => LeaderCardinalityModel.pick_4cycle_leader(
1371            [rel_wx, rel_xy, rel_yz, rel_zw],
1372            stats,
1373            config,
1374        ),
1375        WcojVarOrderingKind::HeatAware => {
1376            HeatAwareLeaderModel.pick_4cycle_leader([rel_wx, rel_xy, rel_yz, rel_zw], stats, config)
1377        }
1378    };
1379    let var_order = leader_idx_4.map(build_cycle4_var_order);
1380    Some(RirNode::MultiWayJoin {
1381        inputs,
1382        slot_vars,
1383        output_columns,
1384        fallback,
1385        plan: None,
1386        var_order,
1387    })
1388}
1389
1390// ===============================================================
1391// K-clique promoter (k = 5..8).
1392//
1393// Tree-flatten + complete-K_k validation. Robust to left-deep /
1394// right-deep / bushy lowered trees. Rejects:
1395//   * Filter / comparison wrappers (semantic preservation gate).
1396//   * Non-canonical nodes (anything other than Project/Join/Scan).
1397//   * Self-edge atoms (e(X, X) — same var in both columns).
1398//   * Reversed atoms (e(v_j, v_i) for canonical (v_i, v_j) with
1399//     i < j) — this promoter does not implement column-swap layout
1400//     for clique edges.
1401//   * Constants in atom positions.
1402//   * Recursive scan bodies are admitted for K-clique
1403//     metadata refresh during semi-naive fixpoint.
1404//   * Atom multisets that don't form the complete K_k edge set.
1405// ===============================================================
1406
1407/// Canonical edge index for (i, j) with 0 <= i < j < k.
1408fn clique_edge_idx(i: usize, j: usize, k: usize) -> usize {
1409    debug_assert!(i < j && j < k);
1410    i * (2 * k - i - 1) / 2 + (j - i - 1)
1411}
1412
1413/// Tiny union-find on atom-column slots (position space).
1414fn uf_find_clique(parent: &mut [usize], mut x: usize) -> usize {
1415    while parent[x] != x {
1416        parent[x] = parent[parent[x]];
1417        x = parent[x];
1418    }
1419    x
1420}
1421
1422fn uf_union_clique(parent: &mut [usize], a: usize, b: usize) {
1423    let ra = uf_find_clique(parent, a);
1424    let rb = uf_find_clique(parent, b);
1425    if ra != rb {
1426        parent[rb] = ra;
1427    }
1428}
1429
1430/// Flatten the join tree. Walks Project/Join/Scan only;
1431/// rejects on Filter or any other RIR variant. Returns
1432/// `(scans, key_equiv_pairs, project_columns)` on success.
1433///
1434/// `scans[i]` = RelId of the i-th leaf Scan in left-to-right
1435/// traversal order. The "global slot space" assigns slot indices
1436/// `2*i` and `2*i + 1` to atom i's col0 and col1 respectively.
1437///
1438/// `key_equiv_pairs` is a list of `(global_slot_a, global_slot_b)`
1439/// pairs derived from each Join's `(left_keys, right_keys)` —
1440/// every Join key references LOCAL join output positions, which
1441/// the flatten translates to global slot positions.
1442///
1443/// `project_columns` is the outermost Project's column list,
1444/// each entry's index translated to a global slot position.
1445type FlattenedCliqueBody = (Vec<RelId>, Vec<(usize, usize)>, Vec<usize>);
1446
1447fn flatten_clique_body(body: &RirNode) -> Option<FlattenedCliqueBody> {
1448    let RirNode::Project { input, columns } = body else {
1449        return None;
1450    };
1451    let mut scans: Vec<RelId> = Vec::new();
1452    let mut key_pairs: Vec<(usize, usize)> = Vec::new();
1453    let _width = walk_clique_node(input, &mut scans, &mut key_pairs)?;
1454    let mut project_globals: Vec<usize> = Vec::with_capacity(columns.len());
1455    for c in columns {
1456        let xlog_ir::rir::ProjectExpr::Column(k) = c else {
1457            return None;
1458        };
1459        project_globals.push(*k);
1460    }
1461    Some((scans, key_pairs, project_globals))
1462}
1463
1464/// Recursive walker. Returns the width (in global slots) of the
1465/// subtree, after accumulating its scans + key-equiv pairs.
1466fn walk_clique_node(
1467    node: &RirNode,
1468    scans: &mut Vec<RelId>,
1469    key_pairs: &mut Vec<(usize, usize)>,
1470) -> Option<usize> {
1471    match node {
1472        RirNode::Scan { rel } => {
1473            scans.push(*rel);
1474            Some(2)
1475        }
1476        RirNode::Join {
1477            left,
1478            right,
1479            left_keys,
1480            right_keys,
1481            join_type,
1482        } => {
1483            if !matches!(join_type, JoinType::Inner) {
1484                return None;
1485            }
1486            let left_offset = scans.len() * 2;
1487            let left_width = walk_clique_node(left, scans, key_pairs)?;
1488            let right_offset = left_offset + left_width;
1489            let right_width = walk_clique_node(right, scans, key_pairs)?;
1490            if left_keys.len() != right_keys.len() {
1491                return None;
1492            }
1493            for (lk, rk) in left_keys.iter().zip(right_keys.iter()) {
1494                if *lk >= left_width || *rk >= right_width {
1495                    return None;
1496                }
1497                key_pairs.push((left_offset + *lk, right_offset + *rk));
1498            }
1499            Some(left_width + right_width)
1500        }
1501        // Reject Project (only outermost is allowed; we already
1502        // peeled it off in flatten_clique_body), Filter, and
1503        // any other RIR variant.
1504        _ => None,
1505    }
1506}
1507
1508/// Generic Free Join — general multiway flatten walker. Arity-aware
1509/// sibling of [`walk_clique_node`]: sizes each Scan leaf from
1510/// `arities` instead of the clique walker's hardcoded arity-2
1511/// assumption, so global slot offsets are running width sums.
1512/// Walks Join/Scan only; rejects non-Inner joins, keyless
1513/// (Cartesian) joins — those stay on the bench-grounded nested-loop
1514/// routing — and any other RIR variant. Returns the
1515/// subtree width in global slots.
1516fn walk_general_node(
1517    node: &RirNode,
1518    arities: &HashMap<RelId, usize>,
1519    scans: &mut Vec<RelId>,
1520    widths: &mut Vec<usize>,
1521    key_pairs: &mut Vec<(usize, usize)>,
1522) -> Option<usize> {
1523    match node {
1524        RirNode::Scan { rel } => {
1525            let width = *arities.get(rel)?;
1526            if width == 0 {
1527                return None;
1528            }
1529            scans.push(*rel);
1530            widths.push(width);
1531            Some(width)
1532        }
1533        RirNode::Join {
1534            left,
1535            right,
1536            left_keys,
1537            right_keys,
1538            join_type,
1539        } => {
1540            if !matches!(join_type, JoinType::Inner) {
1541                return None;
1542            }
1543            // Offset of the left subtree = total width accumulated so
1544            // far (widths fills in left-to-right traversal order).
1545            let left_offset: usize = widths.iter().sum();
1546            let left_width = walk_general_node(left, arities, scans, widths, key_pairs)?;
1547            let right_offset = left_offset + left_width;
1548            let right_width = walk_general_node(right, arities, scans, widths, key_pairs)?;
1549            if left_keys.len() != right_keys.len() || left_keys.is_empty() {
1550                return None;
1551            }
1552            for (lk, rk) in left_keys.iter().zip(right_keys.iter()) {
1553                if *lk >= left_width || *rk >= right_width {
1554                    return None;
1555                }
1556                key_pairs.push((left_offset + *lk, right_offset + *rk));
1557            }
1558            Some(left_width + right_width)
1559        }
1560        _ => None,
1561    }
1562}
1563
1564/// Generic Free Join — promote a general ≥3-atom inner-join body (any
1565/// arity mix, any join-tree shape) to a generic `MultiWayJoin`
1566/// carrying dense variable classes in `slot_vars` and `plan: None`.
1567///
1568/// Runs after every dedicated shape promoter declined in the
1569/// per-rule loop (triangle / 4-cycle / K-clique successes `continue`
1570/// past it), so dedicated shapes never reach this path. The executor
1571/// (`try_dispatch_free_join`) derives a Free Join plan from
1572/// `slot_vars` at dispatch time and silently declines to the
1573/// embedded binary fallback for shapes the GPU engine cannot run.
1574///
1575/// Idempotent: only `Project(inner-join tree)` bodies match, so an
1576/// already-promoted `MultiWayJoin` passes through unchanged.
1577fn try_promote_general_multiway(
1578    body: &RirNode,
1579    arities: &HashMap<RelId, usize>,
1580) -> Option<RirNode> {
1581    let RirNode::Project { input, columns } = body else {
1582        return None;
1583    };
1584    let mut scans: Vec<RelId> = Vec::new();
1585    let mut widths: Vec<usize> = Vec::new();
1586    let mut key_pairs: Vec<(usize, usize)> = Vec::new();
1587    let total = walk_general_node(input, arities, &mut scans, &mut widths, &mut key_pairs)?;
1588    if scans.len() < 3 {
1589        return None;
1590    }
1591
1592    // Union-find over the global slot space, then dense class ids in
1593    // first-occurrence slot order. The executor treats `slot_vars`
1594    // entries as opaque variable classes (it densely remaps again on
1595    // its side), so first-occurrence numbering is canonical enough.
1596    let mut parent: Vec<usize> = (0..total).collect();
1597    for (a, b) in &key_pairs {
1598        if *a >= total || *b >= total {
1599            return None;
1600        }
1601        uf_union_clique(&mut parent, *a, *b);
1602    }
1603    let mut class_of_root: HashMap<usize, u32> = HashMap::new();
1604    let mut slot_class: Vec<u32> = Vec::with_capacity(total);
1605    for slot in 0..total {
1606        let root = uf_find_clique(&mut parent, slot);
1607        let next = class_of_root.len() as u32;
1608        let cls = *class_of_root.entry(root).or_insert(next);
1609        slot_class.push(cls);
1610    }
1611
1612    // Every projection entry must be a plain column inside the slot
1613    // space; computed projections stay on the binary path.
1614    for c in columns {
1615        let ProjectExpr::Column(k) = c else {
1616            return None;
1617        };
1618        if *k >= total {
1619            return None;
1620        }
1621    }
1622
1623    let inputs: Vec<RirNode> = scans
1624        .iter()
1625        .map(|rel| RirNode::Scan { rel: *rel })
1626        .collect();
1627    let mut slot_vars: Vec<Vec<Option<u32>>> = Vec::with_capacity(scans.len());
1628    let mut offset = 0usize;
1629    for &w in &widths {
1630        slot_vars.push((offset..offset + w).map(|s| Some(slot_class[s])).collect());
1631        offset += w;
1632    }
1633
1634    Some(RirNode::MultiWayJoin {
1635        inputs,
1636        slot_vars,
1637        output_columns: columns.clone(),
1638        fallback: Box::new(body.clone()),
1639        // Provenance marker: `inputs` are the fallback's Scan leaves
1640        // in traversal order, so `output_columns` coincides with the
1641        // concatenated-inputs column space — the contract the Free
1642        // Join dispatcher requires (dedicated promoters reorder
1643        // `inputs` canonically and must never carry this variant).
1644        plan: Some(MultiwayPlan::FreeJoin),
1645        var_order: None,
1646    })
1647}
1648
1649/// Generic multiway aggregate group-by fusion: general multiway sibling of
1650/// [`try_promote_triangle_inside_aggregate`]. Promotes any >=3-atom
1651/// inner-join tree sitting inside an aggregate rule's
1652/// `Project{ GroupBy { Project { <join tree> } } }` wrapper through
1653/// [`try_promote_general_multiway`].
1654///
1655/// Unlike the dedicated descents, no column remapping gymnastics are
1656/// needed: FreeJoin-marked nodes keep `output_columns` in the
1657/// join-output column space (inputs are the tree's Scan leaves in
1658/// traversal order), so the group projection's columns are handed to
1659/// the promoter as the head projection verbatim and the group
1660/// projection becomes the identity over the MultiWayJoin's output.
1661/// Returns false — leaving the rule untouched — on any structural
1662/// mismatch.
1663fn try_promote_general_multiway_inside_aggregate(
1664    body: &mut RirNode,
1665    arities: &HashMap<RelId, usize>,
1666) -> bool {
1667    let RirNode::Project { input: gb, .. } = body else {
1668        return false;
1669    };
1670    let RirNode::GroupBy {
1671        input: group_input, ..
1672    } = gb.as_mut()
1673    else {
1674        return false;
1675    };
1676    let RirNode::Project {
1677        input: inner,
1678        columns: group_cols,
1679    } = group_input.as_mut()
1680    else {
1681        return false;
1682    };
1683    let candidate = RirNode::Project {
1684        input: inner.clone(),
1685        columns: group_cols.clone(),
1686    };
1687    let Some(promoted) = try_promote_general_multiway(&candidate, arities) else {
1688        return false;
1689    };
1690    // The MultiWayJoin's output IS the group projection, so the group
1691    // projection becomes positional identity over it.
1692    *group_cols = (0..group_cols.len()).map(ProjectExpr::Column).collect();
1693    **inner = promoted;
1694    true
1695}
1696
1697/// K-clique promoter for k ∈ {5, 6, 7, 8}.
1698///
1699/// Uses tree-flatten + complete-K_k validation. Robust to left-deep
1700/// / right-deep / bushy.
1701/// Rejects filter wrappers, reversed atoms, self-edges,
1702/// constants, and any non-canonical shape; admits recursive
1703/// bodies so runtime metadata refresh can observe each merge.
1704fn try_promote_clique_k(body: &RirNode, k: usize, stats: &StatsManager) -> Option<RirNode> {
1705    if !(5..=8).contains(&k) {
1706        return None;
1707    }
1708    let expected_edges = k * (k - 1) / 2;
1709
1710    // 1. Flatten body. Rejects Filter, non-canonical nodes.
1711    let (scans, key_pairs, project_globals) = flatten_clique_body(body)?;
1712
1713    // 2. Scan count must equal C(k, 2).
1714    if scans.len() != expected_edges {
1715        return None;
1716    }
1717
1718    // 3. Head must have exactly k variables.
1719    if project_globals.len() != k {
1720        return None;
1721    }
1722
1723    // 4. Union-find on global slot space (size 2 * expected_edges)
1724    // to derive variable equivalence classes.
1725    let n_slots = 2 * expected_edges;
1726    let mut parent: Vec<usize> = (0..n_slots).collect();
1727    for (a, b) in &key_pairs {
1728        if *a >= n_slots || *b >= n_slots {
1729            return None;
1730        }
1731        uf_union_clique(&mut parent, *a, *b);
1732    }
1733
1734    // 5. Find unique class representative per Project column.
1735    // Each Project column references a global slot; resolve to
1736    // its class root.
1737    let mut head_class: Vec<usize> = Vec::with_capacity(k);
1738    for col in &project_globals {
1739        if *col >= n_slots {
1740            return None;
1741        }
1742        head_class.push(uf_find_clique(&mut parent, *col));
1743    }
1744    // 6. The k head classes must be distinct.
1745    let mut sorted_head_classes = head_class.clone();
1746    sorted_head_classes.sort();
1747    sorted_head_classes.dedup();
1748    if sorted_head_classes.len() != k {
1749        return None;
1750    }
1751
1752    // 7. Total distinct classes across all slots must equal k.
1753    // (No "extra" non-head variables in atom slots — every slot's
1754    // class must be one of the k head classes.)
1755    let mut all_class_count: HashMap<usize, usize> = HashMap::new();
1756    for slot in 0..n_slots {
1757        let root = uf_find_clique(&mut parent, slot);
1758        *all_class_count.entry(root).or_insert(0) += 1;
1759    }
1760    if all_class_count.len() != k {
1761        return None;
1762    }
1763    // 8. Every class must have exactly k-1 slots (each variable
1764    // appears in exactly k-1 atoms = clique edges incident to it).
1765    for &count in all_class_count.values() {
1766        if count != k - 1 {
1767            return None;
1768        }
1769    }
1770
1771    // 9. Map class → head-var index. The Project column ordering
1772    // defines the head-var indices (Project col i = head var i).
1773    let mut class_to_head_idx: HashMap<usize, usize> = HashMap::new();
1774    for (head_idx, cls) in head_class.iter().enumerate() {
1775        class_to_head_idx.insert(*cls, head_idx);
1776    }
1777
1778    // 10. For each scan, derive its (var_a, var_b) pair from the
1779    // class memberships of its two slots. Reject self-edges
1780    // (both slots in same class) and atoms touching a non-head
1781    // class (already filtered above by class-count check, but
1782    // defensive).
1783    let mut atom_pairs: Vec<(usize, usize)> = Vec::with_capacity(expected_edges);
1784    let mut canonical_to_scan_idx: HashMap<(usize, usize), usize> = HashMap::new();
1785    for (atom_i, _rel) in scans.iter().enumerate() {
1786        let slot_a = 2 * atom_i;
1787        let slot_b = 2 * atom_i + 1;
1788        let cls_a = uf_find_clique(&mut parent, slot_a);
1789        let cls_b = uf_find_clique(&mut parent, slot_b);
1790        if cls_a == cls_b {
1791            // Self-edge e(X, X) — reject.
1792            return None;
1793        }
1794        let head_a = class_to_head_idx.get(&cls_a)?;
1795        let head_b = class_to_head_idx.get(&cls_b)?;
1796        // 11. Reversed-atom rejection: canonical form requires
1797        // col0 maps to lower head idx, col1 to higher. If col0
1798        // is at higher idx, the atom is reversed (this promoter does
1799        // not implement column-swap layout for clique edges).
1800        if *head_a > *head_b {
1801            return None;
1802        }
1803        let (lo, hi) = (*head_a, *head_b);
1804        atom_pairs.push((lo, hi));
1805        // 12. Reject duplicate edges (same (i, j) appearing twice).
1806        if canonical_to_scan_idx.insert((lo, hi), atom_i).is_some() {
1807            return None;
1808        }
1809    }
1810
1811    // 13. Verify the atom_pairs set equals the complete K_k edge
1812    // set {(i, j) | 0 <= i < j < k}.
1813    if canonical_to_scan_idx.len() != expected_edges {
1814        return None;
1815    }
1816    for i in 0..k {
1817        for j in (i + 1)..k {
1818            if !canonical_to_scan_idx.contains_key(&(i, j)) {
1819                return None;
1820            }
1821        }
1822    }
1823
1824    // 14. Reorder scans into canonical lex (i, j) order.
1825    let mut reordered_scans: Vec<RelId> = Vec::with_capacity(expected_edges);
1826    for i in 0..k {
1827        for j in (i + 1)..k {
1828            let scan_idx = canonical_to_scan_idx[&(i, j)];
1829            reordered_scans.push(scans[scan_idx]);
1830        }
1831    }
1832
1833    // 15. Build canonical MultiWayJoin.inputs (one Scan per
1834    // canonical edge in lex order) + slot_vars (each atom's
1835    // (col0, col1) bind (head_i, head_j) for canonical edge
1836    // (i, j)).
1837    let inputs: Vec<RirNode> = reordered_scans
1838        .iter()
1839        .map(|rel| RirNode::Scan { rel: *rel })
1840        .collect();
1841    let mut slot_vars: Vec<Vec<Option<u32>>> = Vec::with_capacity(expected_edges);
1842    for i in 0..k {
1843        for j in (i + 1)..k {
1844            let _ = clique_edge_idx(i, j, k); // sanity; locked invariant
1845            slot_vars.push(vec![Some(i as u32), Some(j as u32)]);
1846        }
1847    }
1848    // Project's existing column list defines the head-output
1849    // mapping; we preserve it on the MultiWayJoin's
1850    // output_columns.
1851    let RirNode::Project { columns, .. } = body else {
1852        return None;
1853    };
1854    let output_columns = columns.clone();
1855    let fallback = Box::new(body.clone());
1856    let shape = build_kclique_shape(k, &reordered_scans)?;
1857    let planner_stats = kclique_planner_stats(stats);
1858
1859    // Cost-planned K-clique routing follows the arXiv paper's
1860    // conditional-win-on-skew caveat: recognized paper-aligned shapes
1861    // emit a positive route. Hash is represented by
1862    // `PlannedHashRoute`, never by a post-recognition raw decline.
1863    let (plan, var_order) = match plan_kclique_var_order(&shape, &planner_stats) {
1864        Some(full_order) => {
1865            let evidence = rir_cost_prediction(&full_order);
1866            if wcoj_cost_gate_predicts_wcoj(evidence.wcoj_cost, evidence.hash_cost) {
1867                let kclique_order = kclique_variable_order_from_plan(&shape, &full_order)?;
1868                (
1869                    MultiwayPlan::WcojWithPlan(kclique_order.clone()),
1870                    Some(VariableOrder::kclique(kclique_order)),
1871                )
1872            } else {
1873                (
1874                    MultiwayPlan::PlannedHashRoute {
1875                        reason: PlannedHashReason::PlannerPredictsHashWins,
1876                        planner_evidence: evidence,
1877                    },
1878                    None,
1879                )
1880            }
1881        }
1882        None => (
1883            MultiwayPlan::PlannedHashRoute {
1884                reason: PlannedHashReason::IncompleteStatsSafeDefault,
1885                planner_evidence: RirCostPredictionRecord::empty(),
1886            },
1887            None,
1888        ),
1889    };
1890
1891    Some(RirNode::MultiWayJoin {
1892        inputs,
1893        slot_vars,
1894        output_columns,
1895        fallback,
1896        plan: Some(plan),
1897        var_order,
1898    })
1899}
1900
1901fn build_kclique_shape(k: usize, rels: &[RelId]) -> Option<KCliqueShape> {
1902    let mut edges = Vec::with_capacity(rels.len());
1903    let mut idx = 0usize;
1904    for i in 0..k {
1905        for j in (i + 1)..k {
1906            let rel_id = *rels.get(idx)?;
1907            edges.push(KCliqueEdge {
1908                rel_id,
1909                left: VertexId(i),
1910                right: VertexId(j),
1911                left_col: 0,
1912                right_col: 1,
1913            });
1914            idx += 1;
1915        }
1916    }
1917    KCliqueShape::from_edges(k as u8, edges)
1918}
1919
1920fn kclique_planner_stats(stats: &StatsManager) -> StatsSnapshot {
1921    stats.snapshot()
1922}
1923
1924fn rir_cost_prediction(plan: &FullVariableOrder) -> RirCostPredictionRecord {
1925    RirCostPredictionRecord {
1926        wcoj_cost: plan.cost_prediction.wcoj_cost,
1927        hash_cost: plan.cost_prediction.hash_cost,
1928    }
1929}
1930
1931fn kclique_variable_order_from_plan(
1932    shape: &KCliqueShape,
1933    plan: &FullVariableOrder,
1934) -> Option<KCliqueVariableOrder> {
1935    let k = shape.variable_count();
1936    let expected_edges = usize::from(k) * usize::from(k - 1) / 2;
1937    if plan.variable_order.len() != usize::from(k) || plan.edge_permutation.len() != expected_edges
1938    {
1939        return None;
1940    }
1941
1942    let mut variable_positions = [u8::MAX; K_CLIQUE_MAX_K];
1943    for (position, variable) in plan.variable_order.iter().enumerate() {
1944        if variable.0 >= usize::from(k) {
1945            return None;
1946        }
1947        variable_positions[variable.0] = position as u8;
1948    }
1949
1950    let mut edge_permutation = [u8::MAX; K_CLIQUE_MAX_EDGES];
1951    let mut column_swaps = Vec::new();
1952    let mut leader_slot = None;
1953    for (slot, edge_idx) in plan.edge_permutation.iter().copied().enumerate() {
1954        let edge = shape.edges().get(edge_idx)?;
1955        let left_pos = variable_positions[edge.left.0];
1956        let right_pos = variable_positions[edge.right.0];
1957        if left_pos == u8::MAX || right_pos == u8::MAX {
1958            return None;
1959        }
1960        edge_permutation[slot] = edge_idx as u8;
1961        if left_pos > right_pos {
1962            column_swaps.push(ColumnSwap {
1963                edge_slot: slot as u8,
1964                swap_cols: true,
1965            });
1966        }
1967        if [left_pos, right_pos].into_iter().min() == Some(0)
1968            && [left_pos, right_pos].into_iter().max() == Some(1)
1969        {
1970            leader_slot = Some(slot as u8);
1971        }
1972    }
1973
1974    let sorted_edge_slots = vec![leader_slot.unwrap_or(0)];
1975    let sorted_layout_requirements = SortedLayoutSpec {
1976        edge_slots: sorted_edge_slots,
1977        key_columns: vec![vec![0, 1]],
1978    };
1979
1980    // Helper-relation splitting elevates buried inner-variable skew.
1981    let helper_split_specs = plan.helper_split_specs.clone();
1982    Some(KCliqueVariableOrder::new(
1983        k,
1984        variable_positions,
1985        edge_permutation,
1986        column_swaps,
1987        sorted_layout_requirements,
1988        helper_split_specs,
1989        StreamGroupId(0),
1990    ))
1991}
1992
1993#[cfg(test)]
1994mod tests {
1995    use super::*;
1996    use xlog_core::RelId;
1997    use xlog_ir::{CompiledRule, ExecutionPlan, PlanBuilder, Scc};
1998
1999    fn canonical_triangle_tree() -> RirNode {
2000        let inner = RirNode::Join {
2001            left: Box::new(RirNode::Scan { rel: RelId(1) }),
2002            right: Box::new(RirNode::Scan { rel: RelId(2) }),
2003            left_keys: vec![1],
2004            right_keys: vec![0],
2005            join_type: JoinType::Inner,
2006        };
2007        let outer = RirNode::Join {
2008            left: Box::new(inner),
2009            right: Box::new(RirNode::Scan { rel: RelId(3) }),
2010            left_keys: vec![0, 3],
2011            right_keys: vec![0, 1],
2012            join_type: JoinType::Inner,
2013        };
2014        RirNode::Project {
2015            input: Box::new(outer),
2016            columns: vec![
2017                ProjectExpr::Column(0),
2018                ProjectExpr::Column(1),
2019                ProjectExpr::Column(3),
2020            ],
2021        }
2022    }
2023
2024    fn plan_with_body(body: RirNode) -> ExecutionPlan {
2025        let mut builder = PlanBuilder::new();
2026        builder.add_scc(Scc {
2027            id: 0,
2028            predicates: vec!["t".to_string()],
2029            is_recursive: false,
2030        });
2031        builder.add_rule(
2032            0,
2033            CompiledRule {
2034                head: "t".to_string(),
2035                body,
2036                meta: Default::default(),
2037            },
2038        );
2039        builder.build()
2040    }
2041
2042    fn canonical_chain_tree() -> RirNode {
2043        let join = RirNode::Join {
2044            left: Box::new(RirNode::Scan { rel: RelId(1) }),
2045            right: Box::new(RirNode::Scan { rel: RelId(2) }),
2046            left_keys: vec![1],
2047            right_keys: vec![0],
2048            join_type: JoinType::Inner,
2049        };
2050        RirNode::Project {
2051            input: Box::new(join),
2052            columns: vec![ProjectExpr::Column(0), ProjectExpr::Column(3)],
2053        }
2054    }
2055
2056    #[test]
2057    fn promotes_canonical_chain() {
2058        let mut plan = plan_with_body(canonical_chain_tree());
2059        promote_multiway(
2060            &mut plan,
2061            &HashMap::new(),
2062            &StatsManager::new(),
2063            &CompilerConfig::default(),
2064        );
2065        let body = &plan.rules_by_scc[0][0].body;
2066        match body {
2067            RirNode::ChainJoin {
2068                left,
2069                right,
2070                left_key,
2071                right_key,
2072                output_columns,
2073                fallback,
2074            } => {
2075                assert!(matches!(left.as_ref(), RirNode::Scan { rel: RelId(1) }));
2076                assert!(matches!(right.as_ref(), RirNode::Scan { rel: RelId(2) }));
2077                assert_eq!(*left_key, 1);
2078                assert_eq!(*right_key, 0);
2079                assert_eq!(
2080                    output_columns,
2081                    &vec![ProjectExpr::Column(0), ProjectExpr::Column(3)]
2082                );
2083                assert!(matches!(fallback.as_ref(), RirNode::Project { .. }));
2084            }
2085            other => panic!("expected ChainJoin, got {:?}", other),
2086        }
2087    }
2088
2089    #[test]
2090    fn chain_promotion_rejects_non_inner_join() {
2091        let mut body = canonical_chain_tree();
2092        if let RirNode::Project { input, .. } = &mut body {
2093            if let RirNode::Join { join_type, .. } = input.as_mut() {
2094                *join_type = JoinType::LeftOuter;
2095            }
2096        }
2097        assert!(try_promote_chain(&body).is_none());
2098    }
2099
2100    #[test]
2101    fn chain_promotion_rejects_multi_key_join() {
2102        let mut body = canonical_chain_tree();
2103        if let RirNode::Project { input, .. } = &mut body {
2104            if let RirNode::Join {
2105                left_keys,
2106                right_keys,
2107                ..
2108            } = input.as_mut()
2109            {
2110                *left_keys = vec![0, 1];
2111                *right_keys = vec![0, 1];
2112            }
2113        }
2114        assert!(try_promote_chain(&body).is_none());
2115    }
2116
2117    #[test]
2118    fn promotes_canonical_triangle() {
2119        let mut plan = plan_with_body(canonical_triangle_tree());
2120        promote_multiway(
2121            &mut plan,
2122            &HashMap::new(),
2123            &StatsManager::new(),
2124            &CompilerConfig::default(),
2125        );
2126        let body = &plan.rules_by_scc[0][0].body;
2127        match body {
2128            RirNode::MultiWayJoin {
2129                inputs,
2130                slot_vars,
2131                output_columns,
2132                fallback,
2133                ..
2134            } => {
2135                assert_eq!(inputs.len(), 3);
2136                assert!(matches!(inputs[0], RirNode::Scan { rel: RelId(1) }));
2137                assert!(matches!(inputs[1], RirNode::Scan { rel: RelId(2) }));
2138                assert!(matches!(inputs[2], RirNode::Scan { rel: RelId(3) }));
2139                assert_eq!(
2140                    slot_vars,
2141                    &vec![
2142                        vec![Some(0u32), Some(1)],
2143                        vec![Some(1u32), Some(2)],
2144                        vec![Some(0u32), Some(2)],
2145                    ]
2146                );
2147                assert_eq!(
2148                    output_columns,
2149                    &vec![
2150                        ProjectExpr::Column(0),
2151                        ProjectExpr::Column(1),
2152                        ProjectExpr::Column(3),
2153                    ]
2154                );
2155                // Fallback is the exact pre-promotion tree.
2156                assert!(matches!(fallback.as_ref(), RirNode::Project { .. }));
2157            }
2158            other => panic!("expected MultiWayJoin, got {:?}", other),
2159        }
2160    }
2161
2162    #[test]
2163    fn fallback_is_structurally_equal_to_input() {
2164        let pre = canonical_triangle_tree();
2165        let mut plan = plan_with_body(pre.clone());
2166        promote_multiway(
2167            &mut plan,
2168            &HashMap::new(),
2169            &StatsManager::new(),
2170            &CompilerConfig::default(),
2171        );
2172        let body = &plan.rules_by_scc[0][0].body;
2173        let RirNode::MultiWayJoin { fallback, .. } = body else {
2174            panic!("expected MultiWayJoin");
2175        };
2176        // Use Debug equality as a structural-equality proxy. RirNode
2177        // doesn't impl PartialEq directly because Expr doesn't; the
2178        // Debug output is deterministic and structurally faithful.
2179        assert_eq!(format!("{:?}", fallback.as_ref()), format!("{:?}", pre));
2180    }
2181
2182    #[test]
2183    fn idempotent_under_repeat_calls() {
2184        let mut plan = plan_with_body(canonical_triangle_tree());
2185        promote_multiway(
2186            &mut plan,
2187            &HashMap::new(),
2188            &StatsManager::new(),
2189            &CompilerConfig::default(),
2190        );
2191        let first = format!("{:?}", plan.rules_by_scc[0][0].body);
2192        promote_multiway(
2193            &mut plan,
2194            &HashMap::new(),
2195            &StatsManager::new(),
2196            &CompilerConfig::default(),
2197        );
2198        let second = format!("{:?}", plan.rules_by_scc[0][0].body);
2199        assert_eq!(first, second);
2200    }
2201
2202    /// Triangle with X-shared inner pair — inner keys
2203    /// `[0]/[0]`, outer keys `[1, 3]/[0, 1]`, project `[0, 1, 3]`.
2204    /// Body atoms at positions: l2 = e_xy, r2 = e_xz, r1 = e_yz.
2205    /// Promoter must reorder inputs to canonical semantic order
2206    /// `[XY, YZ, XZ]` = `[RelId(1), RelId(3), RelId(2)]`.
2207    #[test]
2208    fn promotes_triangle_with_x_shared_inner_pair() {
2209        let inner = RirNode::Join {
2210            left: Box::new(RirNode::Scan { rel: RelId(1) }), // e_xy
2211            right: Box::new(RirNode::Scan { rel: RelId(2) }), // e_xz
2212            left_keys: vec![0],
2213            right_keys: vec![0],
2214            join_type: JoinType::Inner,
2215        };
2216        let outer = RirNode::Join {
2217            left: Box::new(inner),
2218            right: Box::new(RirNode::Scan { rel: RelId(3) }), // e_yz
2219            left_keys: vec![1, 3],
2220            right_keys: vec![0, 1],
2221            join_type: JoinType::Inner,
2222        };
2223        let body = RirNode::Project {
2224            input: Box::new(outer),
2225            columns: vec![
2226                ProjectExpr::Column(0),
2227                ProjectExpr::Column(1),
2228                ProjectExpr::Column(3),
2229            ],
2230        };
2231        let mut plan = plan_with_body(body);
2232        promote_multiway(
2233            &mut plan,
2234            &HashMap::new(),
2235            &StatsManager::new(),
2236            &CompilerConfig::default(),
2237        );
2238        let RirNode::MultiWayJoin {
2239            inputs, slot_vars, ..
2240        } = &plan.rules_by_scc[0][0].body
2241        else {
2242            panic!("expected MultiWayJoin after promotion");
2243        };
2244        // Semantic-order inputs: [XY, YZ, XZ] = [RelId(1), RelId(3), RelId(2)].
2245        let scan_rels: Vec<RelId> = inputs
2246            .iter()
2247            .map(|n| match n {
2248                RirNode::Scan { rel } => *rel,
2249                _ => panic!("expected Scan in MultiWayJoin inputs"),
2250            })
2251            .collect();
2252        assert_eq!(scan_rels, vec![RelId(1), RelId(3), RelId(2)]);
2253        // Shape-fixed canonical slot_vars.
2254        assert_eq!(
2255            slot_vars,
2256            &vec![
2257                vec![Some(0u32), Some(1)],
2258                vec![Some(1u32), Some(2)],
2259                vec![Some(0u32), Some(2)],
2260            ]
2261        );
2262    }
2263
2264    /// Triangle with Z-shared inner pair — inner keys
2265    /// `[1]/[1]`, outer keys `[0, 2]/[0, 1]`, project `[0, 2, 3]`.
2266    /// Body atoms at positions: l2 = e_xz, r2 = e_yz, r1 = e_xy.
2267    /// Promoter must reorder inputs to canonical semantic order
2268    /// `[XY, YZ, XZ]` = `[RelId(3), RelId(2), RelId(1)]`.
2269    #[test]
2270    fn promotes_triangle_with_z_shared_inner_pair() {
2271        let inner = RirNode::Join {
2272            left: Box::new(RirNode::Scan { rel: RelId(1) }), // e_xz
2273            right: Box::new(RirNode::Scan { rel: RelId(2) }), // e_yz
2274            left_keys: vec![1],
2275            right_keys: vec![1],
2276            join_type: JoinType::Inner,
2277        };
2278        let outer = RirNode::Join {
2279            left: Box::new(inner),
2280            right: Box::new(RirNode::Scan { rel: RelId(3) }), // e_xy
2281            left_keys: vec![0, 2],
2282            right_keys: vec![0, 1],
2283            join_type: JoinType::Inner,
2284        };
2285        let body = RirNode::Project {
2286            input: Box::new(outer),
2287            // Inner cols: [X, Z, Y, Z]. Outer cols: [X, Z, Y, Z, X, Y].
2288            // Head (X, Y, Z) = [0, 2, 3].
2289            columns: vec![
2290                ProjectExpr::Column(0),
2291                ProjectExpr::Column(2),
2292                ProjectExpr::Column(3),
2293            ],
2294        };
2295        let mut plan = plan_with_body(body);
2296        promote_multiway(
2297            &mut plan,
2298            &HashMap::new(),
2299            &StatsManager::new(),
2300            &CompilerConfig::default(),
2301        );
2302        let RirNode::MultiWayJoin {
2303            inputs, slot_vars, ..
2304        } = &plan.rules_by_scc[0][0].body
2305        else {
2306            panic!("expected MultiWayJoin after promotion");
2307        };
2308        let scan_rels: Vec<RelId> = inputs
2309            .iter()
2310            .map(|n| match n {
2311                RirNode::Scan { rel } => *rel,
2312                _ => panic!("expected Scan in MultiWayJoin inputs"),
2313            })
2314            .collect();
2315        assert_eq!(scan_rels, vec![RelId(3), RelId(2), RelId(1)]);
2316        assert_eq!(
2317            slot_vars,
2318            &vec![
2319                vec![Some(0u32), Some(1)],
2320                vec![Some(1u32), Some(2)],
2321                vec![Some(0u32), Some(2)],
2322            ]
2323        );
2324    }
2325
2326    #[test]
2327    fn promotes_triangle_with_rotated_projection_columns() {
2328        // The original strict rejection of non-canonical projection
2329        // columns is intentionally relaxed. The variable-graph
2330        // promoter recognizes the triangle
2331        // topology regardless of which head position picks
2332        // which variable, as long as the 3 head columns pick
2333        // 3 distinct equivalence classes. The emitted
2334        // `MultiWayJoin` carries shape-fixed `slot_vars` —
2335        // [[0,1], [1,2], [0,2]] — which encode the triangle's
2336        // topology in the head-label space (head[0] = label 0,
2337        // head[1] = label 1, head[2] = label 2). `output_columns`
2338        // are preserved verbatim from the body's projection.
2339        let inner = RirNode::Join {
2340            left: Box::new(RirNode::Scan { rel: RelId(1) }),
2341            right: Box::new(RirNode::Scan { rel: RelId(2) }),
2342            left_keys: vec![1],
2343            right_keys: vec![0],
2344            join_type: JoinType::Inner,
2345        };
2346        let outer = RirNode::Join {
2347            left: Box::new(inner),
2348            right: Box::new(RirNode::Scan { rel: RelId(3) }),
2349            left_keys: vec![0, 3],
2350            right_keys: vec![0, 1],
2351            join_type: JoinType::Inner,
2352        };
2353        let body = RirNode::Project {
2354            input: Box::new(outer),
2355            // Rotated: head extracts (Y, X, Z) instead of (X, Y, Z).
2356            // Topology is still a triangle.
2357            columns: vec![
2358                ProjectExpr::Column(1),
2359                ProjectExpr::Column(0),
2360                ProjectExpr::Column(3),
2361            ],
2362        };
2363        let mut plan = plan_with_body(body);
2364        promote_multiway(
2365            &mut plan,
2366            &HashMap::new(),
2367            &StatsManager::new(),
2368            &CompilerConfig::default(),
2369        );
2370        // Body now promoted to MultiWayJoin by the semantic-slot contract.
2371        let RirNode::MultiWayJoin {
2372            slot_vars,
2373            output_columns,
2374            ..
2375        } = &plan.rules_by_scc[0][0].body
2376        else {
2377            panic!("expected MultiWayJoin after promotion");
2378        };
2379        // Shape-fixed slot_vars in head-label space.
2380        assert_eq!(
2381            slot_vars,
2382            &vec![
2383                vec![Some(0u32), Some(1)],
2384                vec![Some(1u32), Some(2)],
2385                vec![Some(0u32), Some(2)],
2386            ]
2387        );
2388        // output_columns preserved verbatim.
2389        assert_eq!(
2390            output_columns,
2391            &vec![
2392                ProjectExpr::Column(1),
2393                ProjectExpr::Column(0),
2394                ProjectExpr::Column(3),
2395            ]
2396        );
2397    }
2398
2399    #[test]
2400    fn rejects_non_inner_join() {
2401        let inner = RirNode::Join {
2402            left: Box::new(RirNode::Scan { rel: RelId(1) }),
2403            right: Box::new(RirNode::Scan { rel: RelId(2) }),
2404            left_keys: vec![1],
2405            right_keys: vec![0],
2406            join_type: JoinType::LeftOuter,
2407        };
2408        let outer = RirNode::Join {
2409            left: Box::new(inner),
2410            right: Box::new(RirNode::Scan { rel: RelId(3) }),
2411            left_keys: vec![0, 3],
2412            right_keys: vec![0, 1],
2413            join_type: JoinType::Inner,
2414        };
2415        let body = RirNode::Project {
2416            input: Box::new(outer),
2417            columns: vec![
2418                ProjectExpr::Column(0),
2419                ProjectExpr::Column(1),
2420                ProjectExpr::Column(3),
2421            ],
2422        };
2423        let mut plan = plan_with_body(body);
2424        promote_multiway(
2425            &mut plan,
2426            &HashMap::new(),
2427            &StatsManager::new(),
2428            &CompilerConfig::default(),
2429        );
2430        assert!(matches!(
2431            &plan.rules_by_scc[0][0].body,
2432            RirNode::Project { .. }
2433        ));
2434    }
2435
2436    #[test]
2437    fn rejects_filter_above_outer_join() {
2438        // An optimizer may insert a Filter between the outer Project
2439        // and the outer Join. This promoter does not recognize this.
2440        let inner = RirNode::Join {
2441            left: Box::new(RirNode::Scan { rel: RelId(1) }),
2442            right: Box::new(RirNode::Scan { rel: RelId(2) }),
2443            left_keys: vec![1],
2444            right_keys: vec![0],
2445            join_type: JoinType::Inner,
2446        };
2447        let outer = RirNode::Join {
2448            left: Box::new(inner),
2449            right: Box::new(RirNode::Scan { rel: RelId(3) }),
2450            left_keys: vec![0, 3],
2451            right_keys: vec![0, 1],
2452            join_type: JoinType::Inner,
2453        };
2454        let filtered = RirNode::Filter {
2455            input: Box::new(outer),
2456            predicate: xlog_ir::Expr::Column(0),
2457        };
2458        let body = RirNode::Project {
2459            input: Box::new(filtered),
2460            columns: vec![
2461                ProjectExpr::Column(0),
2462                ProjectExpr::Column(1),
2463                ProjectExpr::Column(3),
2464            ],
2465        };
2466        let mut plan = plan_with_body(body);
2467        promote_multiway(
2468            &mut plan,
2469            &HashMap::new(),
2470            &StatsManager::new(),
2471            &CompilerConfig::default(),
2472        );
2473        assert!(matches!(
2474            &plan.rules_by_scc[0][0].body,
2475            RirNode::Project { .. }
2476        ));
2477    }
2478
2479    #[test]
2480    fn meta_preserved_byte_for_byte() {
2481        use xlog_core::Schema;
2482        use xlog_ir::metadata::RirMeta;
2483
2484        let schema = Schema::new(vec![
2485            ("x".to_string(), xlog_core::ScalarType::U32),
2486            ("y".to_string(), xlog_core::ScalarType::U32),
2487            ("z".to_string(), xlog_core::ScalarType::U32),
2488        ]);
2489        let meta_pre = RirMeta::with_schema(schema).with_rows(100, 250);
2490
2491        let mut builder = PlanBuilder::new();
2492        builder.add_scc(Scc {
2493            id: 0,
2494            predicates: vec!["t".to_string()],
2495            is_recursive: false,
2496        });
2497        builder.add_rule(
2498            0,
2499            CompiledRule {
2500                head: "t".to_string(),
2501                body: canonical_triangle_tree(),
2502                meta: meta_pre.clone(),
2503            },
2504        );
2505        let mut plan = builder.build();
2506        promote_multiway(
2507            &mut plan,
2508            &HashMap::new(),
2509            &StatsManager::new(),
2510            &CompilerConfig::default(),
2511        );
2512        assert_eq!(
2513            format!("{:?}", plan.rules_by_scc[0][0].meta),
2514            format!("{:?}", meta_pre),
2515        );
2516    }
2517
2518    // -----------------------------------------------------------
2519    // Recursive-SCC promotion gates
2520    // -----------------------------------------------------------
2521
2522    /// Recursive promotion contract: a stable triangle (zero recursive Scans) in
2523    /// a recursive SCC IS promoted — the recursive engine's seeding
2524    /// pass dispatches WCOJ via `execute_wcoj_or_fallback_node`.
2525    /// Body Scans are RelId(1)/(2)/(3) and the SCC's predicate "tri"
2526    /// is not in `rel_ids`, so the count is 0.
2527    #[test]
2528    fn promotes_stable_triangle_in_recursive_scc() {
2529        let mut builder = PlanBuilder::new();
2530        builder.add_scc(Scc {
2531            id: 0,
2532            predicates: vec!["tri".to_string()],
2533            is_recursive: true,
2534        });
2535        builder.add_rule(
2536            0,
2537            CompiledRule {
2538                head: "tri".to_string(),
2539                body: canonical_triangle_tree(),
2540                meta: Default::default(),
2541            },
2542        );
2543        let mut plan = builder.build();
2544        // No "tri" entry in rel_ids → all body Scans are extensional
2545        // from this SCC's POV → count == 0 → promote.
2546        promote_multiway(
2547            &mut plan,
2548            &HashMap::new(),
2549            &StatsManager::new(),
2550            &CompilerConfig::default(),
2551        );
2552        assert!(matches!(
2553            &plan.rules_by_scc[0][0].body,
2554            RirNode::MultiWayJoin { .. }
2555        ));
2556    }
2557
2558    /// Recursive promotion contract: a linear-recursive triangle (exactly one
2559    /// in-SCC Scan) IS promoted. Build a triangle whose RelId(2)
2560    /// corresponds to the head SCC's predicate "tri" and assert
2561    /// promotion despite `is_recursive: true`.
2562    #[test]
2563    fn promotes_linear_recursive_triangle() {
2564        let mut builder = PlanBuilder::new();
2565        builder.add_scc(Scc {
2566            id: 0,
2567            predicates: vec!["tri".to_string()],
2568            is_recursive: true,
2569        });
2570        builder.add_rule(
2571            0,
2572            CompiledRule {
2573                head: "tri".to_string(),
2574                body: canonical_triangle_tree(),
2575                meta: Default::default(),
2576            },
2577        );
2578        let mut plan = builder.build();
2579        let mut rel_ids = HashMap::new();
2580        rel_ids.insert("tri".to_string(), RelId(2)); // 1 of 3 Scans
2581        promote_multiway(
2582            &mut plan,
2583            &rel_ids,
2584            &StatsManager::new(),
2585            &CompilerConfig::default(),
2586        );
2587        assert!(matches!(
2588            &plan.rules_by_scc[0][0].body,
2589            RirNode::MultiWayJoin { .. }
2590        ));
2591    }
2592
2593    /// Occurrence-level recursive promotion contract: a recursive SCC
2594    /// body with ≥ 2 recursive Scans (here: 2 distinct in-SCC
2595    /// predicates) IS promoted to `MultiWayJoin`. Mark "tri_a" →
2596    /// RelId(1) and "tri_b" → RelId(2) so two of the three body
2597    /// Scans count as in-SCC.
2598    #[test]
2599    fn promotes_multirec_triangle_in_recursive_scc() {
2600        let mut builder = PlanBuilder::new();
2601        builder.add_scc(Scc {
2602            id: 0,
2603            predicates: vec!["tri_a".to_string(), "tri_b".to_string()],
2604            is_recursive: true,
2605        });
2606        builder.add_rule(
2607            0,
2608            CompiledRule {
2609                head: "tri_a".to_string(),
2610                body: canonical_triangle_tree(),
2611                meta: Default::default(),
2612            },
2613        );
2614        let mut plan = builder.build();
2615        let mut rel_ids = HashMap::new();
2616        rel_ids.insert("tri_a".to_string(), RelId(1));
2617        rel_ids.insert("tri_b".to_string(), RelId(2));
2618        // Count == 2 ≥ 2 → occurrence-level recursive promotion admits it.
2619        promote_multiway(
2620            &mut plan,
2621            &rel_ids,
2622            &StatsManager::new(),
2623            &CompilerConfig::default(),
2624        );
2625        assert!(matches!(
2626            &plan.rules_by_scc[0][0].body,
2627            RirNode::MultiWayJoin { .. }
2628        ));
2629    }
2630
2631    /// Mixed plan: a recursive SCC with a linear-recursive triangle
2632    /// (count 1) AND a non-recursive SCC with a stable triangle
2633    /// (count 0). BOTH get promoted under the recursive promotion contract.
2634    #[test]
2635    fn promotes_linear_rec_and_non_rec_sccs_in_mixed_plan() {
2636        let mut builder = PlanBuilder::new();
2637        builder.add_scc(Scc {
2638            id: 0,
2639            predicates: vec!["rec".to_string()],
2640            is_recursive: true,
2641        });
2642        builder.add_rule(
2643            0,
2644            CompiledRule {
2645                head: "rec".to_string(),
2646                body: canonical_triangle_tree(),
2647                meta: Default::default(),
2648            },
2649        );
2650        builder.add_scc(Scc {
2651            id: 1,
2652            predicates: vec!["nonrec".to_string()],
2653            is_recursive: false,
2654        });
2655        builder.add_rule(
2656            1,
2657            CompiledRule {
2658                head: "nonrec".to_string(),
2659                body: canonical_triangle_tree(),
2660                meta: Default::default(),
2661            },
2662        );
2663        let mut plan = builder.build();
2664        let mut rel_ids = HashMap::new();
2665        rel_ids.insert("rec".to_string(), RelId(1)); // count 1 in SCC 0
2666                                                     // SCC 1 has no rec scans (count 0 in SCC 1 because "nonrec"
2667                                                     // not in body).
2668        promote_multiway(
2669            &mut plan,
2670            &rel_ids,
2671            &StatsManager::new(),
2672            &CompilerConfig::default(),
2673        );
2674        assert!(matches!(
2675            &plan.rules_by_scc[0][0].body,
2676            RirNode::MultiWayJoin { .. }
2677        ));
2678        assert!(matches!(
2679            &plan.rules_by_scc[1][0].body,
2680            RirNode::MultiWayJoin { .. }
2681        ));
2682    }
2683
2684    // -----------------------------------------------------------
2685    // 4-cycle promotion
2686    // -----------------------------------------------------------
2687
2688    /// Build the canonical lowered+optimized 4-cycle subtree —
2689    /// `Project { Join { Join, Join } }` bushy shape with outer
2690    /// keys [0,3]/[3,0] and inner keys [1]/[0]. Verified against
2691    /// `Compiler::compile()` output for
2692    /// `cycle4(W,X,Y,Z) :- e1(W,X), e2(X,Y), e3(Y,Z), e4(Z,W).`.
2693    fn canonical_4cycle_tree() -> RirNode {
2694        let inner_l = RirNode::Join {
2695            left: Box::new(RirNode::Scan { rel: RelId(1) }),
2696            right: Box::new(RirNode::Scan { rel: RelId(2) }),
2697            left_keys: vec![1],
2698            right_keys: vec![0],
2699            join_type: JoinType::Inner,
2700        };
2701        let inner_r = RirNode::Join {
2702            left: Box::new(RirNode::Scan { rel: RelId(3) }),
2703            right: Box::new(RirNode::Scan { rel: RelId(4) }),
2704            left_keys: vec![1],
2705            right_keys: vec![0],
2706            join_type: JoinType::Inner,
2707        };
2708        let outer = RirNode::Join {
2709            left: Box::new(inner_l),
2710            right: Box::new(inner_r),
2711            left_keys: vec![0, 3],
2712            right_keys: vec![3, 0],
2713            join_type: JoinType::Inner,
2714        };
2715        RirNode::Project {
2716            input: Box::new(outer),
2717            columns: vec![
2718                ProjectExpr::Column(0),
2719                ProjectExpr::Column(1),
2720                ProjectExpr::Column(3),
2721                ProjectExpr::Column(5),
2722            ],
2723        }
2724    }
2725
2726    fn plan_with_4cycle_body(body: RirNode) -> ExecutionPlan {
2727        let mut builder = PlanBuilder::new();
2728        builder.add_scc(Scc {
2729            id: 0,
2730            predicates: vec!["cycle4".to_string()],
2731            is_recursive: false,
2732        });
2733        builder.add_rule(
2734            0,
2735            CompiledRule {
2736                head: "cycle4".to_string(),
2737                body,
2738                meta: Default::default(),
2739            },
2740        );
2741        builder.build()
2742    }
2743
2744    #[test]
2745    fn promotes_canonical_4cycle() {
2746        let mut plan = plan_with_4cycle_body(canonical_4cycle_tree());
2747        promote_multiway(
2748            &mut plan,
2749            &HashMap::new(),
2750            &StatsManager::new(),
2751            &CompilerConfig::default(),
2752        );
2753        let body = &plan.rules_by_scc[0][0].body;
2754        match body {
2755            RirNode::MultiWayJoin {
2756                inputs,
2757                slot_vars,
2758                output_columns,
2759                fallback,
2760                ..
2761            } => {
2762                assert_eq!(inputs.len(), 4);
2763                assert!(matches!(inputs[0], RirNode::Scan { rel: RelId(1) }));
2764                assert!(matches!(inputs[1], RirNode::Scan { rel: RelId(2) }));
2765                assert!(matches!(inputs[2], RirNode::Scan { rel: RelId(3) }));
2766                assert!(matches!(inputs[3], RirNode::Scan { rel: RelId(4) }));
2767                assert_eq!(
2768                    slot_vars,
2769                    &vec![
2770                        vec![Some(0u32), Some(1)],
2771                        vec![Some(1u32), Some(2)],
2772                        vec![Some(2u32), Some(3)],
2773                        vec![Some(3u32), Some(0)],
2774                    ]
2775                );
2776                assert_eq!(
2777                    output_columns,
2778                    &vec![
2779                        ProjectExpr::Column(0),
2780                        ProjectExpr::Column(1),
2781                        ProjectExpr::Column(3),
2782                        ProjectExpr::Column(5),
2783                    ]
2784                );
2785                assert!(matches!(fallback.as_ref(), RirNode::Project { .. }));
2786            }
2787            other => panic!("expected MultiWayJoin, got {:?}", other),
2788        }
2789    }
2790
2791    #[test]
2792    fn fallback_4cycle_is_structurally_equal_to_input() {
2793        let pre = canonical_4cycle_tree();
2794        let mut plan = plan_with_4cycle_body(pre.clone());
2795        promote_multiway(
2796            &mut plan,
2797            &HashMap::new(),
2798            &StatsManager::new(),
2799            &CompilerConfig::default(),
2800        );
2801        let body = &plan.rules_by_scc[0][0].body;
2802        let RirNode::MultiWayJoin { fallback, .. } = body else {
2803            panic!("expected MultiWayJoin");
2804        };
2805        assert_eq!(format!("{:?}", fallback.as_ref()), format!("{:?}", pre));
2806    }
2807
2808    #[test]
2809    fn idempotent_4cycle_under_repeat_calls() {
2810        let mut plan = plan_with_4cycle_body(canonical_4cycle_tree());
2811        promote_multiway(
2812            &mut plan,
2813            &HashMap::new(),
2814            &StatsManager::new(),
2815            &CompilerConfig::default(),
2816        );
2817        let first = format!("{:?}", plan.rules_by_scc[0][0].body);
2818        promote_multiway(
2819            &mut plan,
2820            &HashMap::new(),
2821            &StatsManager::new(),
2822            &CompilerConfig::default(),
2823        );
2824        let second = format!("{:?}", plan.rules_by_scc[0][0].body);
2825        assert_eq!(first, second);
2826    }
2827
2828    #[test]
2829    fn rejects_4cycle_with_left_deep_shape() {
2830        // Left-deep with 3 atoms = triangle, not 4-cycle. Even
2831        // though it might have 4 head columns through some other
2832        // construction, the strict 4-cycle matcher requires the
2833        // bushy outer-Join shape with outer-right being a Join.
2834        // This input is the triangle's left-deep tree — it should
2835        // NOT match try_promote_4cycle.
2836        let inner = RirNode::Join {
2837            left: Box::new(RirNode::Scan { rel: RelId(1) }),
2838            right: Box::new(RirNode::Scan { rel: RelId(2) }),
2839            left_keys: vec![1],
2840            right_keys: vec![0],
2841            join_type: JoinType::Inner,
2842        };
2843        let outer = RirNode::Join {
2844            left: Box::new(inner),
2845            right: Box::new(RirNode::Scan { rel: RelId(3) }),
2846            left_keys: vec![0, 3],
2847            right_keys: vec![0, 1],
2848            join_type: JoinType::Inner,
2849        };
2850        let body = RirNode::Project {
2851            input: Box::new(outer),
2852            columns: vec![
2853                ProjectExpr::Column(0),
2854                ProjectExpr::Column(1),
2855                ProjectExpr::Column(3),
2856                ProjectExpr::Column(5),
2857            ],
2858        };
2859        // This shape will be matched by try_promote_triangle if
2860        // its head columns are [0,1,3] — they aren't here (4 cols),
2861        // so triangle declines. 4-cycle declines because outer-right
2862        // is a Scan not a Join.
2863        assert!(
2864            try_promote_4cycle(&body, &StatsManager::new(), &CompilerConfig::default()).is_none()
2865        );
2866    }
2867
2868    /// 4-cycle with the alternative bushy grouping
2869    /// `(e2⋈e3) + (e4⋈e1)` — left-inner shares Y, right-inner
2870    /// shares W. Atoms at positions: ll=e2(X,Y), lr=e3(Y,Z),
2871    /// rl=e4(Z,W), rr=e1(W,X). Promoter must reorder inputs
2872    /// to canonical semantic order `[WX, XY, YZ, ZW]` =
2873    /// `[RelId(1), RelId(2), RelId(3), RelId(4)]` regardless
2874    /// of positional layout.
2875    #[test]
2876    fn promotes_4cycle_with_alternative_inner_grouping() {
2877        // RelId(1)=e1 (W,X), RelId(2)=e2 (X,Y), RelId(3)=e3 (Y,Z), RelId(4)=e4 (Z,W).
2878        // Alternative grouping: (e2⋈e3 on Y) + (e4⋈e1 on W).
2879        let inner_left = RirNode::Join {
2880            left: Box::new(RirNode::Scan { rel: RelId(2) }),
2881            right: Box::new(RirNode::Scan { rel: RelId(3) }),
2882            left_keys: vec![1],
2883            right_keys: vec![0],
2884            join_type: JoinType::Inner,
2885        };
2886        let inner_right = RirNode::Join {
2887            left: Box::new(RirNode::Scan { rel: RelId(4) }),
2888            right: Box::new(RirNode::Scan { rel: RelId(1) }),
2889            left_keys: vec![1],
2890            right_keys: vec![0],
2891            join_type: JoinType::Inner,
2892        };
2893        let outer = RirNode::Join {
2894            left: Box::new(inner_left),
2895            right: Box::new(inner_right),
2896            // Join on X (ll.col[0] == rr.col[1]) and Z (lr.col[1] == rl.col[0]).
2897            // outer-left output cols [X, Y, Y, Z]; outer-right output cols [Z, W, W, X].
2898            left_keys: vec![0, 3],
2899            right_keys: vec![3, 0],
2900            join_type: JoinType::Inner,
2901        };
2902        // Outer cols: [X(0), Y(1), Y(2), Z(3), Z(4), W(5), W(6), X(7)].
2903        // Project to (W, X, Y, Z): [5, 0, 1, 3].
2904        let body = RirNode::Project {
2905            input: Box::new(outer),
2906            columns: vec![
2907                ProjectExpr::Column(5),
2908                ProjectExpr::Column(0),
2909                ProjectExpr::Column(1),
2910                ProjectExpr::Column(3),
2911            ],
2912        };
2913        let mut plan = plan_with_body(body);
2914        promote_multiway(
2915            &mut plan,
2916            &HashMap::new(),
2917            &StatsManager::new(),
2918            &CompilerConfig::default(),
2919        );
2920        let RirNode::MultiWayJoin {
2921            inputs, slot_vars, ..
2922        } = &plan.rules_by_scc[0][0].body
2923        else {
2924            panic!("expected MultiWayJoin after promotion");
2925        };
2926        let scan_rels: Vec<RelId> = inputs
2927            .iter()
2928            .map(|n| match n {
2929                RirNode::Scan { rel } => *rel,
2930                _ => panic!("expected Scan in MultiWayJoin inputs"),
2931            })
2932            .collect();
2933        // Semantic order [WX, XY, YZ, ZW] = [e1, e2, e3, e4]
2934        // = [RelId(1), RelId(2), RelId(3), RelId(4)].
2935        assert_eq!(
2936            scan_rels,
2937            vec![RelId(1), RelId(2), RelId(3), RelId(4)],
2938            "inputs must be in semantic order regardless of positional layout"
2939        );
2940        // Shape-fixed canonical 4-cycle slot_vars.
2941        assert_eq!(
2942            slot_vars,
2943            &vec![
2944                vec![Some(0u32), Some(1)],
2945                vec![Some(1u32), Some(2)],
2946                vec![Some(2u32), Some(3)],
2947                vec![Some(3u32), Some(0)],
2948            ]
2949        );
2950    }
2951
2952    #[test]
2953    fn rejects_4cycle_with_rotated_columns() {
2954        let mut body = canonical_4cycle_tree();
2955        if let RirNode::Project { columns, .. } = &mut body {
2956            // Rotate: swap col 0 and col 1.
2957            columns.swap(0, 1);
2958        }
2959        assert!(
2960            try_promote_4cycle(&body, &StatsManager::new(), &CompilerConfig::default()).is_none()
2961        );
2962    }
2963
2964    #[test]
2965    fn rejects_4cycle_with_non_inner_outer_join() {
2966        let mut body = canonical_4cycle_tree();
2967        if let RirNode::Project { input, .. } = &mut body {
2968            if let RirNode::Join { join_type, .. } = input.as_mut() {
2969                *join_type = JoinType::LeftOuter;
2970            }
2971        }
2972        assert!(
2973            try_promote_4cycle(&body, &StatsManager::new(), &CompilerConfig::default()).is_none()
2974        );
2975    }
2976
2977    #[test]
2978    fn rejects_4cycle_with_wrong_outer_keys() {
2979        let mut body = canonical_4cycle_tree();
2980        if let RirNode::Project { input, .. } = &mut body {
2981            if let RirNode::Join { left_keys, .. } = input.as_mut() {
2982                *left_keys = vec![0, 4]; // not [0, 3]
2983            }
2984        }
2985        assert!(
2986            try_promote_4cycle(&body, &StatsManager::new(), &CompilerConfig::default()).is_none()
2987        );
2988    }
2989
2990    /// Recursive promotion contract: stable 4-cycle (zero recursive Scans) IS
2991    /// promoted in a recursive SCC.
2992    #[test]
2993    fn promotes_stable_4cycle_in_recursive_scc() {
2994        let mut builder = PlanBuilder::new();
2995        builder.add_scc(Scc {
2996            id: 0,
2997            predicates: vec!["rec_cycle".to_string()],
2998            is_recursive: true,
2999        });
3000        builder.add_rule(
3001            0,
3002            CompiledRule {
3003                head: "rec_cycle".to_string(),
3004                body: canonical_4cycle_tree(),
3005                meta: Default::default(),
3006            },
3007        );
3008        let mut plan = builder.build();
3009        // No "rec_cycle" entry in rel_ids → count 0 → promote.
3010        promote_multiway(
3011            &mut plan,
3012            &HashMap::new(),
3013            &StatsManager::new(),
3014            &CompilerConfig::default(),
3015        );
3016        assert!(matches!(
3017            &plan.rules_by_scc[0][0].body,
3018            RirNode::MultiWayJoin { .. }
3019        ));
3020    }
3021
3022    /// Recursive promotion contract: linear-recursive 4-cycle (count 1) IS
3023    /// promoted.
3024    #[test]
3025    fn promotes_linear_recursive_4cycle() {
3026        let mut builder = PlanBuilder::new();
3027        builder.add_scc(Scc {
3028            id: 0,
3029            predicates: vec!["rec_cycle".to_string()],
3030            is_recursive: true,
3031        });
3032        builder.add_rule(
3033            0,
3034            CompiledRule {
3035                head: "rec_cycle".to_string(),
3036                body: canonical_4cycle_tree(),
3037                meta: Default::default(),
3038            },
3039        );
3040        let mut plan = builder.build();
3041        let mut rel_ids = HashMap::new();
3042        // canonical_4cycle_tree uses 4 Scans; map exactly one to
3043        // rec_cycle (RelId selection from 4cycle_tree below).
3044        rel_ids.insert("rec_cycle".to_string(), RelId(2));
3045        promote_multiway(
3046            &mut plan,
3047            &rel_ids,
3048            &StatsManager::new(),
3049            &CompilerConfig::default(),
3050        );
3051        assert!(matches!(
3052            &plan.rules_by_scc[0][0].body,
3053            RirNode::MultiWayJoin { .. }
3054        ));
3055    }
3056
3057    /// Occurrence-level recursive promotion contract: ≥ 2 recursive
3058    /// Scans in a 4-cycle body IS promoted to `MultiWayJoin`.
3059    #[test]
3060    fn promotes_multirec_4cycle_in_recursive_scc() {
3061        let mut builder = PlanBuilder::new();
3062        builder.add_scc(Scc {
3063            id: 0,
3064            predicates: vec!["rc_a".to_string(), "rc_b".to_string()],
3065            is_recursive: true,
3066        });
3067        builder.add_rule(
3068            0,
3069            CompiledRule {
3070                head: "rc_a".to_string(),
3071                body: canonical_4cycle_tree(),
3072                meta: Default::default(),
3073            },
3074        );
3075        let mut plan = builder.build();
3076        let mut rel_ids = HashMap::new();
3077        rel_ids.insert("rc_a".to_string(), RelId(1));
3078        rel_ids.insert("rc_b".to_string(), RelId(2));
3079        promote_multiway(
3080            &mut plan,
3081            &rel_ids,
3082            &StatsManager::new(),
3083            &CompilerConfig::default(),
3084        );
3085        assert!(matches!(
3086            &plan.rules_by_scc[0][0].body,
3087            RirNode::MultiWayJoin { .. }
3088        ));
3089    }
3090
3091    #[test]
3092    fn triangle_does_not_match_4cycle_promoter() {
3093        // Pin the cross-shape contract: a body matched by
3094        // try_promote_triangle must NOT also match try_promote_4cycle.
3095        // Both promoters should be exclusive.
3096        let triangle = canonical_triangle_tree();
3097        assert!(
3098            try_promote_4cycle(&triangle, &StatsManager::new(), &CompilerConfig::default())
3099                .is_none()
3100        );
3101        let four_cycle = canonical_4cycle_tree();
3102        assert!(try_promote_triangle(
3103            &four_cycle,
3104            &StatsManager::new(),
3105            &CompilerConfig::default()
3106        )
3107        .is_none());
3108    }
3109}