Skip to main content

xlog_logic/
optimizer.rs

1//! Query optimizer for join ordering and predicate pushdown.
2//!
3//! This module provides cost-based query optimization for XLOG's relational IR.
4//! It uses GPU-resident statistics from [`xlog_stats::StatsManager`] to make
5//! informed decisions about:
6//!
7//! - **Predicate pushdown**: Moving filter predicates closer to base scans to
8//!   reduce intermediate result sizes early in the pipeline.
9//! - **Cost estimation**: Computing expected row counts, CPU costs, GPU memory
10//!   usage, and data transfer counts for plan nodes.
11//! - **Join ordering**: (Future) Reordering joins based on selectivity estimates
12//!   to minimize intermediate result sizes.
13//!
14//! # Usage
15//!
16//! ```ignore
17//! use std::sync::Arc;
18//! use xlog_logic::optimizer::{Optimizer, OptimizerConfig, PlanCost};
19//! use xlog_stats::StatsManager;
20//!
21//! let stats = Arc::new(StatsManager::new());
22//! let optimizer = Optimizer::new(stats);
23//!
24//! // Optimize a query plan
25//! let optimized_plan = optimizer.optimize(original_plan);
26//!
27//! // Get cost estimates
28//! let cost = optimizer.estimate_cost(&optimized_plan);
29//! println!("Estimated rows: {}, GPU memory: {} bytes", cost.rows, cost.gpu_mem);
30//! ```
31
32use std::collections::HashMap;
33use std::sync::Arc;
34use xlog_core::{RelId, Schema};
35use xlog_ir::{CompareOp, Expr, JoinType, RirNode};
36use xlog_stats::StatsManager;
37
38/// Configuration for query optimization.
39///
40/// Controls optimizer behavior including thresholds for algorithm selection
41/// and feature toggles.
42#[derive(Debug, Clone)]
43#[non_exhaustive]
44pub struct OptimizerConfig {
45    /// Maximum number of relations for exhaustive dynamic programming.
46    ///
47    /// When a query involves more relations than this threshold, the optimizer
48    /// switches to a greedy algorithm for join ordering to avoid exponential
49    /// time complexity. Default: 10 relations.
50    pub dp_threshold: usize,
51
52    /// Heat threshold for recommending index creation.
53    ///
54    /// Relations with access heat above this threshold are candidates for
55    /// index building to accelerate future queries. Default: 0.7.
56    pub index_heat_threshold: f32,
57
58    /// Enable predicate pushdown optimization.
59    ///
60    /// When enabled, filter predicates are pushed down through projections
61    /// and joins to be applied as early as possible. Default: true.
62    pub enable_pushdown: bool,
63
64    /// Default selectivity for filters when no statistics are available.
65    ///
66    /// Used as a fallback when column statistics cannot provide a better
67    /// estimate. Default: 0.1 (10% selectivity).
68    pub default_filter_selectivity: f64,
69
70    /// Cost multiplier for GPU-to-host data transfers.
71    ///
72    /// Transfers are expensive operations; this multiplier reflects the
73    /// relative cost compared to local GPU operations. Default: 100.0.
74    pub transfer_cost_multiplier: f64,
75
76    /// Bytes per row used for GPU memory estimation when schema is unknown.
77    ///
78    /// Default: 32 bytes (assumes 4 columns at 8 bytes each on average).
79    pub default_bytes_per_row: u64,
80}
81
82impl Default for OptimizerConfig {
83    fn default() -> Self {
84        Self {
85            dp_threshold: 10,
86            index_heat_threshold: 0.7,
87            enable_pushdown: true,
88            default_filter_selectivity: 0.1,
89            transfer_cost_multiplier: 100.0,
90            default_bytes_per_row: 32,
91        }
92    }
93}
94
95/// Cost estimate for a query plan node.
96///
97/// Captures the multi-dimensional cost of executing a plan node, enabling
98/// the optimizer to make informed decisions based on available resources.
99#[derive(Debug, Clone, Default, PartialEq)]
100pub struct PlanCost {
101    /// Estimated number of output rows.
102    pub rows: u64,
103
104    /// Estimated CPU cost (arbitrary units, relative comparisons only).
105    ///
106    /// This represents processing overhead that cannot be parallelized on
107    /// the GPU, such as coordination, scheduling, and result materialization.
108    pub cpu_cost: f64,
109
110    /// Estimated GPU memory usage in bytes.
111    ///
112    /// Includes both input buffers and intermediate storage required for
113    /// the operation.
114    pub gpu_mem: u64,
115
116    /// Number of GPU-to-host or host-to-GPU data transfers.
117    ///
118    /// Transfers are typically the most expensive operations in GPU computing
119    /// and should be minimized.
120    pub transfers: u32,
121}
122
123impl PlanCost {
124    /// Creates a new cost estimate with specified row count.
125    pub fn with_rows(rows: u64) -> Self {
126        Self {
127            rows,
128            ..Default::default()
129        }
130    }
131
132    /// Computes a scalar cost value for comparison purposes.
133    ///
134    /// The formula weights different cost components:
135    /// - CPU cost is taken directly
136    /// - GPU memory is scaled by 0.001 (1GB = 1M cost units)
137    /// - Transfers are heavily weighted due to their high latency
138    ///
139    /// # Arguments
140    ///
141    /// * `transfer_weight` - Weight multiplier for transfer costs
142    pub fn total_cost(&self, transfer_weight: f64) -> f64 {
143        self.cpu_cost + (self.gpu_mem as f64 * 0.001) + (self.transfers as f64 * transfer_weight)
144    }
145
146    /// Combines two costs representing sequential operations.
147    ///
148    /// Row count comes from the second (later) operation; other costs are summed.
149    pub fn then(self, other: PlanCost) -> PlanCost {
150        PlanCost {
151            rows: other.rows,
152            cpu_cost: self.cpu_cost + other.cpu_cost,
153            gpu_mem: self.gpu_mem.max(other.gpu_mem), // Peak memory usage
154            transfers: self.transfers + other.transfers,
155        }
156    }
157}
158
159/// Query optimizer using statistics for cost-based decisions.
160///
161/// The optimizer transforms query plans to improve execution efficiency
162/// by applying rewrites like predicate pushdown and using statistics to
163/// estimate costs for different plan alternatives.
164pub struct Optimizer {
165    stats: Arc<StatsManager>,
166    config: OptimizerConfig,
167    /// Schemas for relations, keyed by RelId
168    schemas: HashMap<RelId, Schema>,
169}
170
171impl Optimizer {
172    /// Creates a new optimizer with default configuration.
173    ///
174    /// # Arguments
175    ///
176    /// * `stats` - Shared statistics manager for cardinality and selectivity estimates
177    pub fn new(stats: Arc<StatsManager>) -> Self {
178        Self {
179            stats,
180            config: OptimizerConfig::default(),
181            schemas: HashMap::new(),
182        }
183    }
184
185    /// Creates a new optimizer with custom configuration.
186    ///
187    /// # Arguments
188    ///
189    /// * `stats` - Shared statistics manager
190    /// * `config` - Custom optimizer configuration
191    pub fn with_config(stats: Arc<StatsManager>, config: OptimizerConfig) -> Self {
192        Self {
193            stats,
194            config,
195            schemas: HashMap::new(),
196        }
197    }
198
199    /// Sets the schemas for relations.
200    ///
201    /// This information is used by the optimizer to accurately determine
202    /// column widths during predicate pushdown.
203    pub fn set_schemas(&mut self, schemas: HashMap<RelId, Schema>) {
204        self.schemas = schemas;
205    }
206
207    /// Returns a reference to the current configuration.
208    pub fn config(&self) -> &OptimizerConfig {
209        &self.config
210    }
211
212    /// Returns a reference to the statistics manager.
213    pub fn stats(&self) -> &Arc<StatsManager> {
214        &self.stats
215    }
216
217    /// Optimizes an execution plan by applying transformation rules.
218    ///
219    /// Currently applies:
220    /// - Predicate pushdown (if enabled)
221    ///
222    /// Future optimizations may include:
223    /// - Join reordering based on cardinality estimates
224    /// - Projection pushdown
225    /// - Common subexpression elimination
226    ///
227    /// # Arguments
228    ///
229    /// * `node` - The plan to optimize
230    ///
231    /// # Returns
232    ///
233    /// An optimized plan that is semantically equivalent to the input
234    pub fn optimize(&self, node: RirNode) -> RirNode {
235        if self.config.enable_pushdown {
236            self.predicate_pushdown(node)
237        } else {
238            node
239        }
240    }
241
242    /// Pushes filter predicates closer to scan nodes.
243    ///
244    /// This transformation reduces intermediate result sizes by applying
245    /// filters as early as possible in the query pipeline. The rules are:
246    ///
247    /// - Filters can be pushed through projections (with column remapping)
248    /// - Filters can be pushed into one or both sides of a join if the
249    ///   predicate references only columns from that side
250    /// - Filters on join keys can inform join selectivity estimates
251    ///
252    /// # Arguments
253    ///
254    /// * `node` - The plan node to transform
255    ///
256    /// # Returns
257    ///
258    /// The transformed plan with predicates pushed down where beneficial
259    fn predicate_pushdown(&self, node: RirNode) -> RirNode {
260        match node {
261            // Base case: scan nodes cannot be transformed further
262            RirNode::Unit => RirNode::Unit,
263            RirNode::Scan { rel } => RirNode::Scan { rel },
264
265            // Filter on top of another node: try to push down
266            RirNode::Filter { input, predicate } => {
267                // First, recursively optimize the input
268                let optimized_input = self.predicate_pushdown(*input);
269
270                match optimized_input {
271                    // Filter on Filter: merge predicates
272                    RirNode::Filter {
273                        input: inner_input,
274                        predicate: inner_pred,
275                    } => {
276                        let merged = Expr::And(vec![inner_pred, predicate]);
277                        RirNode::Filter {
278                            input: inner_input,
279                            predicate: merged,
280                        }
281                    }
282
283                    // Filter on Project: push through if possible
284                    RirNode::Project {
285                        input: proj_input,
286                        columns,
287                    } => {
288                        // Check if predicate only references pass-through columns
289                        if let Some(remapped) =
290                            self.remap_predicate_through_project(&predicate, &columns)
291                        {
292                            // Push the remapped predicate below the projection
293                            RirNode::Project {
294                                input: Box::new(RirNode::Filter {
295                                    input: proj_input,
296                                    predicate: remapped,
297                                }),
298                                columns,
299                            }
300                        } else {
301                            // Cannot push: keep filter above
302                            RirNode::Filter {
303                                input: Box::new(RirNode::Project {
304                                    input: proj_input,
305                                    columns,
306                                }),
307                                predicate,
308                            }
309                        }
310                    }
311
312                    // Filter on Join: try to push to appropriate side
313                    RirNode::Join {
314                        left,
315                        right,
316                        left_keys,
317                        right_keys,
318                        join_type,
319                    } => {
320                        let left_width = self.estimate_width(&left);
321                        let (left_preds, right_preds, remaining) =
322                            self.split_predicate_for_join(&predicate, left_width);
323
324                        // Apply pushed predicates to each side
325                        let new_left = if !left_preds.is_empty() {
326                            Box::new(RirNode::Filter {
327                                input: left,
328                                predicate: Self::conjoin(left_preds),
329                            })
330                        } else {
331                            left
332                        };
333
334                        let new_right = if !right_preds.is_empty() {
335                            Box::new(RirNode::Filter {
336                                input: right,
337                                predicate: Self::conjoin(right_preds),
338                            })
339                        } else {
340                            right
341                        };
342
343                        let join_node = RirNode::Join {
344                            left: new_left,
345                            right: new_right,
346                            left_keys,
347                            right_keys,
348                            join_type,
349                        };
350
351                        // Apply remaining predicates that couldn't be pushed
352                        if !remaining.is_empty() {
353                            RirNode::Filter {
354                                input: Box::new(join_node),
355                                predicate: Self::conjoin(remaining),
356                            }
357                        } else {
358                            join_node
359                        }
360                    }
361
362                    // Default: cannot push further
363                    other => RirNode::Filter {
364                        input: Box::new(other),
365                        predicate,
366                    },
367                }
368            }
369
370            // Project: unwrap consecutive projections before visiting the base.
371            // Function expansion can produce one projection per generated
372            // binding, so recursing through this unary chain can exhaust the
373            // native stack at the supported expansion-depth boundary.
374            RirNode::Project { input, columns } => {
375                let mut projections = vec![columns];
376                let mut base = *input;
377                while let RirNode::Project { input, columns } = base {
378                    projections.push(columns);
379                    base = *input;
380                }
381
382                let mut optimized = self.predicate_pushdown(base);
383                for columns in projections.into_iter().rev() {
384                    optimized = RirNode::Project {
385                        input: Box::new(optimized),
386                        columns,
387                    };
388                }
389                optimized
390            }
391
392            // Join: recursively optimize both sides
393            RirNode::Join {
394                left,
395                right,
396                left_keys,
397                right_keys,
398                join_type,
399            } => RirNode::Join {
400                left: Box::new(self.predicate_pushdown(*left)),
401                right: Box::new(self.predicate_pushdown(*right)),
402                left_keys,
403                right_keys,
404                join_type,
405            },
406
407            // GroupBy: recursively optimize input
408            RirNode::GroupBy {
409                input,
410                key_cols,
411                aggs,
412            } => RirNode::GroupBy {
413                input: Box::new(self.predicate_pushdown(*input)),
414                key_cols,
415                aggs,
416            },
417
418            // Union: recursively optimize all inputs
419            RirNode::Union { inputs } => RirNode::Union {
420                inputs: inputs
421                    .into_iter()
422                    .map(|i| self.predicate_pushdown(i))
423                    .collect(),
424            },
425
426            // Distinct: recursively optimize input
427            RirNode::Distinct { input, key_cols } => RirNode::Distinct {
428                input: Box::new(self.predicate_pushdown(*input)),
429                key_cols,
430            },
431
432            // Diff: recursively optimize both sides
433            RirNode::Diff { left, right } => RirNode::Diff {
434                left: Box::new(self.predicate_pushdown(*left)),
435                right: Box::new(self.predicate_pushdown(*right)),
436            },
437
438            // Fixpoint: recursively optimize base and recursive parts
439            RirNode::Fixpoint {
440                scc_id,
441                base,
442                recursive,
443                delta_rel,
444                full_rel,
445            } => RirNode::Fixpoint {
446                scc_id,
447                base: Box::new(self.predicate_pushdown(*base)),
448                recursive: Box::new(self.predicate_pushdown(*recursive)),
449                delta_rel,
450                full_rel,
451            },
452
453            RirNode::TensorMaskedJoin { .. } => node, // Leaf-like: no pushdown
454
455            // Promoted physical-shape nodes are produced after the
456            // optimizer runs. Required for compile safety and as a
457            // no-op fallback if the call order ever changes.
458            RirNode::MultiWayJoin { .. } | RirNode::ChainJoin { .. } => node,
459        }
460    }
461
462    /// Attempts to remap a predicate through a projection.
463    ///
464    /// Returns `Some(remapped_predicate)` if all column references in the
465    /// predicate can be traced back through pass-through columns.
466    /// Returns `None` if the predicate references computed columns.
467    fn remap_predicate_through_project(
468        &self,
469        predicate: &Expr,
470        columns: &[xlog_ir::ProjectExpr],
471    ) -> Option<Expr> {
472        // Build a mapping from output column index to input column index
473        // Only for pass-through columns
474        let mut output_to_input: std::collections::HashMap<usize, usize> =
475            std::collections::HashMap::new();
476
477        for (out_idx, proj_expr) in columns.iter().enumerate() {
478            if let xlog_ir::ProjectExpr::Column(in_idx) = proj_expr {
479                output_to_input.insert(out_idx, *in_idx);
480            }
481        }
482
483        self.remap_expr(predicate, &output_to_input)
484    }
485
486    /// Recursively remaps column references in an expression.
487    fn remap_expr(
488        &self,
489        expr: &Expr,
490        mapping: &std::collections::HashMap<usize, usize>,
491    ) -> Option<Expr> {
492        match expr {
493            Expr::Column(idx) => mapping.get(idx).map(|&new_idx| Expr::Column(new_idx)),
494
495            Expr::Const(val) => Some(Expr::Const(val.clone())),
496
497            Expr::Compare { left, op, right } => {
498                let new_left = self.remap_expr(left, mapping)?;
499                let new_right = self.remap_expr(right, mapping)?;
500                Some(Expr::Compare {
501                    left: Box::new(new_left),
502                    op: *op,
503                    right: Box::new(new_right),
504                })
505            }
506
507            Expr::And(exprs) => {
508                let remapped: Option<Vec<_>> =
509                    exprs.iter().map(|e| self.remap_expr(e, mapping)).collect();
510                remapped.map(Expr::And)
511            }
512
513            Expr::Or(exprs) => {
514                let remapped: Option<Vec<_>> =
515                    exprs.iter().map(|e| self.remap_expr(e, mapping)).collect();
516                remapped.map(Expr::Or)
517            }
518
519            Expr::Not(inner) => {
520                let remapped = self.remap_expr(inner, mapping)?;
521                Some(Expr::Not(Box::new(remapped)))
522            }
523
524            // Arithmetic operations
525            Expr::Add(l, r) => {
526                let new_l = self.remap_expr(l, mapping)?;
527                let new_r = self.remap_expr(r, mapping)?;
528                Some(Expr::Add(Box::new(new_l), Box::new(new_r)))
529            }
530            Expr::Sub(l, r) => {
531                let new_l = self.remap_expr(l, mapping)?;
532                let new_r = self.remap_expr(r, mapping)?;
533                Some(Expr::Sub(Box::new(new_l), Box::new(new_r)))
534            }
535            Expr::Mul(l, r) => {
536                let new_l = self.remap_expr(l, mapping)?;
537                let new_r = self.remap_expr(r, mapping)?;
538                Some(Expr::Mul(Box::new(new_l), Box::new(new_r)))
539            }
540            Expr::Div(l, r) => {
541                let new_l = self.remap_expr(l, mapping)?;
542                let new_r = self.remap_expr(r, mapping)?;
543                Some(Expr::Div(Box::new(new_l), Box::new(new_r)))
544            }
545            Expr::Mod(l, r) => {
546                let new_l = self.remap_expr(l, mapping)?;
547                let new_r = self.remap_expr(r, mapping)?;
548                Some(Expr::Mod(Box::new(new_l), Box::new(new_r)))
549            }
550
551            // Built-in functions
552            Expr::Abs(inner) => {
553                let remapped = self.remap_expr(inner, mapping)?;
554                Some(Expr::Abs(Box::new(remapped)))
555            }
556            Expr::Min(l, r) => {
557                let new_l = self.remap_expr(l, mapping)?;
558                let new_r = self.remap_expr(r, mapping)?;
559                Some(Expr::Min(Box::new(new_l), Box::new(new_r)))
560            }
561            Expr::Max(l, r) => {
562                let new_l = self.remap_expr(l, mapping)?;
563                let new_r = self.remap_expr(r, mapping)?;
564                Some(Expr::Max(Box::new(new_l), Box::new(new_r)))
565            }
566            Expr::Pow(l, r) => {
567                let new_l = self.remap_expr(l, mapping)?;
568                let new_r = self.remap_expr(r, mapping)?;
569                Some(Expr::Pow(Box::new(new_l), Box::new(new_r)))
570            }
571            Expr::Cast(inner, scalar_type) => {
572                let remapped = self.remap_expr(inner, mapping)?;
573                Some(Expr::Cast(Box::new(remapped), *scalar_type))
574            }
575            Expr::Conditional {
576                condition,
577                then_expr,
578                else_expr,
579            } => {
580                let new_condition = self.remap_expr(condition, mapping)?;
581                let new_then = self.remap_expr(then_expr, mapping)?;
582                let new_else = self.remap_expr(else_expr, mapping)?;
583                Some(Expr::Conditional {
584                    condition: Box::new(new_condition),
585                    then_expr: Box::new(new_then),
586                    else_expr: Box::new(new_else),
587                })
588            }
589        }
590    }
591
592    /// Estimates the output width (number of columns) of a plan node.
593    fn estimate_width(&self, node: &RirNode) -> usize {
594        match node {
595            RirNode::Unit => 0,
596            RirNode::Scan { rel } => {
597                // Use schema if available, otherwise stats, otherwise default
598                if let Some(schema) = self.schemas.get(rel) {
599                    schema.arity()
600                } else if let Some(stats) = self.stats.get_relation_stats(*rel) {
601                    stats.column_stats.len().max(1)
602                } else {
603                    4 // Default assumption
604                }
605            }
606            RirNode::Filter { input, .. } => self.estimate_width(input),
607            RirNode::Project { columns, .. } => columns.len(),
608            RirNode::Join { left, right, .. } => {
609                self.estimate_width(left) + self.estimate_width(right)
610            }
611            RirNode::ChainJoin { output_columns, .. } => output_columns.len(),
612            RirNode::GroupBy { key_cols, aggs, .. } => key_cols.len() + aggs.len(),
613            RirNode::Union { inputs } => {
614                inputs.first().map(|i| self.estimate_width(i)).unwrap_or(0)
615            }
616            RirNode::Distinct { input, .. } => self.estimate_width(input),
617            RirNode::Diff { left, .. } => self.estimate_width(left),
618            RirNode::Fixpoint { base, .. } => self.estimate_width(base),
619            // TensorMaskedJoin schemas are keyed by RelId.
620            // Use head_rel_id (not head_rel_name) for lookup.
621            RirNode::TensorMaskedJoin { head_rel_id, .. } => self
622                .schemas
623                .get(head_rel_id)
624                .map(|s| s.arity())
625                .unwrap_or(2),
626            // MultiWayJoin is produced after promotion; width equals the
627            // head projection arity, mirroring the Project arm.
628            RirNode::MultiWayJoin { output_columns, .. } => output_columns.len(),
629        }
630    }
631
632    /// Splits a predicate into parts pushable to left, right, or neither side of a join.
633    ///
634    /// Returns (left_predicates, right_predicates, remaining_predicates).
635    fn split_predicate_for_join(
636        &self,
637        predicate: &Expr,
638        left_width: usize,
639    ) -> (Vec<Expr>, Vec<Expr>, Vec<Expr>) {
640        let mut left_preds = Vec::new();
641        let mut right_preds = Vec::new();
642        let mut remaining = Vec::new();
643
644        // Flatten AND expressions
645        let conjuncts = Self::flatten_and(predicate);
646
647        for conj in conjuncts {
648            let cols = Self::collect_columns(&conj);
649            let max_col = cols.iter().copied().max().unwrap_or(0);
650            let min_col = cols.iter().copied().min().unwrap_or(0);
651
652            if cols.is_empty() {
653                // No columns referenced, can push to either side
654                left_preds.push(conj);
655            } else if max_col < left_width {
656                // All columns from left side
657                left_preds.push(conj);
658            } else if min_col >= left_width {
659                // All columns from right side - need to remap
660                let remapped = Self::remap_columns(&conj, |c| c - left_width);
661                right_preds.push(remapped);
662            } else {
663                // References both sides, cannot push
664                remaining.push(conj);
665            }
666        }
667
668        (left_preds, right_preds, remaining)
669    }
670
671    /// Flattens nested AND expressions into a list of conjuncts.
672    fn flatten_and(expr: &Expr) -> Vec<Expr> {
673        match expr {
674            Expr::And(exprs) => exprs.iter().flat_map(Self::flatten_and).collect(),
675            other => vec![other.clone()],
676        }
677    }
678
679    /// Collects all column indices referenced in an expression.
680    fn collect_columns(expr: &Expr) -> Vec<usize> {
681        match expr {
682            Expr::Column(idx) => vec![*idx],
683            Expr::Const(_) => vec![],
684            Expr::Compare { left, right, .. } => {
685                let mut cols = Self::collect_columns(left);
686                cols.extend(Self::collect_columns(right));
687                cols
688            }
689            Expr::And(exprs) | Expr::Or(exprs) => {
690                exprs.iter().flat_map(Self::collect_columns).collect()
691            }
692            Expr::Not(inner) | Expr::Abs(inner) | Expr::Cast(inner, _) => {
693                Self::collect_columns(inner)
694            }
695            Expr::Add(l, r)
696            | Expr::Sub(l, r)
697            | Expr::Mul(l, r)
698            | Expr::Div(l, r)
699            | Expr::Mod(l, r)
700            | Expr::Min(l, r)
701            | Expr::Max(l, r)
702            | Expr::Pow(l, r) => {
703                let mut cols = Self::collect_columns(l);
704                cols.extend(Self::collect_columns(r));
705                cols
706            }
707            Expr::Conditional {
708                condition,
709                then_expr,
710                else_expr,
711            } => {
712                let mut cols = Self::collect_columns(condition);
713                cols.extend(Self::collect_columns(then_expr));
714                cols.extend(Self::collect_columns(else_expr));
715                cols
716            }
717        }
718    }
719
720    /// Remaps column references in an expression using a transformation function.
721    fn remap_columns<F: Fn(usize) -> usize + Copy>(expr: &Expr, f: F) -> Expr {
722        match expr {
723            Expr::Column(idx) => Expr::Column(f(*idx)),
724            Expr::Const(v) => Expr::Const(v.clone()),
725            Expr::Compare { left, op, right } => Expr::Compare {
726                left: Box::new(Self::remap_columns(left, f)),
727                op: *op,
728                right: Box::new(Self::remap_columns(right, f)),
729            },
730            Expr::And(exprs) => {
731                Expr::And(exprs.iter().map(|e| Self::remap_columns(e, f)).collect())
732            }
733            Expr::Or(exprs) => Expr::Or(exprs.iter().map(|e| Self::remap_columns(e, f)).collect()),
734            Expr::Not(inner) => Expr::Not(Box::new(Self::remap_columns(inner, f))),
735            Expr::Add(l, r) => Expr::Add(
736                Box::new(Self::remap_columns(l, f)),
737                Box::new(Self::remap_columns(r, f)),
738            ),
739            Expr::Sub(l, r) => Expr::Sub(
740                Box::new(Self::remap_columns(l, f)),
741                Box::new(Self::remap_columns(r, f)),
742            ),
743            Expr::Mul(l, r) => Expr::Mul(
744                Box::new(Self::remap_columns(l, f)),
745                Box::new(Self::remap_columns(r, f)),
746            ),
747            Expr::Div(l, r) => Expr::Div(
748                Box::new(Self::remap_columns(l, f)),
749                Box::new(Self::remap_columns(r, f)),
750            ),
751            Expr::Mod(l, r) => Expr::Mod(
752                Box::new(Self::remap_columns(l, f)),
753                Box::new(Self::remap_columns(r, f)),
754            ),
755            Expr::Abs(inner) => Expr::Abs(Box::new(Self::remap_columns(inner, f))),
756            Expr::Min(l, r) => Expr::Min(
757                Box::new(Self::remap_columns(l, f)),
758                Box::new(Self::remap_columns(r, f)),
759            ),
760            Expr::Max(l, r) => Expr::Max(
761                Box::new(Self::remap_columns(l, f)),
762                Box::new(Self::remap_columns(r, f)),
763            ),
764            Expr::Pow(l, r) => Expr::Pow(
765                Box::new(Self::remap_columns(l, f)),
766                Box::new(Self::remap_columns(r, f)),
767            ),
768            Expr::Cast(inner, t) => Expr::Cast(Box::new(Self::remap_columns(inner, f)), *t),
769            Expr::Conditional {
770                condition,
771                then_expr,
772                else_expr,
773            } => Expr::Conditional {
774                condition: Box::new(Self::remap_columns(condition, f)),
775                then_expr: Box::new(Self::remap_columns(then_expr, f)),
776                else_expr: Box::new(Self::remap_columns(else_expr, f)),
777            },
778        }
779    }
780
781    /// Combines a list of predicates into a single AND expression.
782    fn conjoin(predicates: Vec<Expr>) -> Expr {
783        debug_assert!(!predicates.is_empty());
784        if predicates.len() == 1 {
785            predicates.into_iter().next().unwrap()
786        } else {
787            Expr::And(predicates)
788        }
789    }
790
791    /// Estimates the cost of executing a plan node.
792    ///
793    /// Recursively computes cost estimates for the entire plan tree,
794    /// using statistics when available and falling back to heuristics.
795    ///
796    /// # Arguments
797    ///
798    /// * `node` - The plan node to estimate
799    ///
800    /// # Returns
801    ///
802    /// A [`PlanCost`] with estimated rows, CPU cost, GPU memory, and transfers
803    pub fn estimate_cost(&self, node: &RirNode) -> PlanCost {
804        match node {
805            RirNode::Unit => PlanCost {
806                rows: 1,
807                cpu_cost: 0.0,
808                gpu_mem: 0,
809                transfers: 0,
810            },
811            RirNode::Scan { rel } => self.estimate_scan_cost(*rel),
812
813            RirNode::Filter { input, predicate } => {
814                let input_cost = self.estimate_cost(input);
815                self.estimate_filter_cost(input_cost, predicate, input)
816            }
817
818            RirNode::Project { input, columns } => {
819                let input_cost = self.estimate_cost(input);
820                self.estimate_project_cost(input_cost, columns)
821            }
822
823            RirNode::Join {
824                left,
825                right,
826                left_keys,
827                right_keys,
828                join_type,
829            } => {
830                let left_cost = self.estimate_cost(left);
831                let right_cost = self.estimate_cost(right);
832                self.estimate_join_cost(
833                    left_cost, right_cost, left, right, left_keys, right_keys, *join_type,
834                )
835            }
836
837            RirNode::ChainJoin {
838                left,
839                right,
840                left_key,
841                right_key,
842                output_columns,
843                ..
844            } => {
845                let left_cost = self.estimate_cost(left);
846                let right_cost = self.estimate_cost(right);
847                let join_cost = self.estimate_join_cost(
848                    left_cost,
849                    right_cost,
850                    left,
851                    right,
852                    &[*left_key],
853                    &[*right_key],
854                    JoinType::Inner,
855                );
856                self.estimate_project_cost(join_cost, output_columns)
857            }
858
859            RirNode::GroupBy {
860                input,
861                key_cols,
862                aggs,
863            } => {
864                let input_cost = self.estimate_cost(input);
865                self.estimate_groupby_cost(input_cost, key_cols, aggs)
866            }
867
868            RirNode::Union { inputs } => {
869                let costs: Vec<_> = inputs.iter().map(|i| self.estimate_cost(i)).collect();
870                self.estimate_union_cost(costs)
871            }
872
873            RirNode::Distinct { input, key_cols } => {
874                let input_cost = self.estimate_cost(input);
875                self.estimate_distinct_cost(input_cost, key_cols)
876            }
877
878            RirNode::Diff { left, right } => {
879                let left_cost = self.estimate_cost(left);
880                let right_cost = self.estimate_cost(right);
881                self.estimate_diff_cost(left_cost, right_cost)
882            }
883
884            RirNode::Fixpoint {
885                base, recursive, ..
886            } => {
887                let base_cost = self.estimate_cost(base);
888                let recursive_cost = self.estimate_cost(recursive);
889                self.estimate_fixpoint_cost(base_cost, recursive_cost)
890            }
891
892            RirNode::TensorMaskedJoin {
893                max_active_rules, ..
894            } => PlanCost {
895                rows: *max_active_rules as u64,
896                cpu_cost: *max_active_rules as f64 * 100.0,
897                gpu_mem: *max_active_rules as u64 * 1024,
898                transfers: 1,
899            },
900            // MultiWayJoin heuristic cost is the sum of input scan costs.
901            // Post-promoter dispatch decides whether to run the WCOJ kernel
902            // or fall back; full multiway cost-model integration is separate
903            // planner work.
904            RirNode::MultiWayJoin { inputs, .. } => {
905                let mut total = PlanCost::default();
906                for inp in inputs {
907                    let c = self.estimate_cost(inp);
908                    total.rows = total.rows.saturating_add(c.rows);
909                    total.cpu_cost += c.cpu_cost;
910                    total.gpu_mem = total.gpu_mem.saturating_add(c.gpu_mem);
911                    total.transfers = total.transfers.saturating_add(c.transfers);
912                }
913                total
914            }
915        }
916    }
917
918    /// Estimates cost for a base relation scan.
919    fn estimate_scan_cost(&self, rel: RelId) -> PlanCost {
920        if let Some(stats) = self.stats.get_relation_stats(rel) {
921            PlanCost {
922                rows: stats.cardinality,
923                cpu_cost: stats.cardinality as f64 * 0.01, // Minimal per-row CPU cost
924                gpu_mem: stats
925                    .byte_size
926                    .max(stats.cardinality * self.config.default_bytes_per_row),
927                transfers: 0, // Data already on GPU
928            }
929        } else {
930            // Default estimates for unknown relations
931            let default_rows = 1000;
932            PlanCost {
933                rows: default_rows,
934                cpu_cost: default_rows as f64 * 0.01,
935                gpu_mem: default_rows * self.config.default_bytes_per_row,
936                transfers: 0,
937            }
938        }
939    }
940
941    /// Estimates cost for a filter operation.
942    fn estimate_filter_cost(
943        &self,
944        input_cost: PlanCost,
945        predicate: &Expr,
946        input: &RirNode,
947    ) -> PlanCost {
948        let selectivity = self.estimate_predicate_selectivity(predicate, input);
949        let output_rows = ((input_cost.rows as f64 * selectivity) as u64).max(1);
950
951        PlanCost {
952            rows: output_rows,
953            cpu_cost: input_cost.cpu_cost + input_cost.rows as f64 * 0.02, // Predicate eval cost
954            gpu_mem: input_cost.gpu_mem, // Filter reuses input memory
955            transfers: input_cost.transfers,
956        }
957    }
958
959    /// Estimates cost for a projection operation.
960    fn estimate_project_cost(
961        &self,
962        input_cost: PlanCost,
963        columns: &[xlog_ir::ProjectExpr],
964    ) -> PlanCost {
965        // Count computed vs pass-through columns
966        let computed_count = columns
967            .iter()
968            .filter(|c| matches!(c, xlog_ir::ProjectExpr::Computed(_, _)))
969            .count();
970
971        // Computed columns add CPU cost
972        let compute_cost = computed_count as f64 * input_cost.rows as f64 * 0.05;
973
974        // Output size may be smaller if fewer columns
975        let output_width_ratio = columns.len() as f64 / (columns.len() + 2) as f64; // Rough estimate
976
977        PlanCost {
978            rows: input_cost.rows,
979            cpu_cost: input_cost.cpu_cost + compute_cost,
980            gpu_mem: (input_cost.gpu_mem as f64 * output_width_ratio) as u64,
981            transfers: input_cost.transfers,
982        }
983    }
984
985    /// Estimates cost for a join operation.
986    #[expect(
987        clippy::too_many_arguments,
988        reason = "join cost estimation consumes both child costs, both plans, both key sets, and the join semantics as distinct inputs"
989    )]
990    fn estimate_join_cost(
991        &self,
992        left_cost: PlanCost,
993        right_cost: PlanCost,
994        left: &RirNode,
995        right: &RirNode,
996        left_keys: &[usize],
997        right_keys: &[usize],
998        join_type: JoinType,
999    ) -> PlanCost {
1000        // Semi and Anti joins always produce at most left_cost.rows
1001        // Handle these specially before checking stats
1002        let output_rows = match join_type {
1003            JoinType::Semi => {
1004                // At most left side rows, estimate 50% match
1005                ((left_cost.rows as f64 * 0.5) as u64).max(1)
1006            }
1007            JoinType::Anti => {
1008                // At most left side rows, estimate 50% don't match
1009                ((left_cost.rows as f64 * 0.5) as u64).max(1)
1010            }
1011            JoinType::Inner | JoinType::LeftOuter => {
1012                // Get relation IDs for selectivity lookup
1013                let left_rels = left.referenced_relations();
1014                let right_rels = right.referenced_relations();
1015
1016                if left_rels.len() == 1 && right_rels.len() == 1 {
1017                    // Simple join between two base relations
1018                    let estimated = self.stats.estimate_join_cardinality(
1019                        left_rels[0],
1020                        right_rels[0],
1021                        left_keys,
1022                        right_keys,
1023                    );
1024
1025                    match join_type {
1026                        JoinType::LeftOuter => estimated.max(left_cost.rows),
1027                        _ => estimated,
1028                    }
1029                } else {
1030                    // Multi-way or complex join: use heuristic
1031                    match join_type {
1032                        JoinType::Inner => {
1033                            // Assume 10% selectivity for inner joins
1034                            ((left_cost.rows as f64 * right_cost.rows as f64 * 0.1) as u64).max(1)
1035                        }
1036                        JoinType::LeftOuter => {
1037                            // At least left side rows
1038                            left_cost.rows.max(
1039                                ((left_cost.rows as f64 * right_cost.rows as f64 * 0.1) as u64)
1040                                    .max(1),
1041                            )
1042                        }
1043                        _ => unreachable!(),
1044                    }
1045                }
1046            }
1047        };
1048
1049        // Join CPU cost: hash build + probe
1050        let build_cost = right_cost.rows as f64 * 1.0; // Build hash table
1051        let probe_cost = left_cost.rows as f64 * 0.5; // Probe operations
1052        let cpu_cost = left_cost.cpu_cost + right_cost.cpu_cost + build_cost + probe_cost;
1053
1054        // GPU memory: both inputs plus hash table overhead
1055        let hash_table_overhead = right_cost.gpu_mem / 2; // Approximate hash table size
1056        let gpu_mem = left_cost.gpu_mem + right_cost.gpu_mem + hash_table_overhead;
1057
1058        PlanCost {
1059            rows: output_rows,
1060            cpu_cost,
1061            gpu_mem,
1062            transfers: left_cost.transfers + right_cost.transfers,
1063        }
1064    }
1065
1066    /// Estimates cost for a group-by with aggregation.
1067    fn estimate_groupby_cost(
1068        &self,
1069        input_cost: PlanCost,
1070        key_cols: &[usize],
1071        _aggs: &[(usize, xlog_core::AggOp)],
1072    ) -> PlanCost {
1073        // Estimate distinct groups based on key columns
1074        // Heuristic: sqrt(input_rows) for unknown cardinality
1075        let estimated_groups = if key_cols.is_empty() {
1076            1 // No grouping = single result
1077        } else {
1078            // Rough estimate: assume good reduction
1079            ((input_cost.rows as f64).sqrt() as u64).max(1)
1080        };
1081
1082        PlanCost {
1083            rows: estimated_groups,
1084            cpu_cost: input_cost.cpu_cost + input_cost.rows as f64 * 0.5, // Aggregation cost
1085            gpu_mem: input_cost.gpu_mem + estimated_groups * self.config.default_bytes_per_row,
1086            transfers: input_cost.transfers,
1087        }
1088    }
1089
1090    /// Estimates cost for a union operation.
1091    fn estimate_union_cost(&self, input_costs: Vec<PlanCost>) -> PlanCost {
1092        let total_rows: u64 = input_costs.iter().map(|c| c.rows).sum();
1093        let total_cpu: f64 = input_costs.iter().map(|c| c.cpu_cost).sum();
1094        let max_gpu: u64 = input_costs.iter().map(|c| c.gpu_mem).max().unwrap_or(0);
1095        let total_transfers: u32 = input_costs.iter().map(|c| c.transfers).sum();
1096
1097        PlanCost {
1098            rows: total_rows,
1099            cpu_cost: total_cpu + total_rows as f64 * 0.01, // Concatenation cost
1100            gpu_mem: max_gpu,                               // Can process sequentially
1101            transfers: total_transfers,
1102        }
1103    }
1104
1105    /// Estimates cost for a distinct operation.
1106    fn estimate_distinct_cost(&self, input_cost: PlanCost, _key_cols: &[usize]) -> PlanCost {
1107        // Heuristic: distinct reduces rows by some factor
1108        let estimated_distinct = (input_cost.rows as f64 * 0.7) as u64;
1109
1110        PlanCost {
1111            rows: estimated_distinct.max(1),
1112            cpu_cost: input_cost.cpu_cost + input_cost.rows as f64 * 0.3, // Hash-based dedup
1113            gpu_mem: input_cost.gpu_mem + input_cost.rows * 8,            // Hash set overhead
1114            transfers: input_cost.transfers,
1115        }
1116    }
1117
1118    /// Estimates cost for a set difference operation.
1119    fn estimate_diff_cost(&self, left_cost: PlanCost, right_cost: PlanCost) -> PlanCost {
1120        // Diff removes matching rows from left
1121        let estimated_remaining = (left_cost.rows as f64 * 0.5) as u64;
1122
1123        PlanCost {
1124            rows: estimated_remaining.max(1),
1125            cpu_cost: left_cost.cpu_cost + right_cost.cpu_cost + right_cost.rows as f64 * 0.5,
1126            gpu_mem: left_cost.gpu_mem + right_cost.gpu_mem,
1127            transfers: left_cost.transfers + right_cost.transfers,
1128        }
1129    }
1130
1131    /// Estimates cost for a fixpoint (recursive) operation.
1132    fn estimate_fixpoint_cost(&self, base_cost: PlanCost, recursive_cost: PlanCost) -> PlanCost {
1133        // Fixpoint cost depends on number of iterations
1134        // Heuristic: assume log2(base_rows) iterations
1135        let estimated_iterations = ((base_cost.rows as f64).log2().ceil() as u64).max(1);
1136
1137        PlanCost {
1138            rows: base_cost.rows * estimated_iterations, // Output accumulates
1139            cpu_cost: base_cost.cpu_cost + recursive_cost.cpu_cost * estimated_iterations as f64,
1140            gpu_mem: (base_cost.gpu_mem + recursive_cost.gpu_mem) * 2, // Need delta and full
1141            transfers: base_cost.transfers + recursive_cost.transfers * estimated_iterations as u32,
1142        }
1143    }
1144
1145    /// Estimates selectivity of a predicate expression.
1146    fn estimate_predicate_selectivity(&self, predicate: &Expr, input: &RirNode) -> f64 {
1147        match predicate {
1148            Expr::Compare { left, op, right } => {
1149                self.estimate_compare_selectivity(left, *op, right, input)
1150            }
1151            Expr::And(exprs) => {
1152                // Multiply selectivities (independence assumption)
1153                exprs
1154                    .iter()
1155                    .map(|e| self.estimate_predicate_selectivity(e, input))
1156                    .product()
1157            }
1158            Expr::Or(exprs) => {
1159                // P(A or B) = P(A) + P(B) - P(A)P(B) for independent events
1160                // Simplified: max of selectivities as lower bound
1161                exprs
1162                    .iter()
1163                    .map(|e| self.estimate_predicate_selectivity(e, input))
1164                    .fold(0.0, f64::max)
1165            }
1166            Expr::Not(inner) => 1.0 - self.estimate_predicate_selectivity(inner, input),
1167            _ => self.config.default_filter_selectivity,
1168        }
1169    }
1170
1171    /// Estimates selectivity for a comparison predicate.
1172    fn estimate_compare_selectivity(
1173        &self,
1174        left: &Expr,
1175        op: CompareOp,
1176        right: &Expr,
1177        input: &RirNode,
1178    ) -> f64 {
1179        // Try to get column statistics if comparing column to constant
1180        if let (Expr::Column(col_idx), Expr::Const(_)) | (Expr::Const(_), Expr::Column(col_idx)) =
1181            (left, right)
1182        {
1183            // Find the base relation for this column
1184            if let Some(rel_id) = self.find_column_relation(input, *col_idx) {
1185                if let Some(stats) = self.stats.get_relation_stats(rel_id) {
1186                    if let Some(col_stats) = stats.get_column(*col_idx) {
1187                        return match op {
1188                            CompareOp::Eq => col_stats.equality_selectivity(stats.cardinality),
1189                            CompareOp::Ne => {
1190                                1.0 - col_stats.equality_selectivity(stats.cardinality)
1191                            }
1192                            CompareOp::Lt | CompareOp::Le | CompareOp::Gt | CompareOp::Ge => {
1193                                // Range predicates: estimate ~33% selectivity
1194                                0.33
1195                            }
1196                        };
1197                    }
1198                }
1199            }
1200        }
1201
1202        // Default selectivities by operator
1203        match op {
1204            CompareOp::Eq => 0.1, // 10% for equality
1205            CompareOp::Ne => 0.9, // 90% for inequality
1206            CompareOp::Lt | CompareOp::Le | CompareOp::Gt | CompareOp::Ge => 0.33, // 33% for ranges
1207        }
1208    }
1209
1210    /// Finds the base relation that provides a given column.
1211    fn find_column_relation(&self, node: &RirNode, col_idx: usize) -> Option<RelId> {
1212        match node {
1213            RirNode::Scan { rel } => Some(*rel),
1214            RirNode::Filter { input, .. } => self.find_column_relation(input, col_idx),
1215            RirNode::Project { input, columns } => {
1216                // Trace column through projection
1217                if col_idx < columns.len() {
1218                    if let xlog_ir::ProjectExpr::Column(src_idx) = &columns[col_idx] {
1219                        return self.find_column_relation(input, *src_idx);
1220                    }
1221                }
1222                None
1223            }
1224            RirNode::Join { left, right, .. } => {
1225                let left_width = self.estimate_width(left);
1226                if col_idx < left_width {
1227                    self.find_column_relation(left, col_idx)
1228                } else {
1229                    self.find_column_relation(right, col_idx - left_width)
1230                }
1231            }
1232            // MultiWayJoin has no stable column-to-input mapping here.
1233            // The promoter runs after the optimizer, so this arm is
1234            // unreachable in production. A half-mapped implementation that
1235            // walked `inputs` via `slot_vars` would be more dangerous than
1236            // returning None for this optimizer fallback.
1237            RirNode::MultiWayJoin { .. } => None,
1238            _ => None, // Complex cases: give up
1239        }
1240    }
1241
1242    /// Returns relations that should have indexes built based on access heat.
1243    ///
1244    /// This is useful for adaptive query processing where frequently accessed
1245    /// relations benefit from index structures.
1246    pub fn recommend_indexes(&self) -> Vec<RelId> {
1247        self.stats.hot_relations(self.config.index_heat_threshold)
1248    }
1249
1250    /// Returns true if the query involves more relations than the DP threshold.
1251    ///
1252    /// Used to decide between exhaustive and greedy join ordering algorithms.
1253    pub fn should_use_greedy(&self, node: &RirNode) -> bool {
1254        let rels = node.referenced_relations();
1255        let unique_rels: std::collections::HashSet<_> = rels.iter().collect();
1256        unique_rels.len() > self.config.dp_threshold
1257    }
1258}
1259
1260// Selectivity-aware optimizer pass.
1261//
1262// No-op by default for unrecognized shapes. This pass owns the
1263// selectivity-driven join reordering hook; broader planner work may add
1264// reordering logic that consults `stats` for more shapes.
1265//
1266// Walks `plan.rules_by_scc[*].body` and rewrites nodes in place. The default
1267// no-op preserves every existing plan tree byte-for-byte. Tests assert
1268// structural equality for triangle, 4-cycle, and recursive-SCC plans.
1269//
1270// Compile-pipeline ordering: runs between `Optimizer::optimize` and
1271// `xlog_logic::promote::promote_multiway`.
1272pub mod selectivity_pass {
1273    //! Selectivity-driven join reordering for canonical lowered triangle and
1274    //! 4-cycle bodies.
1275    //!
1276    //! ## Behavior
1277    //!
1278    //! For each rule body that matches the canonical lowered
1279    //! triangle or 4-cycle shape, the pass enumerates the valid
1280    //! candidate inner pairings (3 for triangle, 2 for 4-cycle),
1281    //! computes each candidate's
1282    //! `StatsManager::estimate_join_cardinality` with
1283    //! **pair-derived join keys from the shared-variable
1284    //! mapping**, and rewrites the body so the smallest-cost
1285    //! choice is materialized first. Tie → keep the optimizer's
1286    //! existing order (deterministic no-op).
1287    //!
1288    //! ## Safety floor
1289    //!
1290    //! If any input atom for a recognized body has no
1291    //! `StatsManager` entry OR `cardinality == 0`, the body is
1292    //! left unchanged. Recursive deltas / freshly-uploaded
1293    //! relations / unseeded predicates therefore stay on the
1294    //! optimizer's default order until stats are populated.
1295    //!
1296    //! ## Default-fallback edge case
1297    //!
1298    //! `StatsManager::estimate_join_cardinality` returns `u64`
1299    //! with no provenance — the caller cannot tell whether the
1300    //! estimate came from the cached `JoinSelectivity` table,
1301    //! the column-distinct heuristic, or the 10% default
1302    //! fallback. When all input atoms have populated
1303    //! cardinalities but no column statistics, the per-pair
1304    //! estimates may all collapse to the same fallback ratio,
1305    //! making the chosen pairing uninformative. **This is an
1306    //! accepted trade-off**: row-set parity holds regardless of
1307    //! selectivity quality (the rewrite preserves semantics);
1308    //! the integration checks gate on row-set + WCOJ-dispatch
1309    //! correctness, not on optimal pair choice.
1310    //!
1311    //! ## Promoter coordination
1312    //!
1313    //! The triangle and 4-cycle promoters accept the canonical *semantic*
1314    //! shape with any valid key combination; they emit `MultiWayJoin.inputs`
1315    //! and `slot_vars` in canonical semantic order regardless of the body's
1316    //! positional layout. Reordered bodies therefore still promote and still
1317    //! dispatch the WCOJ kernel correctly.
1318    use std::collections::HashMap;
1319    use xlog_core::RelId;
1320    use xlog_ir::ExecutionPlan;
1321    use xlog_stats::StatsManager;
1322
1323    /// Selectivity-driven join reordering for canonical triangle and 4-cycle
1324    /// bodies. See module-level doc.
1325    ///
1326    /// `rel_ids` is the predicate-name → RelId map used to
1327    /// resolve body Scans against `StatsManager` lookups.
1328    /// Production callers pass `Compiler::lowerer().rel_ids()`.
1329    /// Test callers can pass an empty map; with no
1330    /// `StatsManager` entries either, the safety floor leaves
1331    /// every body unchanged (legacy no-op behavior preserved).
1332    pub fn run(plan: &mut ExecutionPlan, stats: &StatsManager, rel_ids: &HashMap<String, RelId>) {
1333        // `rel_ids` is reserved for future shape-extension
1334        // work; the current rewriters operate on RelIds
1335        // directly from the body's Scans, so the map isn't
1336        // consulted here. Production callers still pass it
1337        // so the API surface is forward-compatible.
1338        let _ = rel_ids;
1339        for rules in plan.rules_by_scc.iter_mut() {
1340            for rule in rules.iter_mut() {
1341                if let Some(rewritten) = super::reorder::try_reorder_triangle(&rule.body, stats) {
1342                    rule.body = rewritten;
1343                    continue;
1344                }
1345                if let Some(rewritten) = super::reorder::try_reorder_4cycle(&rule.body, stats) {
1346                    rule.body = rewritten;
1347                }
1348            }
1349        }
1350    }
1351}
1352
1353/// Ahead-of-time helper-relation splitting for deep joins with buried skew.
1354pub mod helper_split_pass {
1355    use std::collections::{HashMap, HashSet};
1356
1357    use xlog_core::{RelId, ScalarType, Schema};
1358    use xlog_ir::rir::{HelperSplitSpec, KCliqueVariableOrder};
1359    use xlog_ir::{CompiledRule, ExecutionPlan, JoinType, ProjectExpr, RirMeta, RirNode, Scc};
1360    use xlog_stats::StatsManager;
1361
1362    const HEAVY_SKEW_RATIO: f64 = 10.0;
1363
1364    /// Description of a helper relation introduced by the pass.
1365    #[derive(Debug, Clone, PartialEq, Eq)]
1366    pub struct HelperRelationSpec {
1367        /// Predicate name allocated for the helper relation.
1368        pub name: String,
1369        /// Relation identifier allocated for the helper relation.
1370        pub rel_id: RelId,
1371        /// Output schema of the helper relation.
1372        pub schema: Schema,
1373        /// Pair of source relations extracted into the helper body.
1374        pub source_rels: [RelId; 2],
1375    }
1376
1377    struct JoinStep {
1378        left_keys: Vec<usize>,
1379        right_keys: Vec<usize>,
1380    }
1381
1382    struct LinearBody {
1383        leaves: Vec<RelId>,
1384        leaf_classes: Vec<Vec<u32>>,
1385        joins: Vec<JoinStep>,
1386        project: Vec<ProjectExpr>,
1387        final_classes: Vec<u32>,
1388    }
1389
1390    struct FlatJoin {
1391        leaves: Vec<RelId>,
1392        output_cols: Vec<usize>,
1393        equalities: Vec<(usize, usize)>,
1394    }
1395
1396    struct Candidate {
1397        pair_start: usize,
1398        helper_schema: Schema,
1399        helper_project: Vec<ProjectExpr>,
1400        helper_join_left_keys: Vec<usize>,
1401        helper_join_right_keys: Vec<usize>,
1402        exposed_classes: Vec<u32>,
1403    }
1404
1405    struct Rewrite {
1406        helper_body: RirNode,
1407        outer_body: RirNode,
1408        spec: HelperRelationSpec,
1409    }
1410
1411    #[derive(Clone, Copy)]
1412    struct KCliqueHelperEdge {
1413        slot: usize,
1414        rel: RelId,
1415        left: usize,
1416        right: usize,
1417    }
1418
1419    /// Rewrite eligible rules in-place and return the helper relations introduced.
1420    pub fn run<F>(
1421        plan: &mut ExecutionPlan,
1422        schemas: &HashMap<RelId, Schema>,
1423        stats: &StatsManager,
1424        mut allocate: F,
1425    ) -> Vec<HelperRelationSpec>
1426    where
1427        F: FnMut(Schema) -> (String, RelId),
1428    {
1429        let mut specs = Vec::new();
1430        for scc_idx in 0..plan.rules_by_scc.len() {
1431            let mut rule_idx = 0;
1432            while rule_idx < plan.rules_by_scc[scc_idx].len() {
1433                let rewrite = {
1434                    let rule = &plan.rules_by_scc[scc_idx][rule_idx];
1435                    try_rewrite_rule(rule, schemas, stats, &mut allocate)
1436                };
1437                if let Some(rewrite) = rewrite {
1438                    let helper_rule = CompiledRule {
1439                        head: rewrite.spec.name.clone(),
1440                        body: rewrite.helper_body,
1441                        meta: RirMeta::with_schema(rewrite.spec.schema.clone()),
1442                    };
1443                    plan.rules_by_scc[scc_idx].insert(rule_idx, helper_rule);
1444                    rule_idx += 1;
1445                    plan.rules_by_scc[scc_idx][rule_idx].body = rewrite.outer_body;
1446                    add_helper_to_scc(&mut plan.sccs, scc_idx, &rewrite.spec.name);
1447                    specs.push(rewrite.spec);
1448                }
1449                rule_idx += 1;
1450            }
1451        }
1452        specs
1453    }
1454
1455    /// K-clique helper split entry for K-clique plans that already carry
1456    /// planner-produced `HelperSplitSpec`s. The pass reuses the compiler-owned
1457    /// helper-relation lifecycle: emit a helper rule before the consumer rule,
1458    /// allocate a compiler-owned helper relation, and rewrite the consumer to
1459    /// scan that helper.
1460    pub fn run_kclique_specs<F>(
1461        plan: &mut ExecutionPlan,
1462        schemas: &HashMap<RelId, Schema>,
1463        mut allocate: F,
1464    ) -> Vec<HelperRelationSpec>
1465    where
1466        F: FnMut(Schema) -> (String, RelId),
1467    {
1468        let mut specs = Vec::new();
1469        for scc_idx in 0..plan.rules_by_scc.len() {
1470            let mut rule_idx = 0;
1471            while rule_idx < plan.rules_by_scc[scc_idx].len() {
1472                let rewrite = {
1473                    let rule = &plan.rules_by_scc[scc_idx][rule_idx];
1474                    try_rewrite_kclique_rule(rule, schemas, &mut allocate)
1475                };
1476                if let Some(rewrite) = rewrite {
1477                    let helper_rule = CompiledRule {
1478                        head: rewrite.spec.name.clone(),
1479                        body: rewrite.helper_body,
1480                        meta: RirMeta::with_schema(rewrite.spec.schema.clone()),
1481                    };
1482                    plan.rules_by_scc[scc_idx].insert(rule_idx, helper_rule);
1483                    rule_idx += 1;
1484                    plan.rules_by_scc[scc_idx][rule_idx].body = rewrite.outer_body;
1485                    add_helper_to_scc(&mut plan.sccs, scc_idx, &rewrite.spec.name);
1486                    specs.push(rewrite.spec);
1487                }
1488                rule_idx += 1;
1489            }
1490        }
1491        specs
1492    }
1493
1494    fn add_helper_to_scc(sccs: &mut [Scc], scc_idx: usize, helper: &str) {
1495        if let Some(scc) = sccs.get_mut(scc_idx) {
1496            if !scc.predicates.iter().any(|p| p == helper) {
1497                scc.predicates.push(helper.to_string());
1498            }
1499        }
1500    }
1501
1502    fn try_rewrite_rule<F>(
1503        rule: &CompiledRule,
1504        schemas: &HashMap<RelId, Schema>,
1505        stats: &StatsManager,
1506        allocate: &mut F,
1507    ) -> Option<Rewrite>
1508    where
1509        F: FnMut(Schema) -> (String, RelId),
1510    {
1511        let linear = linearize_project_body(&rule.body, schemas)?;
1512        let candidate = choose_candidate(&linear, schemas, stats)?;
1513        let (helper_name, helper_rel) = allocate(candidate.helper_schema.clone());
1514        let helper_body = build_helper_body(&linear, &candidate);
1515        let outer_body = build_outer_body(&linear, &candidate, helper_rel)?;
1516        Some(Rewrite {
1517            helper_body,
1518            outer_body,
1519            spec: HelperRelationSpec {
1520                name: helper_name,
1521                rel_id: helper_rel,
1522                schema: candidate.helper_schema,
1523                source_rels: [
1524                    linear.leaves[candidate.pair_start],
1525                    linear.leaves[candidate.pair_start + 1],
1526                ],
1527            },
1528        })
1529    }
1530
1531    fn try_rewrite_kclique_rule<F>(
1532        rule: &CompiledRule,
1533        schemas: &HashMap<RelId, Schema>,
1534        allocate: &mut F,
1535    ) -> Option<Rewrite>
1536    where
1537        F: FnMut(Schema) -> (String, RelId),
1538    {
1539        if !matches!(&rule.body, RirNode::MultiWayJoin { .. }) {
1540            return None;
1541        }
1542        let mut outer_body = rule.body.clone();
1543        let RirNode::MultiWayJoin {
1544            inputs, var_order, ..
1545        } = &mut outer_body
1546        else {
1547            return None;
1548        };
1549        let kclique = var_order.as_ref()?.kclique.as_ref()?;
1550        let spec = kclique.helper_split_specs.first()?;
1551        let (hot_left, hot_right, target) = kclique_helper_edges(inputs, kclique, spec)?;
1552        let helper_schema = schemas.get(&target.rel)?.clone();
1553        let (helper_name, helper_rel) = allocate(helper_schema.clone());
1554        let helper_body = build_kclique_helper_body(spec, hot_left, hot_right, target)?;
1555        *inputs.get_mut(target.slot)? = RirNode::Scan { rel: helper_rel };
1556        Some(Rewrite {
1557            helper_body,
1558            outer_body,
1559            spec: HelperRelationSpec {
1560                name: helper_name,
1561                rel_id: helper_rel,
1562                schema: helper_schema,
1563                source_rels: [hot_left.rel, hot_right.rel],
1564            },
1565        })
1566    }
1567
1568    fn kclique_helper_edges(
1569        inputs: &[RirNode],
1570        kclique: &KCliqueVariableOrder,
1571        spec: &HelperSplitSpec,
1572    ) -> Option<(KCliqueHelperEdge, KCliqueHelperEdge, KCliqueHelperEdge)> {
1573        let k = usize::from(kclique.k);
1574        let hot = usize::from(spec.variable);
1575        let mut hot_edges = Vec::new();
1576        let mut target = None;
1577        for &slot in &spec.edge_slots {
1578            let slot = usize::from(slot);
1579            let (left, right) = kclique_edge_pair(slot, k)?;
1580            let RirNode::Scan { rel } = inputs.get(slot)? else {
1581                return None;
1582            };
1583            let edge = KCliqueHelperEdge {
1584                slot,
1585                rel: *rel,
1586                left,
1587                right,
1588            };
1589            if left == hot || right == hot {
1590                hot_edges.push(edge);
1591            } else {
1592                target = Some(edge);
1593            }
1594        }
1595        if hot_edges.len() != 2 {
1596            return None;
1597        }
1598        Some((hot_edges[0], hot_edges[1], target?))
1599    }
1600
1601    fn build_kclique_helper_body(
1602        spec: &HelperSplitSpec,
1603        hot_left: KCliqueHelperEdge,
1604        hot_right: KCliqueHelperEdge,
1605        target: KCliqueHelperEdge,
1606    ) -> Option<RirNode> {
1607        let hot = usize::from(spec.variable);
1608        let target_left = target.left;
1609        let target_right = target.right;
1610        let first_other = kclique_other_endpoint(hot_left, hot)?;
1611        let second_other = kclique_other_endpoint(hot_right, hot)?;
1612        if ![first_other, second_other].contains(&target_left)
1613            || ![first_other, second_other].contains(&target_right)
1614        {
1615            return None;
1616        }
1617
1618        let first_scan = RirNode::Scan { rel: hot_left.rel };
1619        let second_scan = RirNode::Scan { rel: hot_right.rel };
1620        let target_scan = RirNode::Scan { rel: target.rel };
1621        let first_hot_col = kclique_endpoint_col(hot_left, hot)?;
1622        let second_hot_col = kclique_endpoint_col(hot_right, hot)?;
1623        let first_other_col = kclique_endpoint_col(hot_left, first_other)?;
1624        let second_other_col = 2 + kclique_endpoint_col(hot_right, second_other)?;
1625
1626        let target_left_in_join = if first_other == target_left {
1627            first_other_col
1628        } else {
1629            second_other_col
1630        };
1631        let target_right_in_join = if first_other == target_right {
1632            first_other_col
1633        } else {
1634            second_other_col
1635        };
1636        let target_left_col = kclique_endpoint_col(target, target_left)?;
1637        let target_right_col = kclique_endpoint_col(target, target_right)?;
1638
1639        let hot_join = RirNode::Join {
1640            left: Box::new(first_scan),
1641            right: Box::new(second_scan),
1642            left_keys: vec![first_hot_col],
1643            right_keys: vec![second_hot_col],
1644            join_type: JoinType::Inner,
1645        };
1646        let helper_join = RirNode::Join {
1647            left: Box::new(hot_join),
1648            right: Box::new(target_scan),
1649            left_keys: vec![target_left_in_join, target_right_in_join],
1650            right_keys: vec![target_left_col, target_right_col],
1651            join_type: JoinType::Inner,
1652        };
1653        Some(RirNode::Project {
1654            input: Box::new(helper_join),
1655            columns: vec![ProjectExpr::Column(4), ProjectExpr::Column(5)],
1656        })
1657    }
1658
1659    fn kclique_edge_pair(edge_idx: usize, k: usize) -> Option<(usize, usize)> {
1660        let mut idx = 0usize;
1661        for left in 0..k {
1662            for right in (left + 1)..k {
1663                if idx == edge_idx {
1664                    return Some((left, right));
1665                }
1666                idx += 1;
1667            }
1668        }
1669        None
1670    }
1671
1672    fn kclique_endpoint_col(edge: KCliqueHelperEdge, variable: usize) -> Option<usize> {
1673        if edge.left == variable {
1674            Some(0)
1675        } else if edge.right == variable {
1676            Some(1)
1677        } else {
1678            None
1679        }
1680    }
1681
1682    fn kclique_other_endpoint(edge: KCliqueHelperEdge, variable: usize) -> Option<usize> {
1683        if edge.left == variable {
1684            Some(edge.right)
1685        } else if edge.right == variable {
1686            Some(edge.left)
1687        } else {
1688            None
1689        }
1690    }
1691
1692    fn linearize_project_body(
1693        body: &RirNode,
1694        schemas: &HashMap<RelId, Schema>,
1695    ) -> Option<LinearBody> {
1696        let RirNode::Project { input, columns } = body else {
1697            return None;
1698        };
1699        let flat = collect_join_graph(input, schemas)?;
1700        if flat.leaves.len() < 6 {
1701            return None;
1702        }
1703        let mut offsets = Vec::with_capacity(flat.leaves.len());
1704        let mut total_cols = 0usize;
1705        for rel in &flat.leaves {
1706            offsets.push(total_cols);
1707            total_cols += schemas.get(rel)?.arity();
1708        }
1709        let mut uf = UnionFind::new(total_cols);
1710        for (left, right) in flat.equalities {
1711            if left >= total_cols || right >= total_cols {
1712                return None;
1713            }
1714            uf.union(left, right);
1715        }
1716        let mut leaf_classes: Vec<Vec<u32>> = Vec::with_capacity(flat.leaves.len());
1717        for (leaf_idx, rel) in flat.leaves.iter().enumerate() {
1718            let arity = schemas.get(rel)?.arity();
1719            let offset = offsets[leaf_idx];
1720            leaf_classes.push((0..arity).map(|col| uf.find(offset + col) as u32).collect());
1721        }
1722        let final_classes = flat
1723            .output_cols
1724            .iter()
1725            .map(|col| uf.find(*col) as u32)
1726            .collect();
1727        let joins = derive_left_deep_steps(&leaf_classes)?;
1728        Some(LinearBody {
1729            leaves: flat.leaves,
1730            leaf_classes,
1731            joins,
1732            project: columns.clone(),
1733            final_classes,
1734        })
1735    }
1736
1737    fn collect_join_graph(node: &RirNode, schemas: &HashMap<RelId, Schema>) -> Option<FlatJoin> {
1738        match node {
1739            RirNode::Scan { rel } => Some(FlatJoin {
1740                leaves: vec![*rel],
1741                output_cols: (0..schemas.get(rel)?.arity()).collect(),
1742                equalities: Vec::new(),
1743            }),
1744            RirNode::Join {
1745                left,
1746                right,
1747                left_keys,
1748                right_keys,
1749                join_type,
1750            } if *join_type == JoinType::Inner => {
1751                let left_flat = collect_join_graph(left, schemas)?;
1752                let right_flat = collect_join_graph(right, schemas)?;
1753                if left_keys.len() != right_keys.len() {
1754                    return None;
1755                }
1756                let right_shift = total_width(&left_flat.leaves, schemas)?;
1757                let mut leaves = left_flat.leaves;
1758                leaves.extend(right_flat.leaves);
1759                let right_output_cols: Vec<usize> = right_flat
1760                    .output_cols
1761                    .iter()
1762                    .map(|col| col + right_shift)
1763                    .collect();
1764                let mut equalities = left_flat.equalities;
1765                equalities.extend(
1766                    right_flat
1767                        .equalities
1768                        .iter()
1769                        .map(|(left, right)| (left + right_shift, right + right_shift)),
1770                );
1771                for (&left_key, &right_key) in left_keys.iter().zip(right_keys.iter()) {
1772                    equalities.push((
1773                        *left_flat.output_cols.get(left_key)?,
1774                        *right_output_cols.get(right_key)?,
1775                    ));
1776                }
1777                let mut output_cols = left_flat.output_cols;
1778                output_cols.extend(right_output_cols);
1779                Some(FlatJoin {
1780                    leaves,
1781                    output_cols,
1782                    equalities,
1783                })
1784            }
1785            _ => None,
1786        }
1787    }
1788
1789    fn total_width(leaves: &[RelId], schemas: &HashMap<RelId, Schema>) -> Option<usize> {
1790        leaves
1791            .iter()
1792            .map(|rel| schemas.get(rel).map(Schema::arity))
1793            .try_fold(0usize, |acc, width| width.map(|width| acc + width))
1794    }
1795
1796    fn derive_left_deep_steps(leaf_classes: &[Vec<u32>]) -> Option<Vec<JoinStep>> {
1797        let mut joins = Vec::with_capacity(leaf_classes.len().saturating_sub(1));
1798        let mut current = leaf_classes.first()?.clone();
1799        for classes in leaf_classes.iter().skip(1) {
1800            let mut left_keys = Vec::new();
1801            let mut right_keys = Vec::new();
1802            for (right_col, class) in classes.iter().enumerate() {
1803                if let Some(left_col) = current
1804                    .iter()
1805                    .position(|current_class| current_class == class)
1806                {
1807                    left_keys.push(left_col);
1808                    right_keys.push(right_col);
1809                }
1810            }
1811            if left_keys.is_empty() {
1812                return None;
1813            }
1814            joins.push(JoinStep {
1815                left_keys,
1816                right_keys,
1817            });
1818            current.extend(classes.iter().copied());
1819        }
1820        Some(joins)
1821    }
1822
1823    fn choose_candidate(
1824        linear: &LinearBody,
1825        schemas: &HashMap<RelId, Schema>,
1826        stats: &StatsManager,
1827    ) -> Option<Candidate> {
1828        for pair_start in 3..linear.leaves.len().saturating_sub(1) {
1829            let candidate = build_candidate(linear, schemas, pair_start)?;
1830            if skew_ratio_for_candidate(linear, stats, &candidate) >= HEAVY_SKEW_RATIO {
1831                return Some(candidate);
1832            }
1833        }
1834        None
1835    }
1836
1837    fn build_candidate(
1838        linear: &LinearBody,
1839        schemas: &HashMap<RelId, Schema>,
1840        pair_start: usize,
1841    ) -> Option<Candidate> {
1842        let left_rel = linear.leaves[pair_start];
1843        let right_rel = linear.leaves[pair_start + 1];
1844        let left_schema = schemas.get(&left_rel)?;
1845        let right_schema = schemas.get(&right_rel)?;
1846        let internal_step = linear.joins.get(pair_start)?;
1847        let mut helper_left_keys = Vec::new();
1848        let mut helper_right_keys = Vec::new();
1849        for (&left_key, &right_key) in internal_step
1850            .left_keys
1851            .iter()
1852            .zip(internal_step.right_keys.iter())
1853        {
1854            let class = class_at_state(linear, pair_start + 1, left_key)?;
1855            let left_col = linear.leaf_classes[pair_start]
1856                .iter()
1857                .position(|c| *c == class)?;
1858            helper_left_keys.push(left_col);
1859            helper_right_keys.push(right_key);
1860        }
1861        let internal: HashSet<u32> = helper_left_keys
1862            .iter()
1863            .map(|col| linear.leaf_classes[pair_start][*col])
1864            .collect();
1865        let outside = outside_classes(linear, pair_start);
1866        let output = projected_classes(linear)?;
1867        let mut exposed_classes = Vec::new();
1868        let mut helper_project = Vec::new();
1869        let mut helper_columns = Vec::new();
1870        for (col, class) in linear.leaf_classes[pair_start].iter().copied().enumerate() {
1871            if !internal.contains(&class)
1872                && (outside.contains(&class) || output.contains(&class))
1873                && !exposed_classes.contains(&class)
1874            {
1875                exposed_classes.push(class);
1876                helper_project.push(ProjectExpr::Column(col));
1877                let ty = left_schema.column_type(col).unwrap_or(ScalarType::U32);
1878                helper_columns.push((format!("c{}", helper_columns.len()), ty));
1879            }
1880        }
1881        let right_offset = left_schema.arity();
1882        for (col, class) in linear.leaf_classes[pair_start + 1]
1883            .iter()
1884            .copied()
1885            .enumerate()
1886        {
1887            if !internal.contains(&class)
1888                && (outside.contains(&class) || output.contains(&class))
1889                && !exposed_classes.contains(&class)
1890            {
1891                exposed_classes.push(class);
1892                helper_project.push(ProjectExpr::Column(right_offset + col));
1893                let ty = right_schema.column_type(col).unwrap_or(ScalarType::U32);
1894                helper_columns.push((format!("c{}", helper_columns.len()), ty));
1895            }
1896        }
1897        if exposed_classes.len() != 2 {
1898            return None;
1899        }
1900        Some(Candidate {
1901            pair_start,
1902            helper_schema: Schema::new(helper_columns),
1903            helper_project,
1904            helper_join_left_keys: helper_left_keys,
1905            helper_join_right_keys: helper_right_keys,
1906            exposed_classes,
1907        })
1908    }
1909
1910    fn class_at_state(linear: &LinearBody, leaf_count: usize, col: usize) -> Option<u32> {
1911        let mut idx = col;
1912        for leaf_idx in 0..leaf_count {
1913            let classes = &linear.leaf_classes[leaf_idx];
1914            if idx < classes.len() {
1915                return Some(classes[idx]);
1916            }
1917            idx -= classes.len();
1918        }
1919        None
1920    }
1921
1922    fn outside_classes(linear: &LinearBody, pair_start: usize) -> HashSet<u32> {
1923        linear
1924            .leaf_classes
1925            .iter()
1926            .enumerate()
1927            .filter(|(idx, _)| *idx != pair_start && *idx != pair_start + 1)
1928            .flat_map(|(_, classes)| classes.iter().copied())
1929            .collect()
1930    }
1931
1932    fn projected_classes(linear: &LinearBody) -> Option<HashSet<u32>> {
1933        let mut out = HashSet::new();
1934        for expr in &linear.project {
1935            let ProjectExpr::Column(col) = expr else {
1936                return None;
1937            };
1938            out.insert(*linear.final_classes.get(*col)?);
1939        }
1940        Some(out)
1941    }
1942
1943    fn skew_ratio_for_candidate(
1944        linear: &LinearBody,
1945        stats: &StatsManager,
1946        candidate: &Candidate,
1947    ) -> f64 {
1948        let rel = linear.leaves[candidate.pair_start];
1949        let Some(rel_stats) = stats.get_relation_stats(rel) else {
1950            return 0.0;
1951        };
1952        let mut ratio: f64 = 0.0;
1953        for (col, class) in linear.leaf_classes[candidate.pair_start]
1954            .iter()
1955            .copied()
1956            .enumerate()
1957        {
1958            if !candidate.exposed_classes.contains(&class) {
1959                continue;
1960            }
1961            let Some(col_stats) = rel_stats.get_column(col) else {
1962                continue;
1963            };
1964            if col_stats.distinct_estimate == 0 {
1965                continue;
1966            }
1967            ratio = ratio.max(rel_stats.cardinality as f64 / col_stats.distinct_estimate as f64);
1968        }
1969        ratio
1970    }
1971
1972    fn build_helper_body(linear: &LinearBody, candidate: &Candidate) -> RirNode {
1973        let left = RirNode::Scan {
1974            rel: linear.leaves[candidate.pair_start],
1975        };
1976        let right = RirNode::Scan {
1977            rel: linear.leaves[candidate.pair_start + 1],
1978        };
1979        RirNode::Project {
1980            input: Box::new(RirNode::Join {
1981                left: Box::new(left),
1982                right: Box::new(right),
1983                left_keys: candidate.helper_join_left_keys.clone(),
1984                right_keys: candidate.helper_join_right_keys.clone(),
1985                join_type: JoinType::Inner,
1986            }),
1987            columns: candidate.helper_project.clone(),
1988        }
1989    }
1990
1991    fn build_outer_body(
1992        linear: &LinearBody,
1993        candidate: &Candidate,
1994        helper_rel: RelId,
1995    ) -> Option<RirNode> {
1996        let mut node = RirNode::Scan {
1997            rel: linear.leaves[0],
1998        };
1999        let mut classes = linear.leaf_classes[0].clone();
2000        for leaf_idx in 1..candidate.pair_start {
2001            let step = &linear.joins[leaf_idx - 1];
2002            node = RirNode::Join {
2003                left: Box::new(node),
2004                right: Box::new(RirNode::Scan {
2005                    rel: linear.leaves[leaf_idx],
2006                }),
2007                left_keys: step.left_keys.clone(),
2008                right_keys: step.right_keys.clone(),
2009                join_type: JoinType::Inner,
2010            };
2011            classes.extend(linear.leaf_classes[leaf_idx].iter().copied());
2012        }
2013        let prefix_step = &linear.joins[candidate.pair_start - 1];
2014        let mut helper_right_keys = Vec::new();
2015        for &rk in &prefix_step.right_keys {
2016            let class = linear.leaf_classes[candidate.pair_start][rk];
2017            helper_right_keys.push(candidate.exposed_classes.iter().position(|c| *c == class)?);
2018        }
2019        node = RirNode::Join {
2020            left: Box::new(node),
2021            right: Box::new(RirNode::Scan { rel: helper_rel }),
2022            left_keys: prefix_step.left_keys.clone(),
2023            right_keys: helper_right_keys,
2024            join_type: JoinType::Inner,
2025        };
2026        classes.extend(candidate.exposed_classes.iter().copied());
2027        for leaf_idx in candidate.pair_start + 2..linear.leaves.len() {
2028            let step = &linear.joins[leaf_idx - 1];
2029            let mut left_keys = Vec::new();
2030            for &lk in &step.left_keys {
2031                let class = class_at_state(linear, leaf_idx, lk)?;
2032                left_keys.push(classes.iter().position(|c| *c == class)?);
2033            }
2034            node = RirNode::Join {
2035                left: Box::new(node),
2036                right: Box::new(RirNode::Scan {
2037                    rel: linear.leaves[leaf_idx],
2038                }),
2039                left_keys,
2040                right_keys: step.right_keys.clone(),
2041                join_type: JoinType::Inner,
2042            };
2043            classes.extend(linear.leaf_classes[leaf_idx].iter().copied());
2044        }
2045        let mut project = Vec::with_capacity(linear.project.len());
2046        for expr in &linear.project {
2047            let ProjectExpr::Column(col) = expr else {
2048                return None;
2049            };
2050            let class = *linear.final_classes.get(*col)?;
2051            let mapped = classes.iter().position(|c| *c == class)?;
2052            project.push(ProjectExpr::Column(mapped));
2053        }
2054        Some(RirNode::Project {
2055            input: Box::new(node),
2056            columns: project,
2057        })
2058    }
2059
2060    struct UnionFind {
2061        parent: Vec<usize>,
2062    }
2063
2064    impl UnionFind {
2065        fn new(len: usize) -> Self {
2066            Self {
2067                parent: (0..len).collect(),
2068            }
2069        }
2070
2071        fn find(&mut self, x: usize) -> usize {
2072            let p = self.parent[x];
2073            if p == x {
2074                x
2075            } else {
2076                let root = self.find(p);
2077                self.parent[x] = root;
2078                root
2079            }
2080        }
2081
2082        fn union(&mut self, a: usize, b: usize) {
2083            let ra = self.find(a);
2084            let rb = self.find(b);
2085            if ra != rb {
2086                self.parent[rb] = ra;
2087            }
2088        }
2089    }
2090}
2091
2092#[path = "optimizer/stream_schedule_pass.rs"]
2093pub mod stream_schedule_pass;
2094
2095#[cfg(test)]
2096mod helper_split_pass_tests {
2097    use std::collections::HashMap;
2098
2099    use super::helper_split_pass;
2100    use xlog_core::{RelId, ScalarType, Schema};
2101    use xlog_ir::{CompiledRule, ExecutionPlan, JoinType, ProjectExpr, RirMeta, RirNode, Scc};
2102    use xlog_stats::{ColumnStats, StatsManager};
2103
2104    fn edge_schema() -> Schema {
2105        Schema::new(vec![
2106            ("c0".to_string(), ScalarType::U32),
2107            ("c1".to_string(), ScalarType::U32),
2108        ])
2109    }
2110
2111    fn helper_schema() -> Schema {
2112        Schema::new(vec![
2113            ("c0".to_string(), ScalarType::U32),
2114            ("c1".to_string(), ScalarType::U32),
2115        ])
2116    }
2117
2118    fn schemas() -> HashMap<RelId, Schema> {
2119        (0..6)
2120            .map(|idx| (RelId(idx), edge_schema()))
2121            .collect::<HashMap<_, _>>()
2122    }
2123
2124    fn left_deep_fixture_body() -> RirNode {
2125        let ab_bc = RirNode::Join {
2126            left: Box::new(RirNode::Scan { rel: RelId(0) }),
2127            right: Box::new(RirNode::Scan { rel: RelId(1) }),
2128            left_keys: vec![1],
2129            right_keys: vec![0],
2130            join_type: JoinType::Inner,
2131        };
2132        let with_cd = RirNode::Join {
2133            left: Box::new(ab_bc),
2134            right: Box::new(RirNode::Scan { rel: RelId(2) }),
2135            left_keys: vec![3],
2136            right_keys: vec![0],
2137            join_type: JoinType::Inner,
2138        };
2139        let with_de = RirNode::Join {
2140            left: Box::new(with_cd),
2141            right: Box::new(RirNode::Scan { rel: RelId(3) }),
2142            left_keys: vec![5],
2143            right_keys: vec![0],
2144            join_type: JoinType::Inner,
2145        };
2146        let with_ef = RirNode::Join {
2147            left: Box::new(with_de),
2148            right: Box::new(RirNode::Scan { rel: RelId(4) }),
2149            left_keys: vec![7],
2150            right_keys: vec![0],
2151            join_type: JoinType::Inner,
2152        };
2153        let with_af = RirNode::Join {
2154            left: Box::new(with_ef),
2155            right: Box::new(RirNode::Scan { rel: RelId(5) }),
2156            left_keys: vec![0, 9],
2157            right_keys: vec![0, 1],
2158            join_type: JoinType::Inner,
2159        };
2160        RirNode::Project {
2161            input: Box::new(with_af),
2162            columns: vec![
2163                ProjectExpr::Column(0),
2164                ProjectExpr::Column(1),
2165                ProjectExpr::Column(3),
2166                ProjectExpr::Column(5),
2167                ProjectExpr::Column(9),
2168            ],
2169        }
2170    }
2171
2172    fn plan() -> ExecutionPlan {
2173        ExecutionPlan {
2174            sccs: vec![Scc {
2175                id: 0,
2176                predicates: vec!["out".to_string()],
2177                is_recursive: false,
2178            }],
2179            strata: vec![],
2180            rules_by_scc: vec![vec![CompiledRule {
2181                head: "out".to_string(),
2182                body: left_deep_fixture_body(),
2183                meta: RirMeta::with_schema(Schema::new(vec![
2184                    ("a".to_string(), ScalarType::U32),
2185                    ("b".to_string(), ScalarType::U32),
2186                    ("c".to_string(), ScalarType::U32),
2187                    ("d".to_string(), ScalarType::U32),
2188                    ("f".to_string(), ScalarType::U32),
2189                ])),
2190            }]],
2191            generated_query_rules: vec![],
2192            est_memory_peak: 0,
2193            rel_arities: std::collections::HashMap::new(),
2194        }
2195    }
2196
2197    fn stats_for_de(distinct_d: u64) -> StatsManager {
2198        let mut stats = StatsManager::new();
2199        for idx in 0..6 {
2200            stats.register_relation(RelId(idx));
2201            stats.update_cardinality(RelId(idx), 8192);
2202        }
2203        let mut d_col = ColumnStats::new(0, ScalarType::U32);
2204        d_col.update_distinct(distinct_d);
2205        stats.add_column_stats(RelId(3), d_col);
2206        stats
2207    }
2208
2209    fn contains_scan(node: &RirNode, rel: RelId) -> bool {
2210        match node {
2211            RirNode::Scan { rel: scan_rel } => *scan_rel == rel,
2212            RirNode::Join { left, right, .. } | RirNode::ChainJoin { left, right, .. } => {
2213                contains_scan(left, rel) || contains_scan(right, rel)
2214            }
2215            RirNode::Project { input, .. }
2216            | RirNode::Filter { input, .. }
2217            | RirNode::Distinct { input, .. }
2218            | RirNode::GroupBy { input, .. } => contains_scan(input, rel),
2219            RirNode::Union { inputs } => inputs.iter().any(|input| contains_scan(input, rel)),
2220            RirNode::Diff { left, right } => contains_scan(left, rel) || contains_scan(right, rel),
2221            RirNode::Fixpoint {
2222                base, recursive, ..
2223            } => contains_scan(base, rel) || contains_scan(recursive, rel),
2224            RirNode::MultiWayJoin { inputs, .. } => {
2225                inputs.iter().any(|input| contains_scan(input, rel))
2226            }
2227            RirNode::TensorMaskedJoin { rel_index, .. } => {
2228                rel_index.iter().any(|(input_rel, _)| *input_rel == rel)
2229            }
2230            RirNode::Unit => false,
2231        }
2232    }
2233
2234    #[test]
2235    fn helper_split_extracts_buried_pair() {
2236        let mut plan = plan();
2237        let schemas = schemas();
2238        let stats = stats_for_de(1);
2239        let specs = helper_split_pass::run(&mut plan, &schemas, &stats, |_| {
2240            ("__kclique_helper_6".to_string(), RelId(6))
2241        });
2242
2243        assert_eq!(specs.len(), 1);
2244        assert_eq!(specs[0].name, "__kclique_helper_6");
2245        assert_eq!(specs[0].rel_id, RelId(6));
2246        assert_eq!(specs[0].schema, helper_schema());
2247        assert_eq!(specs[0].source_rels, [RelId(3), RelId(4)]);
2248        assert_eq!(plan.rules_by_scc[0].len(), 2);
2249        assert_eq!(plan.rules_by_scc[0][0].head, "__kclique_helper_6");
2250        assert_eq!(plan.rules_by_scc[0][1].head, "out");
2251        assert!(contains_scan(&plan.rules_by_scc[0][1].body, RelId(6)));
2252        assert!(plan.sccs[0]
2253            .predicates
2254            .iter()
2255            .any(|predicate| predicate == "__kclique_helper_6"));
2256    }
2257
2258    #[test]
2259    fn helper_split_ignores_flat_distribution() {
2260        let mut plan = plan();
2261        let schemas = schemas();
2262        let stats = stats_for_de(8192);
2263        let specs = helper_split_pass::run(&mut plan, &schemas, &stats, |_| {
2264            ("__kclique_helper_6".to_string(), RelId(6))
2265        });
2266
2267        assert!(specs.is_empty());
2268        assert_eq!(plan.rules_by_scc[0].len(), 1);
2269        assert!(!contains_scan(&plan.rules_by_scc[0][0].body, RelId(6)));
2270    }
2271}
2272
2273/// Selectivity-driven body rewriters for triangle and 4-cycle canonical lowered
2274/// shapes. `pub(super)` so `selectivity_pass::run` can dispatch into them.
2275mod reorder {
2276    use std::collections::HashMap;
2277    use xlog_core::RelId;
2278    use xlog_ir::rir::ProjectExpr;
2279    use xlog_ir::{JoinType, RirNode};
2280    use xlog_stats::StatsManager;
2281
2282    fn ac3(atom: u8, col: u8) -> u8 {
2283        atom * 2 + col
2284    }
2285    fn ac4(atom: u8, col: u8) -> u8 {
2286        atom * 2 + col
2287    }
2288    fn uf_find_n<const N: usize>(parent: &mut [u8; N], x: u8) -> u8 {
2289        let mut root = x;
2290        while parent[root as usize] != root {
2291            root = parent[root as usize];
2292        }
2293        let mut cur = x;
2294        while parent[cur as usize] != root {
2295            let next = parent[cur as usize];
2296            parent[cur as usize] = root;
2297            cur = next;
2298        }
2299        root
2300    }
2301    fn uf_union_n<const N: usize>(parent: &mut [u8; N], a: u8, b: u8) {
2302        let ra = uf_find_n(parent, a);
2303        let rb = uf_find_n(parent, b);
2304        if ra != rb {
2305            parent[rb as usize] = ra;
2306        }
2307    }
2308
2309    fn populated_card(stats: &StatsManager, rel: RelId) -> Option<u64> {
2310        stats
2311            .get_relation_stats(rel)
2312            .map(|s| s.cardinality)
2313            .filter(|c| *c > 0)
2314    }
2315
2316    // ---------------------------------------------------------
2317    // Triangle rewriter
2318    // ---------------------------------------------------------
2319
2320    struct TriangleSemantics {
2321        rel_xy: RelId,
2322        rel_yz: RelId,
2323        rel_xz: RelId,
2324    }
2325
2326    fn match_and_infer_triangle(body: &RirNode) -> Option<TriangleSemantics> {
2327        let RirNode::Project {
2328            input: outer_input,
2329            columns,
2330        } = body
2331        else {
2332            return None;
2333        };
2334        let RirNode::Join {
2335            left: l1,
2336            right: r1,
2337            left_keys: lk1,
2338            right_keys: rk1,
2339            join_type: jt1,
2340        } = outer_input.as_ref()
2341        else {
2342            return None;
2343        };
2344        if !matches!(jt1, JoinType::Inner) {
2345            return None;
2346        }
2347        let RirNode::Scan { rel: rel_third } = r1.as_ref() else {
2348            return None;
2349        };
2350        let RirNode::Join {
2351            left: l2,
2352            right: r2,
2353            left_keys: lk2,
2354            right_keys: rk2,
2355            join_type: jt2,
2356        } = l1.as_ref()
2357        else {
2358            return None;
2359        };
2360        if !matches!(jt2, JoinType::Inner) {
2361            return None;
2362        }
2363        let RirNode::Scan { rel: rel_inner_l } = l2.as_ref() else {
2364            return None;
2365        };
2366        let RirNode::Scan { rel: rel_inner_r } = r2.as_ref() else {
2367            return None;
2368        };
2369        if lk2.len() != 1 || rk2.len() != 1 || lk1.len() != 2 || rk1.len() != 2 {
2370            return None;
2371        }
2372        if columns.len() != 3 {
2373            return None;
2374        }
2375        if lk2[0] >= 2 || rk2[0] >= 2 {
2376            return None;
2377        }
2378        if lk1.iter().any(|k| *k >= 4) || rk1.iter().any(|k| *k >= 2) {
2379            return None;
2380        }
2381
2382        let mut parent = [0u8, 1, 2, 3, 4, 5];
2383        uf_union_n::<6>(&mut parent, ac3(0, lk2[0] as u8), ac3(1, rk2[0] as u8));
2384        for i in 0..2 {
2385            let inner_ac = match lk1[i] {
2386                0 => (0u8, 0u8),
2387                1 => (0, 1),
2388                2 => (1, 0),
2389                3 => (1, 1),
2390                _ => return None,
2391            };
2392            uf_union_n::<6>(
2393                &mut parent,
2394                ac3(inner_ac.0, inner_ac.1),
2395                ac3(2, rk1[i] as u8),
2396            );
2397        }
2398        let roots: [u8; 6] = std::array::from_fn(|i| uf_find_n::<6>(&mut parent, i as u8));
2399        let mut counts: HashMap<u8, u8> = HashMap::new();
2400        for r in &roots {
2401            *counts.entry(*r).or_insert(0) += 1;
2402        }
2403        if counts.len() != 3 || counts.values().any(|c| *c != 2) {
2404            return None;
2405        }
2406        let mut head_classes: [u8; 3] = [0; 3];
2407        for (i, pc) in columns.iter().enumerate() {
2408            let ProjectExpr::Column(k) = pc else {
2409                return None;
2410            };
2411            let outer_ac = match *k {
2412                0 => (0u8, 0u8),
2413                1 => (0, 1),
2414                2 => (1, 0),
2415                3 => (1, 1),
2416                4 => (2, 0),
2417                5 => (2, 1),
2418                _ => return None,
2419            };
2420            head_classes[i] = uf_find_n::<6>(&mut parent, ac3(outer_ac.0, outer_ac.1));
2421        }
2422        if head_classes[0] == head_classes[1]
2423            || head_classes[0] == head_classes[2]
2424            || head_classes[1] == head_classes[2]
2425        {
2426            return None;
2427        }
2428        let x_class = head_classes[0];
2429        let y_class = head_classes[1];
2430        let z_class = head_classes[2];
2431        let atom_classes = |a: u8| (roots[ac3(a, 0) as usize], roots[ac3(a, 1) as usize]);
2432        let atom_rels = [*rel_inner_l, *rel_inner_r, *rel_third];
2433        let mut rel_xy = None;
2434        let mut rel_yz = None;
2435        let mut rel_xz = None;
2436        for atom_idx in 0..3u8 {
2437            let (c0, c1) = atom_classes(atom_idx);
2438            let bx = c0 == x_class || c1 == x_class;
2439            let by = c0 == y_class || c1 == y_class;
2440            let bz = c0 == z_class || c1 == z_class;
2441            match (bx, by, bz) {
2442                (true, true, false) => rel_xy = Some(atom_rels[atom_idx as usize]),
2443                (false, true, true) => rel_yz = Some(atom_rels[atom_idx as usize]),
2444                (true, false, true) => rel_xz = Some(atom_rels[atom_idx as usize]),
2445                _ => return None,
2446            }
2447        }
2448        Some(TriangleSemantics {
2449            rel_xy: rel_xy?,
2450            rel_yz: rel_yz?,
2451            rel_xz: rel_xz?,
2452        })
2453    }
2454
2455    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2456    enum TriangleInnerPair {
2457        SharedY,
2458        SharedX,
2459        SharedZ,
2460    }
2461
2462    fn build_triangle_body(s: &TriangleSemantics, inner_pair: TriangleInnerPair) -> RirNode {
2463        let mk_scan = |r: RelId| RirNode::Scan { rel: r };
2464        match inner_pair {
2465            TriangleInnerPair::SharedY => {
2466                let inner = RirNode::Join {
2467                    left: Box::new(mk_scan(s.rel_xy)),
2468                    right: Box::new(mk_scan(s.rel_yz)),
2469                    left_keys: vec![1],
2470                    right_keys: vec![0],
2471                    join_type: JoinType::Inner,
2472                };
2473                let outer = RirNode::Join {
2474                    left: Box::new(inner),
2475                    right: Box::new(mk_scan(s.rel_xz)),
2476                    left_keys: vec![0, 3],
2477                    right_keys: vec![0, 1],
2478                    join_type: JoinType::Inner,
2479                };
2480                RirNode::Project {
2481                    input: Box::new(outer),
2482                    columns: vec![
2483                        ProjectExpr::Column(0),
2484                        ProjectExpr::Column(1),
2485                        ProjectExpr::Column(3),
2486                    ],
2487                }
2488            }
2489            TriangleInnerPair::SharedX => {
2490                let inner = RirNode::Join {
2491                    left: Box::new(mk_scan(s.rel_xy)),
2492                    right: Box::new(mk_scan(s.rel_xz)),
2493                    left_keys: vec![0],
2494                    right_keys: vec![0],
2495                    join_type: JoinType::Inner,
2496                };
2497                let outer = RirNode::Join {
2498                    left: Box::new(inner),
2499                    right: Box::new(mk_scan(s.rel_yz)),
2500                    left_keys: vec![1, 3],
2501                    right_keys: vec![0, 1],
2502                    join_type: JoinType::Inner,
2503                };
2504                RirNode::Project {
2505                    input: Box::new(outer),
2506                    columns: vec![
2507                        ProjectExpr::Column(0),
2508                        ProjectExpr::Column(1),
2509                        ProjectExpr::Column(3),
2510                    ],
2511                }
2512            }
2513            TriangleInnerPair::SharedZ => {
2514                let inner = RirNode::Join {
2515                    left: Box::new(mk_scan(s.rel_xz)),
2516                    right: Box::new(mk_scan(s.rel_yz)),
2517                    left_keys: vec![1],
2518                    right_keys: vec![1],
2519                    join_type: JoinType::Inner,
2520                };
2521                let outer = RirNode::Join {
2522                    left: Box::new(inner),
2523                    right: Box::new(mk_scan(s.rel_xy)),
2524                    left_keys: vec![0, 2],
2525                    right_keys: vec![0, 1],
2526                    join_type: JoinType::Inner,
2527                };
2528                RirNode::Project {
2529                    input: Box::new(outer),
2530                    columns: vec![
2531                        ProjectExpr::Column(0),
2532                        ProjectExpr::Column(2),
2533                        ProjectExpr::Column(3),
2534                    ],
2535                }
2536            }
2537        }
2538    }
2539
2540    pub(super) fn try_reorder_triangle(body: &RirNode, stats: &StatsManager) -> Option<RirNode> {
2541        let s = match_and_infer_triangle(body)?;
2542        let _ = (
2543            populated_card(stats, s.rel_xy)?,
2544            populated_card(stats, s.rel_yz)?,
2545            populated_card(stats, s.rel_xz)?,
2546        );
2547        let est_y = stats.estimate_join_cardinality(s.rel_xy, s.rel_yz, &[1], &[0]);
2548        let est_x = stats.estimate_join_cardinality(s.rel_xy, s.rel_xz, &[0], &[0]);
2549        let est_z = stats.estimate_join_cardinality(s.rel_yz, s.rel_xz, &[1], &[1]);
2550        let mut best = (TriangleInnerPair::SharedY, est_y);
2551        if est_x < best.1 {
2552            best = (TriangleInnerPair::SharedX, est_x);
2553        }
2554        if est_z < best.1 {
2555            best = (TriangleInnerPair::SharedZ, est_z);
2556        }
2557        let candidate = build_triangle_body(&s, best.0);
2558        // Skip when the candidate is structurally identical to
2559        // the input (no-op rewrite). RirNode doesn't impl
2560        // PartialEq, so compare via Debug — bodies are small
2561        // (≤ 6 Scans + 2 Joins + 1 Project) so the cost is
2562        // negligible relative to the optimizer's broader work.
2563        if format!("{:?}", candidate) == format!("{:?}", body) {
2564            return None;
2565        }
2566        Some(candidate)
2567    }
2568
2569    // ---------------------------------------------------------
2570    // 4-cycle rewriter
2571    // ---------------------------------------------------------
2572
2573    struct Cycle4Semantics {
2574        rel_wx: RelId,
2575        rel_xy: RelId,
2576        rel_yz: RelId,
2577        rel_zw: RelId,
2578    }
2579
2580    fn match_and_infer_4cycle(body: &RirNode) -> Option<Cycle4Semantics> {
2581        let RirNode::Project {
2582            input: outer_input,
2583            columns,
2584        } = body
2585        else {
2586            return None;
2587        };
2588        let RirNode::Join {
2589            left: outer_l,
2590            right: outer_r,
2591            left_keys: olk,
2592            right_keys: ork,
2593            join_type: ojt,
2594        } = outer_input.as_ref()
2595        else {
2596            return None;
2597        };
2598        if !matches!(ojt, JoinType::Inner) {
2599            return None;
2600        }
2601        let RirNode::Join {
2602            left: ll,
2603            right: lr,
2604            left_keys: ilk_l,
2605            right_keys: irk_l,
2606            join_type: ijt_l,
2607        } = outer_l.as_ref()
2608        else {
2609            return None;
2610        };
2611        if !matches!(ijt_l, JoinType::Inner) {
2612            return None;
2613        }
2614        let RirNode::Scan { rel: rel_ll } = ll.as_ref() else {
2615            return None;
2616        };
2617        let RirNode::Scan { rel: rel_lr } = lr.as_ref() else {
2618            return None;
2619        };
2620        let RirNode::Join {
2621            left: rl,
2622            right: rr,
2623            left_keys: ilk_r,
2624            right_keys: irk_r,
2625            join_type: ijt_r,
2626        } = outer_r.as_ref()
2627        else {
2628            return None;
2629        };
2630        if !matches!(ijt_r, JoinType::Inner) {
2631            return None;
2632        }
2633        let RirNode::Scan { rel: rel_rl } = rl.as_ref() else {
2634            return None;
2635        };
2636        let RirNode::Scan { rel: rel_rr } = rr.as_ref() else {
2637            return None;
2638        };
2639        if ilk_l.len() != 1 || irk_l.len() != 1 || ilk_r.len() != 1 || irk_r.len() != 1 {
2640            return None;
2641        }
2642        if olk.len() != 2 || ork.len() != 2 || columns.len() != 4 {
2643            return None;
2644        }
2645        if ilk_l[0] >= 2 || irk_l[0] >= 2 || ilk_r[0] >= 2 || irk_r[0] >= 2 {
2646            return None;
2647        }
2648        if olk.iter().any(|k| *k >= 4) || ork.iter().any(|k| *k >= 4) {
2649            return None;
2650        }
2651
2652        let mut parent = [0u8, 1, 2, 3, 4, 5, 6, 7];
2653        uf_union_n::<8>(&mut parent, ac4(0, ilk_l[0] as u8), ac4(1, irk_l[0] as u8));
2654        uf_union_n::<8>(&mut parent, ac4(2, ilk_r[0] as u8), ac4(3, irk_r[0] as u8));
2655        for i in 0..2 {
2656            let l_ac = match olk[i] {
2657                0 => (0u8, 0u8),
2658                1 => (0, 1),
2659                2 => (1, 0),
2660                3 => (1, 1),
2661                _ => return None,
2662            };
2663            let r_ac = match ork[i] {
2664                0 => (2u8, 0u8),
2665                1 => (2, 1),
2666                2 => (3, 0),
2667                3 => (3, 1),
2668                _ => return None,
2669            };
2670            uf_union_n::<8>(&mut parent, ac4(l_ac.0, l_ac.1), ac4(r_ac.0, r_ac.1));
2671        }
2672        let roots: [u8; 8] = std::array::from_fn(|i| uf_find_n::<8>(&mut parent, i as u8));
2673        let mut counts: HashMap<u8, u8> = HashMap::new();
2674        for r in &roots {
2675            *counts.entry(*r).or_insert(0) += 1;
2676        }
2677        if counts.len() != 4 || counts.values().any(|c| *c != 2) {
2678            return None;
2679        }
2680
2681        let mut head_classes: [u8; 4] = [0; 4];
2682        for (i, pc) in columns.iter().enumerate() {
2683            let ProjectExpr::Column(k) = pc else {
2684                return None;
2685            };
2686            let ac = match *k {
2687                0 => (0u8, 0u8),
2688                1 => (0, 1),
2689                2 => (1, 0),
2690                3 => (1, 1),
2691                4 => (2, 0),
2692                5 => (2, 1),
2693                6 => (3, 0),
2694                7 => (3, 1),
2695                _ => return None,
2696            };
2697            head_classes[i] = uf_find_n::<8>(&mut parent, ac4(ac.0, ac.1));
2698        }
2699        for i in 0..4 {
2700            for j in (i + 1)..4 {
2701                if head_classes[i] == head_classes[j] {
2702                    return None;
2703                }
2704            }
2705        }
2706        let w_class = head_classes[0];
2707        let x_class = head_classes[1];
2708        let y_class = head_classes[2];
2709        let z_class = head_classes[3];
2710        let atom_classes = |a: u8| (roots[ac4(a, 0) as usize], roots[ac4(a, 1) as usize]);
2711        let atom_rels = [*rel_ll, *rel_lr, *rel_rl, *rel_rr];
2712        let mut rel_wx = None;
2713        let mut rel_xy = None;
2714        let mut rel_yz = None;
2715        let mut rel_zw = None;
2716        for atom_idx in 0..4u8 {
2717            let (c0, c1) = atom_classes(atom_idx);
2718            let bw = c0 == w_class || c1 == w_class;
2719            let bx = c0 == x_class || c1 == x_class;
2720            let by = c0 == y_class || c1 == y_class;
2721            let bz = c0 == z_class || c1 == z_class;
2722            match (bw, bx, by, bz) {
2723                (true, true, false, false) => rel_wx = Some(atom_rels[atom_idx as usize]),
2724                (false, true, true, false) => rel_xy = Some(atom_rels[atom_idx as usize]),
2725                (false, false, true, true) => rel_yz = Some(atom_rels[atom_idx as usize]),
2726                (true, false, false, true) => rel_zw = Some(atom_rels[atom_idx as usize]),
2727                _ => return None,
2728            }
2729        }
2730        Some(Cycle4Semantics {
2731            rel_wx: rel_wx?,
2732            rel_xy: rel_xy?,
2733            rel_yz: rel_yz?,
2734            rel_zw: rel_zw?,
2735        })
2736    }
2737
2738    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
2739    enum Cycle4Grouping {
2740        Default,
2741        Alt,
2742    }
2743
2744    fn build_4cycle_body(s: &Cycle4Semantics, g: Cycle4Grouping) -> RirNode {
2745        let mk_scan = |r: RelId| RirNode::Scan { rel: r };
2746        match g {
2747            Cycle4Grouping::Default => {
2748                let il = RirNode::Join {
2749                    left: Box::new(mk_scan(s.rel_wx)),
2750                    right: Box::new(mk_scan(s.rel_xy)),
2751                    left_keys: vec![1],
2752                    right_keys: vec![0],
2753                    join_type: JoinType::Inner,
2754                };
2755                let ir = RirNode::Join {
2756                    left: Box::new(mk_scan(s.rel_yz)),
2757                    right: Box::new(mk_scan(s.rel_zw)),
2758                    left_keys: vec![1],
2759                    right_keys: vec![0],
2760                    join_type: JoinType::Inner,
2761                };
2762                let outer = RirNode::Join {
2763                    left: Box::new(il),
2764                    right: Box::new(ir),
2765                    left_keys: vec![0, 3],
2766                    right_keys: vec![3, 0],
2767                    join_type: JoinType::Inner,
2768                };
2769                RirNode::Project {
2770                    input: Box::new(outer),
2771                    columns: vec![
2772                        ProjectExpr::Column(0),
2773                        ProjectExpr::Column(1),
2774                        ProjectExpr::Column(3),
2775                        ProjectExpr::Column(5),
2776                    ],
2777                }
2778            }
2779            Cycle4Grouping::Alt => {
2780                let il = RirNode::Join {
2781                    left: Box::new(mk_scan(s.rel_xy)),
2782                    right: Box::new(mk_scan(s.rel_yz)),
2783                    left_keys: vec![1],
2784                    right_keys: vec![0],
2785                    join_type: JoinType::Inner,
2786                };
2787                let ir = RirNode::Join {
2788                    left: Box::new(mk_scan(s.rel_zw)),
2789                    right: Box::new(mk_scan(s.rel_wx)),
2790                    left_keys: vec![1],
2791                    right_keys: vec![0],
2792                    join_type: JoinType::Inner,
2793                };
2794                let outer = RirNode::Join {
2795                    left: Box::new(il),
2796                    right: Box::new(ir),
2797                    left_keys: vec![0, 3],
2798                    right_keys: vec![3, 0],
2799                    join_type: JoinType::Inner,
2800                };
2801                RirNode::Project {
2802                    input: Box::new(outer),
2803                    columns: vec![
2804                        ProjectExpr::Column(5),
2805                        ProjectExpr::Column(0),
2806                        ProjectExpr::Column(1),
2807                        ProjectExpr::Column(3),
2808                    ],
2809                }
2810            }
2811        }
2812    }
2813
2814    pub(super) fn try_reorder_4cycle(body: &RirNode, stats: &StatsManager) -> Option<RirNode> {
2815        let s = match_and_infer_4cycle(body)?;
2816        let _ = (
2817            populated_card(stats, s.rel_wx)?,
2818            populated_card(stats, s.rel_xy)?,
2819            populated_card(stats, s.rel_yz)?,
2820            populated_card(stats, s.rel_zw)?,
2821        );
2822        let est_default = stats
2823            .estimate_join_cardinality(s.rel_wx, s.rel_xy, &[1], &[0])
2824            .saturating_add(stats.estimate_join_cardinality(s.rel_yz, s.rel_zw, &[1], &[0]));
2825        let est_alt = stats
2826            .estimate_join_cardinality(s.rel_xy, s.rel_yz, &[1], &[0])
2827            .saturating_add(stats.estimate_join_cardinality(s.rel_zw, s.rel_wx, &[1], &[0]));
2828        let chosen = if est_alt < est_default {
2829            Cycle4Grouping::Alt
2830        } else {
2831            Cycle4Grouping::Default
2832        };
2833        let candidate = build_4cycle_body(&s, chosen);
2834        if format!("{:?}", candidate) == format!("{:?}", body) {
2835            return None;
2836        }
2837        Some(candidate)
2838    }
2839}
2840
2841#[cfg(test)]
2842mod selectivity_pass_tests {
2843    use super::selectivity_pass;
2844    use crate::Compiler;
2845    use xlog_stats::StatsManager;
2846
2847    fn body_snapshots(plan: &xlog_ir::ExecutionPlan) -> Vec<String> {
2848        plan.rules_by_scc
2849            .iter()
2850            .flatten()
2851            .map(|r| format!("{:?}", r.body))
2852            .collect()
2853    }
2854
2855    #[test]
2856    fn selectivity_pass_is_noop_for_triangle_plan() {
2857        let mut compiler = Compiler::new();
2858        let plan = compiler
2859            .compile("tri(X, Y, Z) :- e1(X, Y), e2(Y, Z), e3(X, Z).")
2860            .expect("compile");
2861        let before = body_snapshots(&plan);
2862        let stats = StatsManager::new();
2863        let mut plan2 = plan.clone();
2864        selectivity_pass::run(&mut plan2, &stats, &std::collections::HashMap::new());
2865        let after = body_snapshots(&plan2);
2866        assert_eq!(
2867            before, after,
2868            "selectivity_pass must preserve every triangle rule body byte-for-byte"
2869        );
2870    }
2871
2872    #[test]
2873    fn selectivity_pass_is_noop_for_4cycle_plan() {
2874        let mut compiler = Compiler::new();
2875        let plan = compiler
2876            .compile("cycle4(W, X, Y, Z) :- e1(W, X), e2(X, Y), e3(Y, Z), e4(Z, W).")
2877            .expect("compile");
2878        let before = body_snapshots(&plan);
2879        let stats = StatsManager::new();
2880        let mut plan2 = plan.clone();
2881        selectivity_pass::run(&mut plan2, &stats, &std::collections::HashMap::new());
2882        let after = body_snapshots(&plan2);
2883        assert_eq!(
2884            before, after,
2885            "selectivity_pass must preserve every 4-cycle rule body byte-for-byte"
2886        );
2887    }
2888
2889    #[test]
2890    fn selectivity_pass_is_noop_for_recursive_scc() {
2891        let mut compiler = Compiler::new();
2892        let plan = compiler
2893            .compile(
2894                "edge(1, 2). edge(2, 3). \
2895                 reach(X, Y) :- edge(X, Y). \
2896                 reach(X, Z) :- reach(X, Y), edge(Y, Z).",
2897            )
2898            .expect("compile");
2899        let before = body_snapshots(&plan);
2900        let stats = StatsManager::new();
2901        let mut plan2 = plan.clone();
2902        selectivity_pass::run(&mut plan2, &stats, &std::collections::HashMap::new());
2903        let after = body_snapshots(&plan2);
2904        assert_eq!(
2905            before, after,
2906            "selectivity_pass must preserve recursive SCC bodies byte-for-byte"
2907        );
2908    }
2909
2910    // ---------------------------------------------------------
2911    // Selectivity-driven reordering tests
2912    // ---------------------------------------------------------
2913
2914    use xlog_core::RelId;
2915    use xlog_ir::plan::{CompiledRule, PlanBuilder, Scc};
2916    use xlog_ir::rir::ProjectExpr;
2917    use xlog_ir::{ExecutionPlan, JoinType, RirNode};
2918
2919    /// Build a hand-crafted canonical lowered triangle plan
2920    /// with three Scans at RelId(1), RelId(2), RelId(3) for
2921    /// (e_xy, e_yz, e_xz). Bypasses the optimizer entirely so
2922    /// the reordering check is a clean stats-→-pair-choice
2923    /// observation, not a confounded test of optimizer plus the rewriter.
2924    ///
2925    /// Default canonical shape (Y-shared inner): inner keys
2926    /// `[1]/[0]`, outer keys `[0,3]/[0,1]`, project `[0,1,3]`.
2927    fn synth_triangle_plan() -> ExecutionPlan {
2928        let inner = RirNode::Join {
2929            left: Box::new(RirNode::Scan { rel: RelId(1) }),
2930            right: Box::new(RirNode::Scan { rel: RelId(2) }),
2931            left_keys: vec![1],
2932            right_keys: vec![0],
2933            join_type: JoinType::Inner,
2934        };
2935        let outer = RirNode::Join {
2936            left: Box::new(inner),
2937            right: Box::new(RirNode::Scan { rel: RelId(3) }),
2938            left_keys: vec![0, 3],
2939            right_keys: vec![0, 1],
2940            join_type: JoinType::Inner,
2941        };
2942        let body = RirNode::Project {
2943            input: Box::new(outer),
2944            columns: vec![
2945                ProjectExpr::Column(0),
2946                ProjectExpr::Column(1),
2947                ProjectExpr::Column(3),
2948            ],
2949        };
2950        let mut builder = PlanBuilder::new();
2951        builder.add_scc(Scc {
2952            id: 0,
2953            predicates: vec!["tri".to_string()],
2954            is_recursive: false,
2955        });
2956        builder.add_rule(
2957            0,
2958            CompiledRule {
2959                head: "tri".to_string(),
2960                body,
2961                meta: Default::default(),
2962            },
2963        );
2964        builder.build()
2965    }
2966
2967    /// Seed a `StatsManager` with three triangle-edge
2968    /// cardinalities at the conventional RelIds (1, 2, 3) used
2969    /// by `synth_triangle_plan`.
2970    fn seed_triangle_stats(c1: u64, c2: u64, c3: u64) -> StatsManager {
2971        let mut stats = StatsManager::new();
2972        for (rid, card) in [(RelId(1), c1), (RelId(2), c2), (RelId(3), c3)] {
2973            stats.register_relation(rid);
2974            stats.update_cardinality(rid, card);
2975        }
2976        stats
2977    }
2978
2979    /// Inspect the (left RelId, right RelId) of the inner Join
2980    /// in a canonical lowered triangle body. Used by selectivity reordering
2981    /// checks.
2982    ///
2983    /// After `compile()` the body is a `MultiWayJoin` whose
2984    /// `fallback` field holds the post-selectivity-pass
2985    /// pre-promotion shape — that's where the inner-pair
2986    /// signature lives. The helper unwraps `MultiWayJoin →
2987    /// fallback` if needed before drilling into the binary
2988    /// Join structure.
2989    fn inspect_triangle_inner_pair(plan: &xlog_ir::ExecutionPlan) -> Option<(RelId, RelId)> {
2990        let body = &plan.rules_by_scc.iter().flatten().next()?.body;
2991        let body = match body {
2992            xlog_ir::RirNode::MultiWayJoin { fallback, .. } => fallback.as_ref(),
2993            other => other,
2994        };
2995        let xlog_ir::RirNode::Project { input, .. } = body else {
2996            return None;
2997        };
2998        let xlog_ir::RirNode::Join { left, .. } = input.as_ref() else {
2999            return None;
3000        };
3001        let xlog_ir::RirNode::Join {
3002            left: l2,
3003            right: r2,
3004            ..
3005        } = left.as_ref()
3006        else {
3007            return None;
3008        };
3009        let xlog_ir::RirNode::Scan { rel: rel_l } = l2.as_ref() else {
3010            return None;
3011        };
3012        let xlog_ir::RirNode::Scan { rel: rel_r } = r2.as_ref() else {
3013            return None;
3014        };
3015        Some((*rel_l, *rel_r))
3016    }
3017
3018    /// Snapshot 1: cards favor `(e1, e2)` Y-shared inner.
3019    /// Triangle rule: `tri(X, Y, Z) :- e1(X, Y), e2(Y, Z), e3(X, Z)`.
3020    /// To make Y-shared smallest, give e1 + e2 small cards and
3021    /// e3 a large card so all pair products are dominated by
3022    /// pairs containing e3 — except the pair (e1, e2) which
3023    /// is the smallest product.
3024    #[test]
3025    fn selectivity_pass_picks_y_shared_inner_when_e1_e2_smallest() {
3026        let mut plan = synth_triangle_plan();
3027        // e1=10, e2=10, e3=100_000 → Y-shared (e1⋈e2) smallest.
3028        let stats = seed_triangle_stats(10, 10, 100_000);
3029        selectivity_pass::run(&mut plan, &stats, &std::collections::HashMap::new());
3030        let pair = inspect_triangle_inner_pair(&plan).expect("inner pair");
3031        // Y-shared inner = (e_xy, e_yz) = (RelId(1), RelId(2)).
3032        assert!(
3033            pair == (RelId(1), RelId(2)) || pair == (RelId(2), RelId(1)),
3034            "expected (RelId(1), RelId(2)) for Y-shared; got {:?}",
3035            pair
3036        );
3037    }
3038
3039    /// Snapshot 2: cards favor `(e1, e3)` X-shared inner.
3040    /// e1 + e3 small, e2 large.
3041    #[test]
3042    fn selectivity_pass_picks_x_shared_inner_when_e1_e3_smallest() {
3043        let mut plan = synth_triangle_plan();
3044        // e1=10, e2=100_000, e3=10 → X-shared (e1⋈e3) smallest.
3045        let stats = seed_triangle_stats(10, 100_000, 10);
3046        selectivity_pass::run(&mut plan, &stats, &std::collections::HashMap::new());
3047        let pair = inspect_triangle_inner_pair(&plan).expect("inner pair");
3048        // X-shared inner = (e_xy, e_xz) = (RelId(1), RelId(3)).
3049        assert!(
3050            pair == (RelId(1), RelId(3)) || pair == (RelId(3), RelId(1)),
3051            "expected (RelId(1), RelId(3)) for X-shared; got {:?}",
3052            pair
3053        );
3054    }
3055
3056    /// Snapshot 3: cards favor `(e2, e3)` Z-shared inner.
3057    /// e2 + e3 small, e1 large.
3058    #[test]
3059    fn selectivity_pass_picks_z_shared_inner_when_e2_e3_smallest() {
3060        let mut plan = synth_triangle_plan();
3061        // e1=100_000, e2=10, e3=10 → Z-shared (e2⋈e3) smallest.
3062        let stats = seed_triangle_stats(100_000, 10, 10);
3063        selectivity_pass::run(&mut plan, &stats, &std::collections::HashMap::new());
3064        let pair = inspect_triangle_inner_pair(&plan).expect("inner pair");
3065        // Z-shared inner = (e_yz, e_xz) = (RelId(2), RelId(3)).
3066        assert!(
3067            pair == (RelId(2), RelId(3)) || pair == (RelId(3), RelId(2)),
3068            "expected (RelId(2), RelId(3)) for Z-shared; got {:?}",
3069            pair
3070        );
3071    }
3072
3073    /// Two snapshots produce different inner pairs. Pins
3074    /// "stats drive the order, not deterministic
3075    /// canonicalization." Deterministic canonicalization that
3076    /// ignores stats CANNOT pass this gate.
3077    #[test]
3078    fn selectivity_pass_two_snapshots_produce_different_inner_pairs() {
3079        let mut plan_a = synth_triangle_plan();
3080        let stats_a = seed_triangle_stats(10, 10, 100_000); // Y-shared
3081        selectivity_pass::run(&mut plan_a, &stats_a, &std::collections::HashMap::new());
3082        let pair_a = inspect_triangle_inner_pair(&plan_a).expect("snapshot A pair");
3083
3084        let mut plan_b = synth_triangle_plan();
3085        let stats_b = seed_triangle_stats(100_000, 10, 10); // Z-shared
3086        selectivity_pass::run(&mut plan_b, &stats_b, &std::collections::HashMap::new());
3087        let pair_b = inspect_triangle_inner_pair(&plan_b).expect("snapshot B pair");
3088
3089        let normalize = |(a, b): (RelId, RelId)| -> (RelId, RelId) {
3090            if a.0 <= b.0 {
3091                (a, b)
3092            } else {
3093                (b, a)
3094            }
3095        };
3096        assert_ne!(
3097            normalize(pair_a),
3098            normalize(pair_b),
3099            "two different stats snapshots must produce different inner pairs; \
3100             got A = {:?}, B = {:?}",
3101            pair_a,
3102            pair_b
3103        );
3104    }
3105
3106    /// Fallback edge case: relation cards present but no
3107    /// column statistics. The 10% default fallback inside
3108    /// `estimate_join_cardinality` means all three pair
3109    /// estimates collapse to roughly the same ratio. The pass
3110    /// either picks SOME pair or leaves the body unchanged;
3111    /// the test is tolerant by design and documents the
3112    /// uninformative-fallback case explicitly.
3113    #[test]
3114    fn selectivity_pass_with_only_relation_cards_may_pick_arbitrary_pair() {
3115        let mut plan = synth_triangle_plan();
3116        // All three cards equal — no column stats to break ties.
3117        let stats = seed_triangle_stats(100, 100, 100);
3118        selectivity_pass::run(&mut plan, &stats, &std::collections::HashMap::new());
3119        // Either a triangle inner pair is identifiable (any of
3120        // the three) or the body stays unchanged. Both are OK.
3121        let _ = inspect_triangle_inner_pair(&plan);
3122    }
3123
3124    // ---------------------------------------------------------
3125    // 4-cycle compile-time reordering tests
3126    // ---------------------------------------------------------
3127
3128    /// Build a hand-crafted canonical lowered 4-cycle plan
3129    /// with four Scans at RelId(1), RelId(2), RelId(3), RelId(4)
3130    /// for (e_wx, e_xy, e_yz, e_zw). Bypasses the optimizer.
3131    /// Default canonical bushy shape: inner-left
3132    /// `(e_wx ⋈ e_xy)` on X, inner-right `(e_yz ⋈ e_zw)` on Z,
3133    /// outer keys `[0, 3] / [3, 0]`, project `[0, 1, 3, 5]`.
3134    fn synth_4cycle_plan() -> ExecutionPlan {
3135        let inner_left = RirNode::Join {
3136            left: Box::new(RirNode::Scan { rel: RelId(1) }),
3137            right: Box::new(RirNode::Scan { rel: RelId(2) }),
3138            left_keys: vec![1],
3139            right_keys: vec![0],
3140            join_type: JoinType::Inner,
3141        };
3142        let inner_right = RirNode::Join {
3143            left: Box::new(RirNode::Scan { rel: RelId(3) }),
3144            right: Box::new(RirNode::Scan { rel: RelId(4) }),
3145            left_keys: vec![1],
3146            right_keys: vec![0],
3147            join_type: JoinType::Inner,
3148        };
3149        let outer = RirNode::Join {
3150            left: Box::new(inner_left),
3151            right: Box::new(inner_right),
3152            left_keys: vec![0, 3],
3153            right_keys: vec![3, 0],
3154            join_type: JoinType::Inner,
3155        };
3156        let body = RirNode::Project {
3157            input: Box::new(outer),
3158            columns: vec![
3159                ProjectExpr::Column(0),
3160                ProjectExpr::Column(1),
3161                ProjectExpr::Column(3),
3162                ProjectExpr::Column(5),
3163            ],
3164        };
3165        let mut builder = PlanBuilder::new();
3166        builder.add_scc(Scc {
3167            id: 0,
3168            predicates: vec!["cyc".to_string()],
3169            is_recursive: false,
3170        });
3171        builder.add_rule(
3172            0,
3173            CompiledRule {
3174                head: "cyc".to_string(),
3175                body,
3176                meta: Default::default(),
3177            },
3178        );
3179        builder.build()
3180    }
3181
3182    fn seed_4cycle_stats(c1: u64, c2: u64, c3: u64, c4: u64) -> StatsManager {
3183        let mut stats = StatsManager::new();
3184        for (rid, card) in [
3185            (RelId(1), c1),
3186            (RelId(2), c2),
3187            (RelId(3), c3),
3188            (RelId(4), c4),
3189        ] {
3190            stats.register_relation(rid);
3191            stats.update_cardinality(rid, card);
3192        }
3193        stats
3194    }
3195
3196    /// Recover the 4-cycle inner-grouping signature: `(left_left,
3197    /// left_right, right_left, right_right)` Scan RelIds. Used
3198    /// to identify which grouping the rewriter chose.
3199    fn inspect_4cycle_grouping(
3200        plan: &xlog_ir::ExecutionPlan,
3201    ) -> Option<(RelId, RelId, RelId, RelId)> {
3202        let body = &plan.rules_by_scc.iter().flatten().next()?.body;
3203        let body = match body {
3204            xlog_ir::RirNode::MultiWayJoin { fallback, .. } => fallback.as_ref(),
3205            other => other,
3206        };
3207        let xlog_ir::RirNode::Project { input, .. } = body else {
3208            return None;
3209        };
3210        let xlog_ir::RirNode::Join { left, right, .. } = input.as_ref() else {
3211            return None;
3212        };
3213        let xlog_ir::RirNode::Join {
3214            left: ll,
3215            right: lr,
3216            ..
3217        } = left.as_ref()
3218        else {
3219            return None;
3220        };
3221        let xlog_ir::RirNode::Join {
3222            left: rl,
3223            right: rr,
3224            ..
3225        } = right.as_ref()
3226        else {
3227            return None;
3228        };
3229        let xlog_ir::RirNode::Scan { rel: r_ll } = ll.as_ref() else {
3230            return None;
3231        };
3232        let xlog_ir::RirNode::Scan { rel: r_lr } = lr.as_ref() else {
3233            return None;
3234        };
3235        let xlog_ir::RirNode::Scan { rel: r_rl } = rl.as_ref() else {
3236            return None;
3237        };
3238        let xlog_ir::RirNode::Scan { rel: r_rr } = rr.as_ref() else {
3239            return None;
3240        };
3241        Some((*r_ll, *r_lr, *r_rl, *r_rr))
3242    }
3243
3244    /// 4-cycle: cards favor Default grouping
3245    /// `(e_wx⋈e_xy on X) + (e_yz⋈e_zw on Z)`. Default cost is
3246    /// `est(WX⋈XY)+est(YZ⋈ZW) = 0.1*c1*c2 + 0.1*c3*c4`.
3247    /// Alt cost is `0.1*c2*c3 + 0.1*c4*c1`. Default smaller
3248    /// when `c1*c2 + c3*c4 < c2*c3 + c4*c1`. With
3249    /// (c1=10, c2=10, c3=100_000, c4=100_000):
3250    ///   default = 100 + 10^10 ≈ 10^10.
3251    ///   alt = 10^6 + 10^6 ≈ 2*10^6.
3252    /// → alt is smaller, so this fixture actually favors Alt.
3253    /// Use (c1=10, c2=10, c3=10, c4=10_000_000) instead:
3254    ///   default = 100 + 10^8 = 10^8.
3255    ///   alt = 100 + 10^8 = 10^8 (same).
3256    /// Need uneven c4 vs others: (c1=10, c2=10, c3=10_000_000, c4=10):
3257    ///   default = 100 + 10^8 = 10^8.
3258    ///   alt = 10^8 + 100 = 10^8 (same).
3259    /// Default favored when c1*c2 << c2*c3 AND c3*c4 << c4*c1.
3260    /// I.e., c1 small and c4 small relative to c2 and c3.
3261    /// (c1=10, c2=10_000, c3=10_000, c4=10):
3262    ///   default = 0.1*100_000 + 0.1*100_000 = 20_000.
3263    ///   alt = 0.1*100_000_000 + 0.1*100 = 10_000_010.
3264    /// → Default smaller. ✓
3265    #[test]
3266    fn selectivity_pass_4cycle_picks_default_grouping_when_corners_smallest() {
3267        let mut plan = synth_4cycle_plan();
3268        let stats = seed_4cycle_stats(10, 10_000, 10_000, 10);
3269        selectivity_pass::run(&mut plan, &stats, &std::collections::HashMap::new());
3270        let (ll, lr, rl, rr) = inspect_4cycle_grouping(&plan).expect("grouping");
3271        // Default: (e_wx, e_xy, e_yz, e_zw) = (RelId(1..4)).
3272        assert_eq!(
3273            (ll, lr, rl, rr),
3274            (RelId(1), RelId(2), RelId(3), RelId(4)),
3275            "expected Default grouping"
3276        );
3277    }
3278
3279    /// 4-cycle: cards favor Alt grouping
3280    /// `(e_xy⋈e_yz on Y) + (e_zw⋈e_wx on W)`. Alt smaller when
3281    /// `c2*c3 + c4*c1 < c1*c2 + c3*c4`. Use
3282    /// (c1=10_000, c2=10, c3=10, c4=10_000):
3283    ///   default = 0.1*100_000 + 0.1*100_000 = 20_000.
3284    ///   alt = 0.1*100 + 0.1*10^8 = 10_000_010.
3285    /// → Default still wins. Need c1*c2 LARGE and c3*c4 LARGE
3286    /// while c2*c3 SMALL and c4*c1 SMALL. Try
3287    /// (c1=10_000, c2=10_000, c3=10, c4=10):
3288    ///   default = 0.1*10^8 + 0.1*100 = 10_000_010.
3289    ///   alt = 0.1*100_000 + 0.1*100_000 = 20_000.
3290    /// → Alt smaller. ✓
3291    #[test]
3292    fn selectivity_pass_4cycle_picks_alt_grouping_when_diagonals_smallest() {
3293        let mut plan = synth_4cycle_plan();
3294        let stats = seed_4cycle_stats(10_000, 10_000, 10, 10);
3295        selectivity_pass::run(&mut plan, &stats, &std::collections::HashMap::new());
3296        let (ll, lr, rl, rr) = inspect_4cycle_grouping(&plan).expect("grouping");
3297        // Alt: (e_xy, e_yz, e_zw, e_wx) = (RelId(2), RelId(3), RelId(4), RelId(1)).
3298        assert_eq!(
3299            (ll, lr, rl, rr),
3300            (RelId(2), RelId(3), RelId(4), RelId(1)),
3301            "expected Alt grouping"
3302        );
3303    }
3304
3305    /// Same plan, two stats snapshots → two different
3306    /// 4-cycle groupings. Pins "stats drive the choice" for
3307    /// 4-cycle.
3308    #[test]
3309    fn selectivity_pass_4cycle_two_snapshots_produce_different_groupings() {
3310        let mut plan_a = synth_4cycle_plan();
3311        let stats_a = seed_4cycle_stats(10, 10_000, 10_000, 10); // Default.
3312        selectivity_pass::run(&mut plan_a, &stats_a, &std::collections::HashMap::new());
3313        let g_a = inspect_4cycle_grouping(&plan_a).expect("grouping a");
3314
3315        let mut plan_b = synth_4cycle_plan();
3316        let stats_b = seed_4cycle_stats(10_000, 10_000, 10, 10); // Alt.
3317        selectivity_pass::run(&mut plan_b, &stats_b, &std::collections::HashMap::new());
3318        let g_b = inspect_4cycle_grouping(&plan_b).expect("grouping b");
3319
3320        assert_ne!(
3321            g_a, g_b,
3322            "two different stats snapshots must produce different 4-cycle groupings; \
3323             got A = {:?}, B = {:?}",
3324            g_a, g_b
3325        );
3326    }
3327
3328    /// 4-cycle missing-stats safety floor: any unseeded
3329    /// relation → body unchanged.
3330    #[test]
3331    fn selectivity_pass_4cycle_skips_when_card_missing() {
3332        let mut plan = synth_4cycle_plan();
3333        // Only seed 3 of 4.
3334        let mut stats = StatsManager::new();
3335        for rid in [RelId(1), RelId(2), RelId(3)] {
3336            stats.register_relation(rid);
3337            stats.update_cardinality(rid, 100);
3338        }
3339        let before = format!("{:?}", plan.rules_by_scc[0][0].body);
3340        selectivity_pass::run(&mut plan, &stats, &std::collections::HashMap::new());
3341        let after = format!("{:?}", plan.rules_by_scc[0][0].body);
3342        assert_eq!(
3343            before, after,
3344            "missing-stats safety floor must leave body unchanged"
3345        );
3346    }
3347}
3348
3349#[cfg(test)]
3350mod tests {
3351    use super::*;
3352    use xlog_core::ScalarType;
3353    use xlog_ir::{ConstValue, ProjectExpr};
3354    use xlog_stats::ColumnStats;
3355
3356    fn make_stats_manager() -> Arc<StatsManager> {
3357        let mut mgr = StatsManager::new();
3358
3359        // Register test relations with realistic statistics
3360        mgr.register_relation(RelId(1));
3361        mgr.update_cardinality(RelId(1), 10_000);
3362        mgr.update_byte_size(RelId(1), 320_000); // ~32 bytes per row
3363
3364        mgr.register_relation(RelId(2));
3365        mgr.update_cardinality(RelId(2), 5_000);
3366        mgr.update_byte_size(RelId(2), 160_000);
3367
3368        mgr.register_relation(RelId(3));
3369        mgr.update_cardinality(RelId(3), 1_000);
3370        mgr.update_byte_size(RelId(3), 32_000);
3371
3372        // Add column statistics for relation 1
3373        let mut col0 = ColumnStats::new(0, ScalarType::I64);
3374        col0.update_distinct(1000);
3375        col0.update_range(0, 10000);
3376        mgr.add_column_stats(RelId(1), col0);
3377
3378        let mut col1 = ColumnStats::new(1, ScalarType::I64);
3379        col1.update_distinct(100);
3380        mgr.add_column_stats(RelId(1), col1);
3381
3382        Arc::new(mgr)
3383    }
3384
3385    #[test]
3386    fn test_optimizer_new() {
3387        let stats = make_stats_manager();
3388        let optimizer = Optimizer::new(stats);
3389
3390        assert_eq!(optimizer.config().dp_threshold, 10);
3391        assert!(optimizer.config().enable_pushdown);
3392    }
3393
3394    #[test]
3395    fn test_optimizer_with_config() {
3396        let stats = make_stats_manager();
3397        let config = OptimizerConfig {
3398            dp_threshold: 5,
3399            enable_pushdown: false,
3400            ..Default::default()
3401        };
3402        let optimizer = Optimizer::with_config(stats, config);
3403
3404        assert_eq!(optimizer.config().dp_threshold, 5);
3405        assert!(!optimizer.config().enable_pushdown);
3406    }
3407
3408    #[test]
3409    fn test_estimate_scan_cost() {
3410        let stats = make_stats_manager();
3411        let optimizer = Optimizer::new(stats);
3412
3413        let scan = RirNode::Scan { rel: RelId(1) };
3414        let cost = optimizer.estimate_cost(&scan);
3415
3416        assert_eq!(cost.rows, 10_000);
3417        assert!(cost.gpu_mem > 0);
3418        assert_eq!(cost.transfers, 0); // Data on GPU
3419    }
3420
3421    #[test]
3422    fn test_estimate_scan_cost_unknown_relation() {
3423        let stats = Arc::new(StatsManager::new());
3424        let optimizer = Optimizer::new(stats);
3425
3426        let scan = RirNode::Scan { rel: RelId(999) };
3427        let cost = optimizer.estimate_cost(&scan);
3428
3429        // Should use defaults
3430        assert_eq!(cost.rows, 1000);
3431    }
3432
3433    #[test]
3434    fn test_estimate_filter_cost() {
3435        let stats = make_stats_manager();
3436        let optimizer = Optimizer::new(stats);
3437
3438        let filter = RirNode::Filter {
3439            input: Box::new(RirNode::Scan { rel: RelId(1) }),
3440            predicate: Expr::Compare {
3441                left: Box::new(Expr::Column(0)),
3442                op: CompareOp::Eq,
3443                right: Box::new(Expr::Const(ConstValue::I64(42))),
3444            },
3445        };
3446
3447        let cost = optimizer.estimate_cost(&filter);
3448
3449        // Filter should reduce row count
3450        assert!(cost.rows < 10_000);
3451        assert!(cost.rows >= 1);
3452    }
3453
3454    #[test]
3455    fn test_estimate_join_cost() {
3456        let stats = make_stats_manager();
3457        let optimizer = Optimizer::new(stats);
3458
3459        let join = RirNode::Join {
3460            left: Box::new(RirNode::Scan { rel: RelId(1) }),
3461            right: Box::new(RirNode::Scan { rel: RelId(2) }),
3462            left_keys: vec![0],
3463            right_keys: vec![0],
3464            join_type: JoinType::Inner,
3465        };
3466
3467        let cost = optimizer.estimate_cost(&join);
3468
3469        // Should have positive estimates
3470        assert!(cost.rows > 0);
3471        assert!(cost.cpu_cost > 0.0);
3472        assert!(cost.gpu_mem > 0);
3473    }
3474
3475    #[test]
3476    fn test_estimate_join_cost_with_selectivity() {
3477        let mut mgr = StatsManager::new();
3478        mgr.register_relation(RelId(1));
3479        mgr.register_relation(RelId(2));
3480        mgr.update_cardinality(RelId(1), 1000);
3481        mgr.update_cardinality(RelId(2), 500);
3482
3483        // Record a join result to cache selectivity
3484        mgr.record_join_result(RelId(1), RelId(2), vec![0], vec![0], 500_000, 2500);
3485
3486        let optimizer = Optimizer::new(Arc::new(mgr));
3487
3488        let join = RirNode::Join {
3489            left: Box::new(RirNode::Scan { rel: RelId(1) }),
3490            right: Box::new(RirNode::Scan { rel: RelId(2) }),
3491            left_keys: vec![0],
3492            right_keys: vec![0],
3493            join_type: JoinType::Inner,
3494        };
3495
3496        let cost = optimizer.estimate_cost(&join);
3497
3498        // Should use cached selectivity for estimate
3499        assert!(cost.rows > 0);
3500    }
3501
3502    #[test]
3503    fn test_predicate_pushdown_simple_scan() {
3504        let stats = make_stats_manager();
3505        let optimizer = Optimizer::new(stats);
3506
3507        let scan = RirNode::Scan { rel: RelId(1) };
3508        let optimized = optimizer.optimize(scan);
3509
3510        // Scan should pass through unchanged
3511        assert!(matches!(optimized, RirNode::Scan { rel: RelId(1) }));
3512    }
3513
3514    #[test]
3515    fn test_predicate_pushdown_filter_on_scan() {
3516        let stats = make_stats_manager();
3517        let optimizer = Optimizer::new(stats);
3518
3519        let filter = RirNode::Filter {
3520            input: Box::new(RirNode::Scan { rel: RelId(1) }),
3521            predicate: Expr::Compare {
3522                left: Box::new(Expr::Column(0)),
3523                op: CompareOp::Eq,
3524                right: Box::new(Expr::Const(ConstValue::I64(42))),
3525            },
3526        };
3527
3528        let optimized = optimizer.optimize(filter);
3529
3530        // Filter on scan should stay in place
3531        assert!(matches!(optimized, RirNode::Filter { .. }));
3532    }
3533
3534    #[test]
3535    fn test_predicate_pushdown_merges_filters() {
3536        let stats = make_stats_manager();
3537        let optimizer = Optimizer::new(stats);
3538
3539        let nested_filter = RirNode::Filter {
3540            input: Box::new(RirNode::Filter {
3541                input: Box::new(RirNode::Scan { rel: RelId(1) }),
3542                predicate: Expr::Compare {
3543                    left: Box::new(Expr::Column(0)),
3544                    op: CompareOp::Gt,
3545                    right: Box::new(Expr::Const(ConstValue::I64(0))),
3546                },
3547            }),
3548            predicate: Expr::Compare {
3549                left: Box::new(Expr::Column(0)),
3550                op: CompareOp::Lt,
3551                right: Box::new(Expr::Const(ConstValue::I64(100))),
3552            },
3553        };
3554
3555        let optimized = optimizer.optimize(nested_filter);
3556
3557        // Filters should be merged into AND
3558        if let RirNode::Filter { predicate, .. } = optimized {
3559            assert!(matches!(predicate, Expr::And(_)));
3560        } else {
3561            panic!("Expected Filter node");
3562        }
3563    }
3564
3565    #[test]
3566    fn test_predicate_pushdown_through_project() {
3567        let stats = make_stats_manager();
3568        let optimizer = Optimizer::new(stats);
3569
3570        // Filter on projected column that's a pass-through
3571        let plan = RirNode::Filter {
3572            input: Box::new(RirNode::Project {
3573                input: Box::new(RirNode::Scan { rel: RelId(1) }),
3574                columns: vec![ProjectExpr::Column(0), ProjectExpr::Column(1)],
3575            }),
3576            predicate: Expr::Compare {
3577                left: Box::new(Expr::Column(0)),
3578                op: CompareOp::Eq,
3579                right: Box::new(Expr::Const(ConstValue::I64(42))),
3580            },
3581        };
3582
3583        let optimized = optimizer.optimize(plan);
3584
3585        // Filter should be pushed below project
3586        assert!(matches!(optimized, RirNode::Project { .. }));
3587        if let RirNode::Project { input, .. } = optimized {
3588            assert!(matches!(*input, RirNode::Filter { .. }));
3589        }
3590    }
3591
3592    #[test]
3593    fn test_predicate_pushdown_into_join() {
3594        let stats = make_stats_manager();
3595        let optimizer = Optimizer::new(stats);
3596
3597        // Filter on left side column only
3598        let plan = RirNode::Filter {
3599            input: Box::new(RirNode::Join {
3600                left: Box::new(RirNode::Scan { rel: RelId(1) }),
3601                right: Box::new(RirNode::Scan { rel: RelId(2) }),
3602                left_keys: vec![0],
3603                right_keys: vec![0],
3604                join_type: JoinType::Inner,
3605            }),
3606            predicate: Expr::Compare {
3607                left: Box::new(Expr::Column(0)), // Left side column
3608                op: CompareOp::Eq,
3609                right: Box::new(Expr::Const(ConstValue::I64(42))),
3610            },
3611        };
3612
3613        let optimized = optimizer.optimize(plan);
3614
3615        // Filter should be pushed into left side of join
3616        if let RirNode::Join { left, .. } = optimized {
3617            assert!(matches!(*left, RirNode::Filter { .. }));
3618        } else {
3619            panic!("Expected Join node");
3620        }
3621    }
3622
3623    #[test]
3624    fn test_plan_cost_total() {
3625        let cost = PlanCost {
3626            rows: 1000,
3627            cpu_cost: 100.0,
3628            gpu_mem: 1_000_000,
3629            transfers: 2,
3630        };
3631
3632        let total = cost.total_cost(100.0);
3633
3634        // cpu_cost + gpu_mem*0.001 + transfers*100
3635        // 100.0 + 1000.0 + 200.0 = 1300.0
3636        assert!((total - 1300.0).abs() < 0.001);
3637    }
3638
3639    #[test]
3640    fn test_plan_cost_then() {
3641        let cost1 = PlanCost {
3642            rows: 1000,
3643            cpu_cost: 50.0,
3644            gpu_mem: 500,
3645            transfers: 1,
3646        };
3647
3648        let cost2 = PlanCost {
3649            rows: 500,
3650            cpu_cost: 25.0,
3651            gpu_mem: 800,
3652            transfers: 1,
3653        };
3654
3655        let combined = cost1.then(cost2);
3656
3657        assert_eq!(combined.rows, 500); // Takes output rows from second
3658        assert_eq!(combined.cpu_cost, 75.0);
3659        assert_eq!(combined.gpu_mem, 800); // Peak memory
3660        assert_eq!(combined.transfers, 2);
3661    }
3662
3663    #[test]
3664    fn test_optimizer_config_default() {
3665        let config = OptimizerConfig::default();
3666
3667        assert_eq!(config.dp_threshold, 10);
3668        assert!((config.index_heat_threshold - 0.7).abs() < 0.001);
3669        assert!(config.enable_pushdown);
3670        assert!((config.default_filter_selectivity - 0.1).abs() < 0.001);
3671    }
3672
3673    #[test]
3674    fn test_should_use_greedy() {
3675        let stats = make_stats_manager();
3676        let config = OptimizerConfig {
3677            dp_threshold: 2,
3678            ..Default::default()
3679        };
3680        let optimizer = Optimizer::with_config(stats, config);
3681
3682        // Single relation: should NOT use greedy
3683        let single = RirNode::Scan { rel: RelId(1) };
3684        assert!(!optimizer.should_use_greedy(&single));
3685
3686        // Three relations: should use greedy (threshold is 2)
3687        let multi = RirNode::Join {
3688            left: Box::new(RirNode::Join {
3689                left: Box::new(RirNode::Scan { rel: RelId(1) }),
3690                right: Box::new(RirNode::Scan { rel: RelId(2) }),
3691                left_keys: vec![0],
3692                right_keys: vec![0],
3693                join_type: JoinType::Inner,
3694            }),
3695            right: Box::new(RirNode::Scan { rel: RelId(3) }),
3696            left_keys: vec![0],
3697            right_keys: vec![0],
3698            join_type: JoinType::Inner,
3699        };
3700        assert!(optimizer.should_use_greedy(&multi));
3701    }
3702
3703    #[test]
3704    fn test_recommend_indexes() {
3705        let mut mgr = StatsManager::new();
3706        mgr.register_relation(RelId(1));
3707        mgr.register_relation(RelId(2));
3708
3709        // Heat up relation 1 extensively
3710        for _ in 0..50 {
3711            mgr.record_access(RelId(1));
3712        }
3713
3714        let optimizer = Optimizer::new(Arc::new(mgr));
3715        let recommendations = optimizer.recommend_indexes();
3716
3717        assert!(recommendations.contains(&RelId(1)));
3718        assert!(!recommendations.contains(&RelId(2)));
3719    }
3720
3721    #[test]
3722    fn test_estimate_groupby_cost() {
3723        let stats = make_stats_manager();
3724        let optimizer = Optimizer::new(stats);
3725
3726        let groupby = RirNode::GroupBy {
3727            input: Box::new(RirNode::Scan { rel: RelId(1) }),
3728            key_cols: vec![0],
3729            aggs: vec![(1, xlog_core::AggOp::Sum)],
3730        };
3731
3732        let cost = optimizer.estimate_cost(&groupby);
3733
3734        // GroupBy should reduce row count
3735        assert!(cost.rows < 10_000);
3736        assert!(cost.rows >= 1);
3737    }
3738
3739    #[test]
3740    fn test_estimate_union_cost() {
3741        let stats = make_stats_manager();
3742        let optimizer = Optimizer::new(stats);
3743
3744        let union = RirNode::Union {
3745            inputs: vec![
3746                RirNode::Scan { rel: RelId(1) },
3747                RirNode::Scan { rel: RelId(2) },
3748            ],
3749        };
3750
3751        let cost = optimizer.estimate_cost(&union);
3752
3753        // Union sums row counts
3754        assert_eq!(cost.rows, 15_000); // 10000 + 5000
3755    }
3756
3757    #[test]
3758    fn test_estimate_distinct_cost() {
3759        let stats = make_stats_manager();
3760        let optimizer = Optimizer::new(stats);
3761
3762        let distinct = RirNode::Distinct {
3763            input: Box::new(RirNode::Scan { rel: RelId(1) }),
3764            key_cols: vec![0],
3765        };
3766
3767        let cost = optimizer.estimate_cost(&distinct);
3768
3769        // Distinct reduces rows
3770        assert!(cost.rows <= 10_000);
3771        assert!(cost.rows >= 1);
3772    }
3773
3774    #[test]
3775    fn test_estimate_diff_cost() {
3776        let stats = make_stats_manager();
3777        let optimizer = Optimizer::new(stats);
3778
3779        let diff = RirNode::Diff {
3780            left: Box::new(RirNode::Scan { rel: RelId(1) }),
3781            right: Box::new(RirNode::Scan { rel: RelId(2) }),
3782        };
3783
3784        let cost = optimizer.estimate_cost(&diff);
3785
3786        // Diff reduces left side
3787        assert!(cost.rows <= 10_000);
3788        assert!(cost.rows >= 1);
3789    }
3790
3791    #[test]
3792    fn test_estimate_fixpoint_cost() {
3793        let stats = make_stats_manager();
3794        let optimizer = Optimizer::new(stats);
3795
3796        let fixpoint = RirNode::Fixpoint {
3797            scc_id: 0,
3798            base: Box::new(RirNode::Scan { rel: RelId(1) }),
3799            recursive: Box::new(RirNode::Scan { rel: RelId(1) }),
3800            delta_rel: RelId(10),
3801            full_rel: RelId(11),
3802        };
3803
3804        let cost = optimizer.estimate_cost(&fixpoint);
3805
3806        // Fixpoint accumulates rows across iterations
3807        assert!(cost.rows >= 10_000);
3808    }
3809
3810    #[test]
3811    fn test_predicate_selectivity_equality() {
3812        let stats = make_stats_manager();
3813        let optimizer = Optimizer::new(stats);
3814
3815        let scan = RirNode::Scan { rel: RelId(1) };
3816
3817        // Equality predicate
3818        let eq_pred = Expr::Compare {
3819            left: Box::new(Expr::Column(0)),
3820            op: CompareOp::Eq,
3821            right: Box::new(Expr::Const(ConstValue::I64(42))),
3822        };
3823
3824        let selectivity = optimizer.estimate_predicate_selectivity(&eq_pred, &scan);
3825
3826        // With 1000 distinct values, selectivity should be ~0.001
3827        assert!(selectivity < 0.01);
3828        assert!(selectivity > 0.0);
3829    }
3830
3831    #[test]
3832    fn test_predicate_selectivity_and() {
3833        let stats = make_stats_manager();
3834        let optimizer = Optimizer::new(stats);
3835
3836        let scan = RirNode::Scan { rel: RelId(1) };
3837
3838        // AND of two predicates
3839        let and_pred = Expr::And(vec![
3840            Expr::Compare {
3841                left: Box::new(Expr::Column(0)),
3842                op: CompareOp::Gt,
3843                right: Box::new(Expr::Const(ConstValue::I64(0))),
3844            },
3845            Expr::Compare {
3846                left: Box::new(Expr::Column(0)),
3847                op: CompareOp::Lt,
3848                right: Box::new(Expr::Const(ConstValue::I64(100))),
3849            },
3850        ]);
3851
3852        let selectivity = optimizer.estimate_predicate_selectivity(&and_pred, &scan);
3853
3854        // Product of individual selectivities (0.33 * 0.33 ≈ 0.11)
3855        assert!(selectivity < 0.5);
3856        assert!(selectivity > 0.0);
3857    }
3858
3859    #[test]
3860    fn test_predicate_selectivity_not() {
3861        let stats = make_stats_manager();
3862        let optimizer = Optimizer::new(stats);
3863
3864        let scan = RirNode::Scan { rel: RelId(1) };
3865
3866        // NOT of equality
3867        let not_pred = Expr::Not(Box::new(Expr::Compare {
3868            left: Box::new(Expr::Column(0)),
3869            op: CompareOp::Eq,
3870            right: Box::new(Expr::Const(ConstValue::I64(42))),
3871        }));
3872
3873        let selectivity = optimizer.estimate_predicate_selectivity(&not_pred, &scan);
3874
3875        // NOT(equality) should have high selectivity
3876        assert!(selectivity > 0.9);
3877    }
3878
3879    #[test]
3880    fn test_join_type_semi() {
3881        let stats = make_stats_manager();
3882        let optimizer = Optimizer::new(stats);
3883
3884        let semi_join = RirNode::Join {
3885            left: Box::new(RirNode::Scan { rel: RelId(1) }),
3886            right: Box::new(RirNode::Scan { rel: RelId(2) }),
3887            left_keys: vec![0],
3888            right_keys: vec![0],
3889            join_type: JoinType::Semi,
3890        };
3891
3892        let cost = optimizer.estimate_cost(&semi_join);
3893
3894        // Semi join outputs at most left side rows
3895        assert!(cost.rows <= 10_000);
3896    }
3897
3898    #[test]
3899    fn test_join_type_anti() {
3900        let stats = make_stats_manager();
3901        let optimizer = Optimizer::new(stats);
3902
3903        let anti_join = RirNode::Join {
3904            left: Box::new(RirNode::Scan { rel: RelId(1) }),
3905            right: Box::new(RirNode::Scan { rel: RelId(2) }),
3906            left_keys: vec![0],
3907            right_keys: vec![0],
3908            join_type: JoinType::Anti,
3909        };
3910
3911        let cost = optimizer.estimate_cost(&anti_join);
3912
3913        // Anti join outputs at most left side rows
3914        assert!(cost.rows <= 10_000);
3915    }
3916
3917    #[test]
3918    fn test_pushdown_disabled() {
3919        let stats = make_stats_manager();
3920        let config = OptimizerConfig {
3921            enable_pushdown: false,
3922            ..Default::default()
3923        };
3924        let optimizer = Optimizer::with_config(stats, config);
3925
3926        // Filter that could be pushed
3927        let plan = RirNode::Filter {
3928            input: Box::new(RirNode::Filter {
3929                input: Box::new(RirNode::Scan { rel: RelId(1) }),
3930                predicate: Expr::Compare {
3931                    left: Box::new(Expr::Column(0)),
3932                    op: CompareOp::Gt,
3933                    right: Box::new(Expr::Const(ConstValue::I64(0))),
3934                },
3935            }),
3936            predicate: Expr::Compare {
3937                left: Box::new(Expr::Column(0)),
3938                op: CompareOp::Lt,
3939                right: Box::new(Expr::Const(ConstValue::I64(100))),
3940            },
3941        };
3942
3943        let optimized = optimizer.optimize(plan.clone());
3944
3945        // With pushdown disabled, structure should remain the same
3946        // (outer filter, inner filter, scan)
3947        if let RirNode::Filter { input, .. } = optimized {
3948            assert!(matches!(*input, RirNode::Filter { .. }));
3949        } else {
3950            panic!("Expected Filter node");
3951        }
3952    }
3953
3954    #[test]
3955    fn test_collect_columns() {
3956        let expr = Expr::And(vec![
3957            Expr::Compare {
3958                left: Box::new(Expr::Column(0)),
3959                op: CompareOp::Eq,
3960                right: Box::new(Expr::Column(2)),
3961            },
3962            Expr::Compare {
3963                left: Box::new(Expr::Column(1)),
3964                op: CompareOp::Gt,
3965                right: Box::new(Expr::Const(ConstValue::I64(0))),
3966            },
3967        ]);
3968
3969        let cols = Optimizer::collect_columns(&expr);
3970
3971        assert!(cols.contains(&0));
3972        assert!(cols.contains(&1));
3973        assert!(cols.contains(&2));
3974    }
3975
3976    #[test]
3977    fn test_flatten_and() {
3978        let nested = Expr::And(vec![
3979            Expr::And(vec![
3980                Expr::Compare {
3981                    left: Box::new(Expr::Column(0)),
3982                    op: CompareOp::Eq,
3983                    right: Box::new(Expr::Const(ConstValue::I64(1))),
3984                },
3985                Expr::Compare {
3986                    left: Box::new(Expr::Column(1)),
3987                    op: CompareOp::Eq,
3988                    right: Box::new(Expr::Const(ConstValue::I64(2))),
3989                },
3990            ]),
3991            Expr::Compare {
3992                left: Box::new(Expr::Column(2)),
3993                op: CompareOp::Eq,
3994                right: Box::new(Expr::Const(ConstValue::I64(3))),
3995            },
3996        ]);
3997
3998        let flattened = Optimizer::flatten_and(&nested);
3999
4000        assert_eq!(flattened.len(), 3);
4001    }
4002
4003    #[test]
4004    fn test_conjoin_single() {
4005        let single = vec![Expr::Compare {
4006            left: Box::new(Expr::Column(0)),
4007            op: CompareOp::Eq,
4008            right: Box::new(Expr::Const(ConstValue::I64(42))),
4009        }];
4010
4011        let result = Optimizer::conjoin(single);
4012
4013        assert!(matches!(result, Expr::Compare { .. }));
4014    }
4015
4016    #[test]
4017    fn test_conjoin_multiple() {
4018        let multiple = vec![
4019            Expr::Compare {
4020                left: Box::new(Expr::Column(0)),
4021                op: CompareOp::Eq,
4022                right: Box::new(Expr::Const(ConstValue::I64(1))),
4023            },
4024            Expr::Compare {
4025                left: Box::new(Expr::Column(1)),
4026                op: CompareOp::Eq,
4027                right: Box::new(Expr::Const(ConstValue::I64(2))),
4028            },
4029        ];
4030
4031        let result = Optimizer::conjoin(multiple);
4032
4033        assert!(matches!(result, Expr::And(_)));
4034    }
4035
4036    #[test]
4037    fn test_predicate_pushdown_with_schemas() {
4038        // Regression test: ensure predicate pushdown uses schemas for accurate width estimation.
4039        // Without schemas, the optimizer could incorrectly remap column indices.
4040        let stats = make_stats_manager();
4041        let mut optimizer = Optimizer::new(stats);
4042
4043        // Set up schemas: left has 3 columns, right has 3 columns
4044        let left_schema = Schema::new(vec![
4045            ("c0".to_string(), xlog_core::ScalarType::Symbol),
4046            ("c1".to_string(), xlog_core::ScalarType::Symbol),
4047            ("c2".to_string(), xlog_core::ScalarType::Symbol),
4048        ]);
4049        let right_schema = Schema::new(vec![
4050            ("c0".to_string(), xlog_core::ScalarType::Symbol),
4051            ("c1".to_string(), xlog_core::ScalarType::Symbol),
4052            ("c2".to_string(), xlog_core::ScalarType::U32),
4053        ]);
4054
4055        let mut schemas = HashMap::new();
4056        schemas.insert(RelId(1), left_schema);
4057        schemas.insert(RelId(2), right_schema);
4058        optimizer.set_schemas(schemas);
4059
4060        // Filter on Column(5) which is in the right side (left_width=3, so column 5-3=2 in right)
4061        let plan = RirNode::Filter {
4062            input: Box::new(RirNode::Join {
4063                left: Box::new(RirNode::Scan { rel: RelId(1) }),
4064                right: Box::new(RirNode::Scan { rel: RelId(2) }),
4065                left_keys: vec![0],
4066                right_keys: vec![0],
4067                join_type: JoinType::Inner,
4068            }),
4069            predicate: Expr::Compare {
4070                left: Box::new(Expr::Column(5)), // Right side column (index 5 = 3 + 2)
4071                op: CompareOp::Ge,
4072                right: Box::new(Expr::Const(ConstValue::U32(4))),
4073            },
4074        };
4075
4076        let optimized = optimizer.optimize(plan);
4077
4078        // Filter should be pushed into right side of join with Column(2) (remapped from 5-3=2)
4079        if let RirNode::Join { right, .. } = optimized {
4080            if let RirNode::Filter { predicate, .. } = *right {
4081                if let Expr::Compare { left, .. } = predicate {
4082                    if let Expr::Column(idx) = *left {
4083                        assert_eq!(
4084                            idx, 2,
4085                            "Column should be remapped to 2 (5 - left_width(3) = 2)"
4086                        );
4087                    } else {
4088                        panic!("Expected Column expression");
4089                    }
4090                } else {
4091                    panic!("Expected Compare predicate");
4092                }
4093            } else {
4094                panic!("Expected Filter on right side of join");
4095            }
4096        } else {
4097            panic!("Expected Join node");
4098        }
4099    }
4100
4101    /// Optimizer fallback arms for `MultiWayJoin`.
4102    ///
4103    /// The promoter runs after `Optimizer::optimize` in `Compiler`, so
4104    /// these arms are unreachable in production. They exist for compile
4105    /// safety and to pin the documented semantics: `optimize` returns
4106    /// the node unchanged, `estimate_width` reports the head arity from
4107    /// `output_columns`, `estimate_cost` is the sum of input costs, and
4108    /// `find_column_relation` returns `None` under the optimizer fallback.
4109    ///
4110    /// Shape-agnostic coverage extends each test below to also exercise a
4111    /// synthesized four-input `MultiWayJoin` via [`build_4input_multiway`].
4112    /// This pins shape-agnosticism: the arms must NOT hard-code
4113    /// `inputs.len() == 3` or `output_columns.len() == 3`. The four-way
4114    /// promoter path produces real four-input bodies; these tests are the
4115    /// load-bearing guard against silent regression.
4116    fn build_canonical_triangle_multiway() -> RirNode {
4117        let scan_xy = RirNode::Scan { rel: RelId(1) };
4118        let scan_yz = RirNode::Scan { rel: RelId(2) };
4119        let scan_xz = RirNode::Scan { rel: RelId(3) };
4120        let inner_join = RirNode::Join {
4121            left: Box::new(scan_xy.clone()),
4122            right: Box::new(scan_yz.clone()),
4123            left_keys: vec![1],
4124            right_keys: vec![0],
4125            join_type: JoinType::Inner,
4126        };
4127        let outer_join = RirNode::Join {
4128            left: Box::new(inner_join),
4129            right: Box::new(scan_xz.clone()),
4130            left_keys: vec![0, 3],
4131            right_keys: vec![0, 1],
4132            join_type: JoinType::Inner,
4133        };
4134        let fallback = RirNode::Project {
4135            input: Box::new(outer_join),
4136            columns: vec![
4137                ProjectExpr::Column(0),
4138                ProjectExpr::Column(1),
4139                ProjectExpr::Column(3),
4140            ],
4141        };
4142        RirNode::MultiWayJoin {
4143            inputs: vec![scan_xy, scan_yz, scan_xz],
4144            slot_vars: vec![
4145                vec![Some(0), Some(1)],
4146                vec![Some(1), Some(2)],
4147                vec![Some(0), Some(2)],
4148            ],
4149            output_columns: vec![
4150                ProjectExpr::Column(0),
4151                ProjectExpr::Column(1),
4152                ProjectExpr::Column(3),
4153            ],
4154            fallback: Box::new(fallback),
4155            plan: None,
4156            var_order: None,
4157        }
4158    }
4159
4160    /// Synthesized four-input `MultiWayJoin` for shape-agnosticism testing.
4161    /// The original promoter shape is triangle-only, so this shape never
4162    /// reaches `Optimizer` through the production pipeline; the tests below
4163    /// exercise the optimizer arms directly.
4164    ///
4165    /// Inputs reuse `RelId(1, 2, 3, 1)` — RelId(1) repeats — so the
4166    /// stats manager registered in `make_stats_manager` covers all
4167    /// four scans. Cost floor is `2*10_000 + 5_000 + 1_000 = 26_000`.
4168    fn build_4input_multiway() -> RirNode {
4169        let scans = [RelId(1), RelId(2), RelId(3), RelId(1)]
4170            .map(|rel| RirNode::Scan { rel })
4171            .to_vec();
4172        // 4-cycle slot_vars [[A,B],[B,C],[C,D],[A,D]].
4173        let slot_vars = vec![
4174            vec![Some(0u32), Some(1)],
4175            vec![Some(1u32), Some(2)],
4176            vec![Some(2u32), Some(3)],
4177            vec![Some(0u32), Some(3)],
4178        ];
4179        // 4-arity head projection (no real semantic meaning — the
4180        // synthesized fallback is a stub).
4181        let output_columns = vec![
4182            ProjectExpr::Column(0),
4183            ProjectExpr::Column(1),
4184            ProjectExpr::Column(2),
4185            ProjectExpr::Column(3),
4186        ];
4187        // Stub fallback: the optimizer arms do not execute fallback,
4188        // so any RirNode is fine. Use Unit to keep the fixture small.
4189        let fallback = RirNode::Unit;
4190        RirNode::MultiWayJoin {
4191            inputs: scans,
4192            slot_vars,
4193            output_columns,
4194            fallback: Box::new(fallback),
4195            plan: None,
4196            var_order: None,
4197        }
4198    }
4199
4200    #[test]
4201    fn optimize_returns_multiway_unchanged() {
4202        let optimizer = Optimizer::new(make_stats_manager());
4203        for node in [build_canonical_triangle_multiway(), build_4input_multiway()] {
4204            let optimized = optimizer.optimize(node.clone());
4205            match (&node, &optimized) {
4206                (
4207                    RirNode::MultiWayJoin {
4208                        inputs: a_in,
4209                        output_columns: a_out,
4210                        ..
4211                    },
4212                    RirNode::MultiWayJoin {
4213                        inputs: b_in,
4214                        output_columns: b_out,
4215                        ..
4216                    },
4217                ) => {
4218                    assert_eq!(a_in.len(), b_in.len());
4219                    assert_eq!(a_out.len(), b_out.len());
4220                }
4221                _ => panic!("optimize() must return a MultiWayJoin"),
4222            }
4223        }
4224    }
4225
4226    #[test]
4227    fn estimate_width_uses_output_columns_arity() {
4228        let optimizer = Optimizer::new(make_stats_manager());
4229        // Canonical triangle: 3 head columns.
4230        assert_eq!(
4231            optimizer.estimate_width(&build_canonical_triangle_multiway()),
4232            3
4233        );
4234        // 4-input synthesized: 4 head columns. Locks shape-
4235        // agnosticism — the arm must use output_columns.len(),
4236        // not a hard-coded 3.
4237        assert_eq!(optimizer.estimate_width(&build_4input_multiway()), 4);
4238    }
4239
4240    #[test]
4241    fn estimate_cost_sums_input_costs() {
4242        let optimizer = Optimizer::new(make_stats_manager());
4243
4244        // Canonical triangle: rels 1, 2, 3 with cardinalities
4245        // 10_000 + 5_000 + 1_000 = 16_000.
4246        let cost_tri = optimizer.estimate_cost(&build_canonical_triangle_multiway());
4247        assert!(
4248            cost_tri.rows >= 16_000,
4249            "expected cost.rows >= 16000, got {}",
4250            cost_tri.rows
4251        );
4252
4253        // 4-input synthesized: rels 1, 2, 3, 1 → 2*10_000 + 5_000 +
4254        // 1_000 = 26_000. The arm sums all four inputs; cost grows.
4255        // Locks shape-agnosticism — the arm must walk every entry
4256        // in `inputs`, not a hard-coded 3.
4257        let cost_4 = optimizer.estimate_cost(&build_4input_multiway());
4258        assert!(
4259            cost_4.rows >= 26_000,
4260            "expected 4-input cost.rows >= 26000, got {}",
4261            cost_4.rows
4262        );
4263        assert!(
4264            cost_4.rows > cost_tri.rows,
4265            "4-input cost ({}) must exceed triangle cost ({})",
4266            cost_4.rows,
4267            cost_tri.rows
4268        );
4269    }
4270
4271    #[test]
4272    fn find_column_relation_returns_none_for_multiway() {
4273        let optimizer = Optimizer::new(make_stats_manager());
4274        // Optimizer fallback guardrail: no column-to-input mapping is exposed
4275        // here. Half-mapped is more dangerous than None. The arm must return
4276        // None regardless of arity; the synthesized four-input shape catches a
4277        // future "let's just return inputs[col_idx % len]" patch.
4278        for node in [build_canonical_triangle_multiway(), build_4input_multiway()] {
4279            for col in 0..node.referenced_relations().len() {
4280                assert!(
4281                    optimizer.find_column_relation(&node, col).is_none(),
4282                    "find_column_relation must return None for any \
4283                     MultiWayJoin column (col={})",
4284                    col,
4285                );
4286            }
4287        }
4288    }
4289}