Skip to main content

xlog_logic/
compile.rs

1//! Compilation pipeline for XLOG programs
2//!
3//! This module compiles the core XLOG AST into execution plans. The compilation
4//! process consists of:
5//!
6//! 1. **Parsing**: Convert source text to AST (`parser::parse_program`)
7//! 2. **Stratification**: Analyze negation/aggregation dependencies (`stratify::stratify`)
8//! 3. **Lowering**: Transform AST to Relational IR (`lower::Lowerer::lower_program`)
9//!
10//! The `Compiler` struct orchestrates these phases and provides a single entry
11//! point via the `compile` method. User-defined functions are a source-level
12//! normalization step: callers using this low-level compiler must expand them
13//! before compilation. Execution-facing `LogicProgram` APIs perform that
14//! normalization for parsed and source inputs.
15
16use std::path::{Path, PathBuf};
17
18use xlog_core::{Result, XlogError};
19use xlog_ir::{ExecutionPlan, GeneratedQueryRuleProvenance};
20use xlog_stats::{StatsManager, StatsSnapshot};
21
22use crate::compiler_config::CompilerConfig;
23use crate::list_normalize::normalize_list_builtins_owned;
24use crate::lower::Lowerer;
25use crate::magic_sets::rewrite_magic_sets_owned;
26use crate::meta_normalize::normalize_meta_builtins_owned;
27use crate::module::ModuleError;
28use crate::optimizer::Optimizer;
29use crate::parser::parse_program;
30use crate::resolver::ModuleResolver;
31use crate::stratify::stratify;
32use crate::{BodyLiteral, Program, Query, Rule as AstRule, Term};
33
34/// The XLOG compiler orchestrates the full compilation pipeline.
35///
36/// This is the core AST-to-RIR compiler. It does not resolve imports or expand
37/// user-defined functions; callers must perform those source-level steps first,
38/// or use the execution-facing `LogicProgram` compilation APIs.
39///
40/// # Example
41///
42/// ```ignore
43/// use xlog_logic::compile::Compiler;
44///
45/// let mut compiler = Compiler::new();
46/// let plan = compiler.compile(r#"
47///     edge(1, 2).
48///     edge(2, 3).
49///     reach(X, Y) :- edge(X, Y).
50///     reach(X, Z) :- reach(X, Y), edge(Y, Z).
51/// "#)?;
52/// ```
53pub struct Compiler {
54    lowerer: Lowerer,
55}
56
57use std::collections::{HashMap, HashSet};
58use std::sync::Arc;
59use xlog_core::{RelId, Schema};
60
61impl Default for Compiler {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl Compiler {
68    /// Create a new compiler instance.
69    pub fn new() -> Self {
70        Self {
71            lowerer: Lowerer::new(),
72        }
73    }
74
75    /// Set the maximum active rules for TensorMaskedJoin (16..=128).
76    pub fn set_max_active_rules(&mut self, max: usize) {
77        self.lowerer.set_max_active_rules(max);
78    }
79
80    /// Compile XLOG source code into an execution plan.
81    ///
82    /// This is the main entry point for core compilation. It chains together:
83    /// 1. Parsing (source → AST)
84    /// 2. Stratification (analyze dependencies, check for cycles)
85    /// 3. Lowering (AST → Relational IR execution plan)
86    ///
87    /// Function definitions and calls must already be expanded into the core
88    /// language. Use an execution-facing `LogicProgram` API when source-level
89    /// import resolution and function normalization are required.
90    ///
91    /// # Arguments
92    ///
93    /// * `source` - The XLOG source code as a string
94    ///
95    /// # Returns
96    ///
97    /// * `Ok(ExecutionPlan)` - The compiled execution plan ready for execution
98    /// * `Err(XlogError)` - If any compilation phase fails:
99    ///   - `XlogError::Parse` - Syntax errors in the source
100    ///   - `XlogError::StratificationCycle` - Unstratifiable negation/aggregation
101    ///   - `XlogError::Compilation` - Other semantic errors
102    ///
103    /// # Example
104    ///
105    /// ```ignore
106    /// let mut compiler = Compiler::new();
107    ///
108    /// // Compile a simple transitive closure program
109    /// let plan = compiler.compile(r#"
110    ///     edge(1, 2).
111    ///     edge(2, 3).
112    ///     reach(X, Y) :- edge(X, Y).
113    ///     reach(X, Z) :- reach(X, Y), edge(Y, Z).
114    /// "#)?;
115    ///
116    /// // The plan can now be executed by xlog-runtime
117    /// ```
118    pub fn compile(&mut self, source: &str) -> Result<ExecutionPlan> {
119        self.compile_with_stats_snapshot(source, None)
120    }
121
122    /// Compile XLOG source code into an execution plan, optionally seeding the optimizer
123    /// with a runtime statistics snapshot.
124    ///
125    /// This entry point delegates through the composable config-aware API
126    /// with `CompilerConfig::default()`, which preserves existing triangle,
127    /// 4-cycle, recursive, and selectivity-aware dispatch behavior
128    /// bit-identically.
129    pub fn compile_with_stats_snapshot(
130        &mut self,
131        source: &str,
132        stats_snapshot: Option<&StatsSnapshot>,
133    ) -> Result<ExecutionPlan> {
134        self.compile_with_config_and_stats_snapshot(
135            source,
136            &CompilerConfig::default(),
137            stats_snapshot,
138        )
139    }
140
141    /// Composable entry point that accepts a `CompilerConfig`.
142    ///
143    /// Default-config callers should keep using `compile()` /
144    /// `compile_with_stats_snapshot()`. This entry point exists so callers can
145    /// enable the variable-ordering cost model per call without an environment
146    /// override.
147    pub fn compile_with_config_and_stats_snapshot(
148        &mut self,
149        source: &str,
150        config: &CompilerConfig,
151        stats_snapshot: Option<&StatsSnapshot>,
152    ) -> Result<ExecutionPlan> {
153        let program = parse_program(source)?;
154        self.compile_owned_program_with_config_and_stats_snapshot(program, config, stats_snapshot)
155    }
156
157    /// Compile a parsed XLOG program into an execution plan.
158    ///
159    /// This is useful for callers that want to inspect the AST (facts, queries,
160    /// constraints) while compiling without reparsing. The parsed program must
161    /// already be normalized to the core language, including function expansion.
162    pub fn compile_program(&mut self, program: &Program) -> Result<ExecutionPlan> {
163        self.compile_program_with_stats_snapshot(program, None)
164    }
165
166    /// Compile a program whose authored constraint identities were prepared at
167    /// the outer full-program boundary.
168    pub fn compile_prepared_program(&mut self, program: &Program) -> Result<ExecutionPlan> {
169        self.compile_prepared_program_with_stats_snapshot(program, None)
170    }
171
172    /// Compile a parsed XLOG program into an execution plan, optionally seeding the optimizer.
173    ///
174    /// Delegates to [`Self::compile_program_with_config_and_stats_snapshot`]
175    /// with `CompilerConfig::default()`.
176    pub fn compile_program_with_stats_snapshot(
177        &mut self,
178        program: &Program,
179        stats_snapshot: Option<&StatsSnapshot>,
180    ) -> Result<ExecutionPlan> {
181        self.compile_program_with_config_and_stats_snapshot(
182            program,
183            &CompilerConfig::default(),
184            stats_snapshot,
185        )
186    }
187
188    /// Compile an already prepared program with an optional statistics snapshot.
189    pub fn compile_prepared_program_with_stats_snapshot(
190        &mut self,
191        program: &Program,
192        stats_snapshot: Option<&StatsSnapshot>,
193    ) -> Result<ExecutionPlan> {
194        self.compile_prepared_program_with_config_and_stats_snapshot(
195            program,
196            &CompilerConfig::default(),
197            stats_snapshot,
198        )
199    }
200
201    /// Validate a parsed program through the production lowering contracts without
202    /// requiring the dependency graph to be stratifiable.
203    ///
204    /// This is used by epistemic preparation, where a supported negated modal cycle
205    /// must reach well-founded execution rather than fail ordinary stratification.
206    /// The normal preprocessing, negation-safety, schema/type, constant, arithmetic,
207    /// and projection checks still run unchanged.
208    pub(crate) fn validate_program_without_stratification(
209        &mut self,
210        program: &Program,
211    ) -> Result<()> {
212        program.validate_prepared_authored_constraint_identity()?;
213        let program = run_frontend_passes(program.clone())?;
214        self.lowerer.validate_program_without_plan(&program)
215    }
216
217    /// Composable program-level entry point.
218    ///
219    /// `config` is currently consumed only by the promoter when it wires the
220    /// variable-ordering cost model. With `CompilerConfig::default()`, the
221    /// promoter keeps the default variable order.
222    pub fn compile_program_with_config_and_stats_snapshot(
223        &mut self,
224        program: &Program,
225        config: &CompilerConfig,
226        stats_snapshot: Option<&StatsSnapshot>,
227    ) -> Result<ExecutionPlan> {
228        self.compile_owned_program_with_config_and_stats_snapshot(
229            program.clone(),
230            config,
231            stats_snapshot,
232        )
233    }
234
235    /// [`Self::compile_program_with_config_and_stats_snapshot`] for a program
236    /// the caller hands over by value (no clone of the AST).
237    pub fn compile_owned_program_with_config_and_stats_snapshot(
238        &mut self,
239        mut program: Program,
240        config: &CompilerConfig,
241        stats_snapshot: Option<&StatsSnapshot>,
242    ) -> Result<ExecutionPlan> {
243        if program.authored_constraint_source_bound.is_some() {
244            program.validate_prepared_authored_constraint_identity()?;
245        } else {
246            program.prepare_authored_constraint_identity_at_root()?;
247        }
248        self.compile_prepared_owned_program_with_config_and_stats_snapshot(
249            program,
250            config,
251            stats_snapshot,
252        )
253    }
254
255    /// Config-aware compilation for an already prepared program.
256    pub fn compile_prepared_program_with_config_and_stats_snapshot(
257        &mut self,
258        program: &Program,
259        config: &CompilerConfig,
260        stats_snapshot: Option<&StatsSnapshot>,
261    ) -> Result<ExecutionPlan> {
262        self.compile_prepared_owned_program_with_config_and_stats_snapshot(
263            program.clone(),
264            config,
265            stats_snapshot,
266        )
267    }
268
269    /// [`Self::compile_prepared_program_with_config_and_stats_snapshot`] for a
270    /// program handed over by value.
271    ///
272    /// The frontend passes (desugar → meta → list → magic sets) each take and
273    /// return the `Program` by value, so a fact-heavy AST is never cloned
274    /// between passes; the `&Program` entry points clone exactly once.
275    pub fn compile_prepared_owned_program_with_config_and_stats_snapshot(
276        &mut self,
277        program: Program,
278        config: &CompilerConfig,
279        stats_snapshot: Option<&StatsSnapshot>,
280    ) -> Result<ExecutionPlan> {
281        program.validate_prepared_authored_constraint_identity()?;
282        let generated_query_heads = generated_query_heads_for_program(&program)?;
283        let program = run_frontend_passes(program)?;
284
285        // Phase 2: Stratify (analyze dependencies, detect cycles)
286        let strata = stratify(&program).map_err(map_stratification_to_naf_error)?;
287
288        // Convert strata to the format expected by the lowerer
289        let strata_preds: Vec<Vec<String>> = strata.into_iter().map(|s| s.predicates).collect();
290
291        // Phase 3: Lower AST to execution plan
292        self.lowerer.set_strata(strata_preds);
293
294        // If we have predicate names for the snapshot, use them to seed lowering-time
295        // join ordering with better cardinality estimates.
296        let mut cardinality_hints: HashMap<String, u64> = HashMap::new();
297        if let Some(snapshot) = stats_snapshot {
298            if !snapshot.rel_names.is_empty() {
299                let rel_name_by_id: HashMap<RelId, &str> = snapshot
300                    .rel_names
301                    .iter()
302                    .map(|(id, name)| (*id, name.as_str()))
303                    .collect();
304                for rel in &snapshot.relations {
305                    if let Some(name) = rel_name_by_id.get(&rel.rel_id) {
306                        cardinality_hints.insert((*name).to_string(), rel.cardinality);
307                    }
308                }
309            }
310        }
311        self.lowerer.set_cardinality_hints(cardinality_hints);
312
313        let mut plan = self.lowerer.lower_program(&program)?;
314
315        // Phase 4: Optimize (predicate pushdown + cost-aware rewrites)
316        //
317        // Seed statistics with any known fact cardinalities so cost estimation has
318        // at least a baseline for EDB relations.
319        let mut mgr = StatsManager::new();
320        let mut fact_counts: HashMap<String, u64> = HashMap::new();
321        for fact in program.facts() {
322            *fact_counts.entry(fact.head.predicate.clone()).or_insert(0) += 1;
323        }
324
325        for (pred, rel_id) in self.lowerer.rel_ids() {
326            mgr.register_relation(*rel_id);
327            let rows = fact_counts.get(pred).copied().unwrap_or(0);
328            if rows > 0 {
329                mgr.update_cardinality(*rel_id, rows);
330                if let Some(schema) = self.lowerer.schemas().get(pred) {
331                    mgr.update_byte_size(*rel_id, rows * schema.row_size_bytes() as u64);
332                }
333            }
334        }
335
336        if let Some(snapshot) = stats_snapshot {
337            if snapshot.rel_names.is_empty() {
338                mgr.merge_snapshot(snapshot);
339            } else {
340                let rel_name_by_id: HashMap<RelId, &str> = snapshot
341                    .rel_names
342                    .iter()
343                    .map(|(id, name)| (*id, name.as_str()))
344                    .collect();
345
346                for rel in &snapshot.relations {
347                    let Some(pred) = rel_name_by_id.get(&rel.rel_id) else {
348                        continue;
349                    };
350                    let Some(rel_id) = self.lowerer.rel_ids().get(*pred) else {
351                        continue;
352                    };
353
354                    let mut remapped = rel.clone();
355                    remapped.rel_id = *rel_id;
356
357                    if let Some(schema) = self.lowerer.schemas().get(*pred) {
358                        remapped.column_stats.retain(|col| {
359                            col.col_idx < schema.arity()
360                                && schema.column_type(col.col_idx) == Some(col.dtype)
361                        });
362                    } else {
363                        remapped.column_stats.clear();
364                    }
365
366                    mgr.register_relation(*rel_id);
367                    if let Some(stats) = mgr.get_relation_stats_mut(*rel_id) {
368                        *stats = remapped;
369                    }
370                }
371
372                for js in &snapshot.join_selectivities {
373                    if js.left_keys.len() != js.right_keys.len() {
374                        continue;
375                    }
376
377                    let Some(left_pred) = rel_name_by_id.get(&js.left_rel) else {
378                        continue;
379                    };
380                    let Some(right_pred) = rel_name_by_id.get(&js.right_rel) else {
381                        continue;
382                    };
383                    let Some(&left_id) = self.lowerer.rel_ids().get(*left_pred) else {
384                        continue;
385                    };
386                    let Some(&right_id) = self.lowerer.rel_ids().get(*right_pred) else {
387                        continue;
388                    };
389
390                    let Some(left_schema) = self.lowerer.schemas().get(*left_pred) else {
391                        continue;
392                    };
393                    let Some(right_schema) = self.lowerer.schemas().get(*right_pred) else {
394                        continue;
395                    };
396                    if js.left_keys.iter().any(|&k| k >= left_schema.arity())
397                        || js.right_keys.iter().any(|&k| k >= right_schema.arity())
398                    {
399                        continue;
400                    }
401
402                    mgr.set_join_selectivity(
403                        left_id,
404                        right_id,
405                        js.left_keys.clone(),
406                        js.right_keys.clone(),
407                        js.selectivity,
408                    );
409                }
410            }
411        }
412
413        // Build schemas by RelId for the optimizer
414        let schemas_by_rel_id: HashMap<RelId, Schema> = self
415            .lowerer
416            .rel_ids()
417            .iter()
418            .filter_map(|(pred, rel_id)| {
419                self.lowerer
420                    .schemas()
421                    .get(pred)
422                    .map(|schema| (*rel_id, schema.clone()))
423            })
424            .collect();
425
426        let stats_arc = Arc::new(mgr);
427
428        crate::optimizer::helper_split_pass::run(
429            &mut plan,
430            &schemas_by_rel_id,
431            &stats_arc,
432            |schema| self.lowerer.create_helper_relation(schema),
433        );
434
435        let schemas_by_rel_id: HashMap<RelId, Schema> = self
436            .lowerer
437            .rel_ids()
438            .iter()
439            .filter_map(|(pred, rel_id)| {
440                self.lowerer
441                    .schemas()
442                    .get(pred)
443                    .map(|schema| (*rel_id, schema.clone()))
444            })
445            .collect();
446
447        let mut optimizer = Optimizer::new(Arc::clone(&stats_arc));
448        optimizer.set_schemas(schemas_by_rel_id);
449        for rules in &mut plan.rules_by_scc {
450            for rule in rules {
451                let body = std::mem::replace(&mut rule.body, xlog_ir::RirNode::Unit);
452                rule.body = optimizer.optimize(body);
453            }
454        }
455
456        // Selectivity-aware reordering pass. Runs BETWEEN the optimizer loop
457        // and promote_multiway.
458        // Locked compile-pipeline ordering:
459        //   lower → helper_split_pass → optimizer → selectivity_pass → promote_multiway
460        //
461        // Takes `rel_ids` so per-body Scans can be resolved against
462        // `StatsManager`. Behavior on empty stats / unseeded relations is
463        // no-op (safety floor).
464        crate::optimizer::selectivity_pass::run(&mut plan, &stats_arc, self.lowerer.rel_ids());
465
466        // Promote eligible triangle subtrees to RirNode::MultiWayJoin. Runs
467        // *after* the optimizer so the optimizer never has to learn the new
468        // variant. Fallback identity preserves binary-join semantics on
469        // dispatch decline.
470        //
471        // Pass the lowerer's predicate→RelId map so the promoter can gate
472        // recursive-SCC bodies on the count of in-SCC Scans (≤ 1 = promote,
473        // ≥ 2 = skip).
474        //
475        // Also pass `&stats_arc` and the caller-provided `&CompilerConfig`.
476        // With `CompilerConfig::default()` (`Disabled`), the promoter never
477        // sets `var_order` and default dispatch is bit-identical.
478        crate::promote::promote_multiway(&mut plan, self.lowerer.rel_ids(), &stats_arc, config);
479
480        let schemas_by_rel_id: HashMap<RelId, Schema> = self
481            .lowerer
482            .rel_ids()
483            .iter()
484            .filter_map(|(pred, rel_id)| {
485                self.lowerer
486                    .schemas()
487                    .get(pred)
488                    .map(|schema| (*rel_id, schema.clone()))
489            })
490            .collect();
491
492        crate::optimizer::helper_split_pass::run_kclique_specs(
493            &mut plan,
494            &schemas_by_rel_id,
495            |schema| self.lowerer.create_helper_relation(schema),
496        );
497
498        plan.generated_query_rules = generated_query_heads
499            .iter()
500            .enumerate()
501            .map(|(query_index, head)| {
502                let positions = plan
503                    .rules_by_scc
504                    .iter()
505                    .enumerate()
506                    .flat_map(|(scc_index, rules)| {
507                        rules
508                            .iter()
509                            .enumerate()
510                            .filter_map(move |(rule_index, rule)| {
511                                (rule.head == *head).then_some((scc_index, rule_index))
512                            })
513                    })
514                    .collect::<Vec<_>>();
515                let [(scc_index, rule_index)] = positions.as_slice() else {
516                    return Err(XlogError::Compilation(format!(
517                        "generated query head {head} must have exactly one compiled rule, found {}",
518                        positions.len()
519                    )));
520                };
521                Ok(GeneratedQueryRuleProvenance {
522                    query_index,
523                    scc_index: *scc_index,
524                    rule_index: *rule_index,
525                })
526            })
527            .collect::<Result<Vec<_>>>()?;
528
529        Ok(plan)
530    }
531
532    /// Reset the compiler state for a fresh compilation.
533    ///
534    /// This creates a new lowerer, clearing any cached schemas or relation IDs
535    /// from previous compilations.
536    pub fn reset(&mut self) {
537        self.lowerer = Lowerer::new();
538    }
539
540    /// Get the mapping from predicate names to relation IDs after compilation.
541    ///
542    /// This mapping is needed to register relations in the executor with
543    /// the correct RelIds.
544    pub fn rel_ids(&self) -> &HashMap<String, RelId> {
545        self.lowerer.rel_ids()
546    }
547
548    /// Get the inferred schemas for predicates after compilation.
549    ///
550    /// These schemas are needed to create GPU buffers with correct column types.
551    pub fn schemas(&self) -> &HashMap<String, Schema> {
552        self.lowerer.schemas()
553    }
554}
555
556fn generated_query_heads_for_program(program: &Program) -> Result<Vec<String>> {
557    (0..program.queries.len())
558        .map(|query_index| {
559            let generated_head = format!("__xlog_query_{query_index}");
560            let authored_collision = program
561                .predicates
562                .iter()
563                .any(|declaration| declaration.name == generated_head)
564                || program
565                    .rules
566                    .iter()
567                    .any(|rule| rule.head.predicate == generated_head)
568                || program
569                    .learnable_rules
570                    .iter()
571                    .any(|rule| rule.head.predicate == generated_head);
572            if authored_collision {
573                return Err(XlogError::Compilation(format!(
574                    "authored relation {generated_head} collides with generated query head"
575                )));
576            }
577            Ok(generated_head)
578        })
579        .collect()
580}
581
582/// The source-level frontend passes shared by compilation and validation:
583/// desugar queries/constraints → meta normalization → list normalization →
584/// magic sets → negation-safety check. All by value, no AST clones.
585fn run_frontend_passes(program: Program) -> Result<Program> {
586    let program = desugar_queries_and_constraints(program)?;
587    let program = normalize_meta_builtins_owned(program)?;
588    let program = normalize_list_builtins_owned(program)?;
589    let program = rewrite_magic_sets_owned(program)?.program;
590    validate_negation_safety(&program)?;
591    Ok(program)
592}
593
594fn desugar_queries_and_constraints(program: Program) -> Result<Program> {
595    let mut out = program;
596
597    // Constraints: `:- body.` becomes `__xlog_constraint_i(1) :- body.`
598    for constraint in &out.constraints {
599        let authored_index = constraint.require_authored_index()?;
600        let pred = format!("__xlog_constraint_{authored_index}");
601        out.rules.push(AstRule {
602            head: crate::ast::Atom {
603                predicate: pred,
604                terms: vec![Term::Integer(1)],
605            },
606            body: constraint.body.clone(),
607        });
608    }
609
610    // Queries: `?- atom.` becomes `__xlog_query_i(Vars...) :- atom.`
611    for (i, Query { atom }) in out.queries.iter().enumerate() {
612        let pred = format!("__xlog_query_{}", i);
613
614        let mut head_terms: Vec<Term> = Vec::new();
615        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
616
617        for term in &atom.terms {
618            for name in term.variables() {
619                if seen.insert(name) {
620                    head_terms.push(Term::Variable(name.to_string()));
621                }
622            }
623        }
624
625        if head_terms.is_empty() {
626            head_terms.push(Term::Integer(1));
627        }
628
629        out.rules.push(AstRule {
630            head: crate::ast::Atom {
631                predicate: pred,
632                terms: head_terms,
633            },
634            body: vec![BodyLiteral::Positive(atom.clone())],
635        });
636    }
637
638    Ok(out)
639}
640
641fn validate_negation_safety(program: &Program) -> Result<()> {
642    for rule in &program.rules {
643        validate_body_naf_safety(&rule.body, &format!("rule {}", rule.head.predicate))?;
644    }
645    for constraint in &program.constraints {
646        validate_body_naf_safety(
647            &constraint.body,
648            &format!("constraint {}", constraint.require_authored_index()?),
649        )?;
650    }
651    for (idx, learnable) in program.learnable_rules.iter().enumerate() {
652        validate_body_naf_safety(&learnable.body, &format!("learnable rule {}", idx))?;
653    }
654    Ok(())
655}
656
657fn validate_body_naf_safety(body: &[BodyLiteral], context: &str) -> Result<()> {
658    let mut bound: HashSet<String> = HashSet::new();
659    for lit in body {
660        match lit {
661            BodyLiteral::Positive(atom) => {
662                for name in atom.variables() {
663                    bound.insert(name.to_string());
664                }
665            }
666            BodyLiteral::Negated(atom) => {
667                for name in atom.variables() {
668                    if !bound.contains(name) {
669                        return Err(naf_error(format!(
670                            "unbound variable {} in negated atom {}/{} in {}; bind it before not with a positive atom or deterministic is expression, or use '_' for existential positions",
671                            name,
672                            atom.predicate,
673                            atom.arity(),
674                            context
675                        )));
676                    }
677                }
678            }
679            BodyLiteral::IsExpr(is_expr) => {
680                bound.insert(is_expr.target.clone());
681            }
682            BodyLiteral::Epistemic(_) => {}
683            BodyLiteral::Comparison(_) | BodyLiteral::Univ(_) => {}
684        }
685    }
686    Ok(())
687}
688
689fn map_stratification_to_naf_error(err: XlogError) -> XlogError {
690    match err {
691        XlogError::StratificationCycle(cycle) => naf_error(format!(
692            "deterministic not atom must be stratified; cycle through negation or aggregation: {}",
693            cycle.join(" -> ")
694        )),
695        other => other,
696    }
697}
698
699fn naf_error(message: impl Into<String>) -> XlogError {
700    XlogError::Compilation(format!("negation safety error: {}", message.into()))
701}
702
703/// Convenience function to compile source in one call.
704///
705/// This creates a short-lived compiler and compiles the source.
706/// For multiple compilations, prefer creating a `Compiler` instance directly.
707/// Function definitions and calls must already be expanded; execution-facing
708/// `LogicProgram` APIs own source normalization and import resolution.
709///
710/// # Example
711///
712/// ```ignore
713/// use xlog_logic::compile::compile;
714///
715/// let plan = compile("edge(1, 2). reach(X, Y) :- edge(X, Y).")?;
716/// ```
717pub fn compile(source: &str) -> Result<ExecutionPlan> {
718    let mut compiler = Compiler::new();
719    compiler.compile(source)
720}
721
722/// Load modules for an entry source file.
723///
724/// This function:
725/// 1. Loads the entry module from the exact supplied path
726/// 2. Loads all direct and transitive `.xlog` module dependencies
727///
728/// # Arguments
729///
730/// * `entry_file` - Path to the entry source file
731/// * `search_paths` - Additional directories to search for modules
732///
733/// # Returns
734///
735/// The loaded module resolver with all dependencies resolved, or an error
736/// if module resolution fails.
737pub fn load_modules(
738    entry_file: &Path,
739    search_paths: Vec<PathBuf>,
740) -> std::result::Result<ModuleResolver, ModuleError> {
741    let mut resolver = ModuleResolver::new(search_paths);
742    resolver.load_entry_file(entry_file)?;
743
744    Ok(resolver)
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use xlog_core::ScalarType;
751    use xlog_ir::RirNode;
752    use xlog_stats::ColumnStats;
753    use xlog_stats::RelationStats;
754    use xlog_stats::StatsManager;
755
756    #[test]
757    fn test_compiler_new() {
758        let compiler = Compiler::new();
759        // Just verify it can be created
760        drop(compiler);
761    }
762
763    #[test]
764    fn test_compile_fact() {
765        let mut compiler = Compiler::new();
766        let result = compiler.compile("edge(1, 2).");
767        assert!(result.is_ok(), "Failed to compile fact: {:?}", result.err());
768    }
769
770    #[test]
771    fn test_compile_simple_rule() {
772        let mut compiler = Compiler::new();
773        let result = compiler.compile(
774            r#"
775            edge(1, 2).
776            reach(X, Y) :- edge(X, Y).
777        "#,
778        );
779        assert!(
780            result.is_ok(),
781            "Failed to compile simple rule: {:?}",
782            result.err()
783        );
784
785        let plan = result.unwrap();
786        assert!(!plan.sccs.is_empty(), "Expected at least one SCC");
787    }
788
789    #[test]
790    fn compiler_records_each_program_query_at_its_compiled_rule_position() {
791        let plan = Compiler::new()
792            .compile(
793                r#"
794                    source(a).
795                    ?- source(X).
796                    ?- source(a).
797                "#,
798            )
799            .expect("queries should compile");
800
801        assert_eq!(plan.generated_query_rules.len(), 2);
802        for (query_index, provenance) in plan.generated_query_rules.iter().enumerate() {
803            assert_eq!(provenance.query_index, query_index);
804            let rule = &plan.rules_by_scc[provenance.scc_index][provenance.rule_index];
805            assert_eq!(rule.head, format!("__xlog_query_{query_index}"));
806        }
807    }
808
809    #[test]
810    fn compiler_does_not_infer_query_provenance_from_an_authored_prefix() {
811        let mut program = parse_program(
812            r#"
813                source(a).
814                authored(X) :- source(X).
815            "#,
816        )
817        .expect("authored program should parse");
818        program
819            .rules
820            .iter_mut()
821            .find(|rule| rule.head.predicate == "authored")
822            .expect("authored rule should exist")
823            .head
824            .predicate = "__xlog_query_authored".to_string();
825
826        let plan = Compiler::new()
827            .compile_program(&program)
828            .expect("authored prefix should remain an ordinary rule");
829        assert!(plan.generated_query_rules.is_empty());
830    }
831
832    #[test]
833    fn compiler_rejects_an_authored_exact_generated_query_head_collision() {
834        let mut program = parse_program(
835            r#"
836                source(a).
837                authored(X) :- source(X).
838                ?- source(X).
839            "#,
840        )
841        .expect("collision witness should parse");
842        program
843            .rules
844            .iter_mut()
845            .find(|rule| rule.head.predicate == "authored")
846            .expect("authored rule should exist")
847            .head
848            .predicate = "__xlog_query_0".to_string();
849
850        let error = Compiler::new()
851            .compile_program(&program)
852            .expect_err("authored exact query-head collisions must fail compilation");
853        assert!(
854            error
855                .to_string()
856                .contains("authored relation __xlog_query_0 collides with generated query head"),
857            "unexpected collision error: {error}"
858        );
859    }
860
861    #[test]
862    fn compiler_reports_bound_is_target_before_arithmetic_type_mismatch() {
863        let error = Compiler::new()
864            .compile(
865                r#"
866                val(10).
867                bad(Z) :- val(Z), Z is Z + 1.
868                ?- bad(Z).
869                "#,
870            )
871            .expect_err("an already-bound is-expression target must be rejected");
872
873        assert_eq!(
874            error.to_string(),
875            "Compilation error: Variable Z already bound; 'is' requires fresh variable"
876        );
877    }
878
879    #[test]
880    fn compiler_rejects_is_target_bound_by_an_earlier_is_expression() {
881        let error = Compiler::new()
882            .compile(
883                r#"
884                val(10).
885                bad(Z) :- val(X), Z is X + X, Z is X + X.
886                ?- bad(Z).
887                "#,
888            )
889            .expect_err("a prior is-expression target must stay bound");
890
891        assert_eq!(
892            error.to_string(),
893            "Compilation error: Variable Z already bound; 'is' requires fresh variable"
894        );
895    }
896
897    #[test]
898    fn compiler_accepts_fresh_chained_is_targets() {
899        Compiler::new()
900            .compile(
901                r#"
902                val(10).
903                good(Z) :- val(X), Y is X + X, Z is Y + Y.
904                ?- good(Z).
905                "#,
906            )
907            .expect("distinct source-ordered is-expression targets must remain valid");
908    }
909
910    #[test]
911    fn compiler_reports_unbound_is_input_before_a_later_duplicate_target() {
912        let error = Compiler::new()
913            .compile(
914                r#"
915                seed(1).
916                bad(Z) :- seed(X), Z is U + 1, Z is 1 + 1.
917                ?- bad(Z).
918                "#,
919            )
920            .expect_err("an unreachable later binding must not preempt the first is error");
921
922        assert_eq!(
923            error.to_string(),
924            "Compilation error: Variable U used in arithmetic but not bound"
925        );
926    }
927
928    #[test]
929    fn test_compile_transitive_closure() {
930        let mut compiler = Compiler::new();
931        let result = compiler.compile(
932            r#"
933            edge(1, 2).
934            edge(2, 3).
935            edge(3, 4).
936            reach(X, Y) :- edge(X, Y).
937            reach(X, Z) :- reach(X, Y), edge(Y, Z).
938        "#,
939        );
940        assert!(result.is_ok(), "Failed to compile TC: {:?}", result.err());
941
942        let plan = result.unwrap();
943        // Should have SCCs for edge and reach
944        assert!(!plan.sccs.is_empty());
945    }
946
947    #[test]
948    fn test_compile_with_negation() {
949        let mut compiler = Compiler::new();
950        let result = compiler.compile(
951            r#"
952            node(1).
953            node(2).
954            node(3).
955            edge(1, 2).
956            isolated(X) :- node(X), not edge(X, _).
957        "#,
958        );
959        assert!(
960            result.is_ok(),
961            "Failed to compile with negation: {:?}",
962            result.err()
963        );
964    }
965
966    #[test]
967    fn test_compile_with_comparison() {
968        let mut compiler = Compiler::new();
969        let result = compiler.compile(
970            r#"
971            value(1).
972            value(5).
973            value(10).
974            value(15).
975            small(X) :- value(X), X < 10.
976        "#,
977        );
978        assert!(
979            result.is_ok(),
980            "Failed to compile with comparison: {:?}",
981            result.err()
982        );
983    }
984
985    #[test]
986    fn test_schema_infers_from_rule_body_types() {
987        let mut compiler = Compiler::new();
988        let result = compiler.compile(
989            r#"
990            path(X, Y) :- reach(X, Y).
991            edge(1, 2).
992            edge(2, 3).
993            reach(X, Y) :- edge(X, Y).
994        "#,
995        );
996        assert!(
997            result.is_ok(),
998            "Failed to compile rule for schema inference: {:?}",
999            result.err()
1000        );
1001
1002        let schema = compiler
1003            .schemas()
1004            .get("reach")
1005            .expect("missing reach schema");
1006        assert_eq!(
1007            schema.column_type(0),
1008            Some(ScalarType::U32),
1009            "reach column 0 should match edge column type"
1010        );
1011        assert_eq!(
1012            schema.column_type(1),
1013            Some(ScalarType::U32),
1014            "reach column 1 should match edge column type"
1015        );
1016
1017        let schema = compiler.schemas().get("path").expect("missing path schema");
1018        assert_eq!(
1019            schema.column_type(0),
1020            Some(ScalarType::U32),
1021            "path column 0 should inherit the transitive body type"
1022        );
1023        assert_eq!(
1024            schema.column_type(1),
1025            Some(ScalarType::U32),
1026            "path column 1 should inherit the transitive body type"
1027        );
1028    }
1029
1030    #[test]
1031    fn test_schema_propagates_known_columns_when_a_sibling_column_is_unresolved() {
1032        let mut compiler = Compiler::new();
1033        let result = compiler.compile(
1034            r#"
1035            path(X) :- intermediate(X, Z).
1036            intermediate(X, Z) :- source(X), unresolved(Z).
1037            unresolved(Z) :- unresolved(Z).
1038            source(value).
1039        "#,
1040        );
1041        assert!(
1042            result.is_ok(),
1043            "Failed to compile partial rule-head schema inference: {:?}",
1044            result.err()
1045        );
1046
1047        let schema = compiler.schemas().get("path").expect("missing path schema");
1048        assert_eq!(
1049            schema.column_type(0),
1050            Some(ScalarType::Symbol),
1051            "path should inherit the known intermediate column independently of its sibling"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_schema_rejects_conflicting_rule_head_evidence_in_either_order() {
1057        let symbol_first = r#"
1058            pred symbols(symbol).
1059            pred integers(i64).
1060            symbols(value).
1061            integers(5000000000).
1062            mixed(X) :- symbols(X).
1063            mixed(X) :- integers(X).
1064        "#;
1065        let integer_first = r#"
1066            pred symbols(symbol).
1067            pred integers(i64).
1068            symbols(value).
1069            integers(5000000000).
1070            mixed(X) :- integers(X).
1071            mixed(X) :- symbols(X).
1072        "#;
1073
1074        for source in [symbol_first, integer_first] {
1075            let error = Compiler::new()
1076                .compile(source)
1077                .expect_err("conflicting inferred rule-head types must be rejected");
1078            let message = error.to_string();
1079            assert!(message.contains("mixed"), "{message}");
1080            assert!(message.contains("column 1"), "{message}");
1081        }
1082    }
1083
1084    #[test]
1085    fn test_schema_rejects_conflicting_body_evidence_in_either_order() {
1086        let first_body_order = r#"
1087            source_number(1).
1088            source_symbol(value).
1089            mixed(X) :- source_number(X), source_symbol(X).
1090            mixed(1) :- source_number(1).
1091        "#;
1092        let reversed_body_order = r#"
1093            source_number(1).
1094            source_symbol(value).
1095            mixed(X) :- source_symbol(X), source_number(X).
1096            mixed(1) :- source_number(1).
1097        "#;
1098
1099        for source in [first_body_order, reversed_body_order] {
1100            let error = Compiler::new()
1101                .compile(source)
1102                .expect_err("conflicting body-column types must be rejected");
1103            let message = error.to_string();
1104            assert!(message.contains("mixed"), "{message}");
1105            assert!(message.contains("variable X"), "{message}");
1106        }
1107    }
1108
1109    #[test]
1110    fn test_schema_infers_arithmetic_binding_result_type() {
1111        let mut compiler = Compiler::new();
1112        compiler
1113            .compile(
1114                r#"
1115                computed(X) :- X is cast(1, u64).
1116            "#,
1117            )
1118            .expect("an arithmetic binding should determine its head-column type");
1119
1120        let schema = compiler
1121            .schemas()
1122            .get("computed")
1123            .expect("missing computed schema");
1124        assert_eq!(schema.column_type(0), Some(ScalarType::U64));
1125    }
1126
1127    #[test]
1128    fn test_schema_rejects_arithmetic_and_atom_evidence_in_either_rule_order() {
1129        let arithmetic_first = r#"
1130            pred integers(i64).
1131            integers(5000000000).
1132            mixed(X) :- X is cast(1, u64).
1133            mixed(X) :- integers(X).
1134        "#;
1135        let atom_first = r#"
1136            pred integers(i64).
1137            integers(5000000000).
1138            mixed(X) :- integers(X).
1139            mixed(X) :- X is cast(1, u64).
1140        "#;
1141
1142        for source in [arithmetic_first, atom_first] {
1143            let error = Compiler::new()
1144                .compile(source)
1145                .expect_err("conflicting arithmetic and atom types must be rejected");
1146            let message = error.to_string();
1147            assert!(message.contains("mixed"), "{message}");
1148            assert!(message.contains("column 1"), "{message}");
1149        }
1150    }
1151
1152    #[test]
1153    fn test_schema_rejects_incompatible_constant_heads_before_runtime() {
1154        let sources = [
1155            r#"
1156                pred p(u32).
1157                seed().
1158                p(value) :- seed().
1159            "#,
1160            r#"
1161                p(1).
1162                p(value).
1163            "#,
1164            r#"
1165                p(value).
1166                p(1).
1167            "#,
1168            r#"
1169                p(1).
1170                p(5000000000).
1171            "#,
1172            r#"
1173                p(5000000000).
1174                p(1).
1175            "#,
1176        ];
1177
1178        for source in sources {
1179            let error = Compiler::new()
1180                .compile(source)
1181                .expect_err("incompatible constant head types must fail during compilation");
1182            let message = error.to_string();
1183            assert!(message.contains("p"), "{message}");
1184            assert!(message.contains("head term at position 0"), "{message}");
1185        }
1186    }
1187
1188    #[test]
1189    fn test_declared_scalar_schemas_accept_supported_literal_conversions() {
1190        Compiler::new()
1191            .compile(
1192                r#"
1193                pred signed(i64).
1194                pred wide(u64).
1195                pred real(f64).
1196                pred flag(bool).
1197                seed().
1198                signed(1).
1199                wide(1).
1200                real(1).
1201                real(2) :- seed().
1202                flag(true).
1203                flag(false) :- seed().
1204            "#,
1205            )
1206            .expect("representable literals should use declared scalar storage types");
1207    }
1208
1209    #[test]
1210    fn test_compile_unstratifiable_fails() {
1211        let mut compiler = Compiler::new();
1212        let result = compiler.compile(
1213            r#"
1214            p :- not q.
1215            q :- not p.
1216        "#,
1217        );
1218        assert!(result.is_err(), "Should fail with stratification cycle");
1219    }
1220
1221    #[test]
1222    fn test_compile_syntax_error_fails() {
1223        let mut compiler = Compiler::new();
1224        let result = compiler.compile("edge(1, 2"); // Missing closing paren and period
1225        assert!(result.is_err(), "Should fail with syntax error");
1226    }
1227
1228    #[test]
1229    fn test_compile_convenience_function() {
1230        let result = compile("edge(1, 2).");
1231        assert!(
1232            result.is_ok(),
1233            "Convenience compile failed: {:?}",
1234            result.err()
1235        );
1236    }
1237
1238    #[test]
1239    fn test_compiler_reset() {
1240        let mut compiler = Compiler::new();
1241
1242        // First compilation
1243        let result1 = compiler.compile("edge(1, 2).");
1244        assert!(result1.is_ok());
1245
1246        // Reset and compile again
1247        compiler.reset();
1248        let result2 = compiler.compile("node(1). node(2).");
1249        assert!(result2.is_ok());
1250    }
1251
1252    #[test]
1253    fn test_compile_with_pred_decl() {
1254        let mut compiler = Compiler::new();
1255        let result = compiler.compile(
1256            r#"
1257            pred edge(u32, u32).
1258            edge(1, 2).
1259            edge(2, 3).
1260            reach(X, Y) :- edge(X, Y).
1261        "#,
1262        );
1263        assert!(
1264            result.is_ok(),
1265            "Failed to compile with pred decl: {:?}",
1266            result.err()
1267        );
1268    }
1269
1270    #[test]
1271    fn test_compile_multi_stratum() {
1272        let mut compiler = Compiler::new();
1273        let result = compiler.compile(
1274            r#"
1275            // Base facts
1276            edge(1, 2).
1277            edge(2, 3).
1278            edge(3, 1).
1279
1280            // Stratum 0: edge (base)
1281            // Stratum 1: reach (depends on edge, recursive)
1282            reach(X, Y) :- edge(X, Y).
1283            reach(X, Z) :- reach(X, Y), edge(Y, Z).
1284
1285            // Stratum 2: non_reach (negates reach)
1286            all_pairs(X, Y) :- edge(X, Z), edge(Y, W).
1287            non_reach(X, Y) :- all_pairs(X, Y), not reach(X, Y).
1288        "#,
1289        );
1290        assert!(
1291            result.is_ok(),
1292            "Failed to compile multi-stratum: {:?}",
1293            result.err()
1294        );
1295
1296        let plan = result.unwrap();
1297        // Should have multiple strata
1298        assert!(!plan.strata.is_empty(), "Expected multiple strata");
1299    }
1300
1301    #[test]
1302    fn test_compile_aggregation() {
1303        let mut compiler = Compiler::new();
1304        let result = compiler.compile(
1305            r#"
1306            edge(1, 2).
1307            edge(1, 3).
1308            edge(2, 3).
1309            out_degree(X, count(Y)) :- edge(X, Y).
1310        "#,
1311        );
1312        assert!(
1313            result.is_ok(),
1314            "Failed to compile with aggregation: {:?}",
1315            result.err()
1316        );
1317
1318        let plan = result.unwrap();
1319        let out_degree_rules: Vec<_> = plan
1320            .rules_by_scc
1321            .iter()
1322            .flatten()
1323            .filter(|r| r.head == "out_degree")
1324            .collect();
1325        assert_eq!(out_degree_rules.len(), 1, "Expected one out_degree rule");
1326
1327        // Aggregation lowering should produce a GroupBy node (wrapped in a Project to match head order).
1328        let body = &out_degree_rules[0].body;
1329        match body {
1330            RirNode::Project { input, .. } => {
1331                assert!(
1332                    matches!(input.as_ref(), RirNode::GroupBy { .. }),
1333                    "Expected Project(GroupBy(..)), got {:?}",
1334                    input
1335                );
1336            }
1337            other => panic!("Expected Project(GroupBy(..)), got {:?}", other),
1338        }
1339    }
1340
1341    #[test]
1342    fn test_aggregate_schemas_match_runtime_result_types() {
1343        let mut compiler = Compiler::new();
1344        compiler
1345            .compile(
1346                r#"
1347                pred edge(u32, u64).
1348                pred score(u32, f64).
1349                pred counted(u32, u64).
1350                pred summed(u32, u64).
1351                pred minimum(u32, u64).
1352                pred maximum(u32, u64).
1353                pred combined(u32, f64).
1354                edge(1, 5000000000).
1355                score(1, 2.5).
1356                counted(X, count(Y)) :- edge(X, Y).
1357                summed(X, sum(Y)) :- edge(X, Y).
1358                minimum(X, min(Y)) :- edge(X, Y).
1359                maximum(X, max(Y)) :- edge(X, Y).
1360                combined(X, logsumexp(Y)) :- score(X, Y).
1361            "#,
1362            )
1363            .expect("aggregate declarations should match provider result schemas");
1364
1365        for predicate in ["counted", "summed", "minimum", "maximum"] {
1366            assert_eq!(
1367                compiler
1368                    .schemas()
1369                    .get(predicate)
1370                    .expect("missing aggregate schema")
1371                    .column_type(1),
1372                Some(ScalarType::U64),
1373                "unexpected result type for {predicate}"
1374            );
1375        }
1376        assert_eq!(
1377            compiler
1378                .schemas()
1379                .get("combined")
1380                .expect("missing log-sum-exp schema")
1381                .column_type(1),
1382            Some(ScalarType::F64)
1383        );
1384    }
1385
1386    #[test]
1387    fn test_undeclared_count_schema_is_u64() {
1388        let mut compiler = Compiler::new();
1389        compiler
1390            .compile(
1391                r#"
1392                edge(1, 5000000000).
1393                degree(X, count(Y)) :- edge(X, Y).
1394            "#,
1395            )
1396            .expect("undeclared count result should compile with its runtime type");
1397        assert_eq!(
1398            compiler
1399                .schemas()
1400                .get("degree")
1401                .expect("missing inferred count schema")
1402                .column_type(1),
1403            Some(ScalarType::U64)
1404        );
1405    }
1406
1407    #[test]
1408    fn test_unsupported_aggregate_inputs_are_rejected_before_runtime() {
1409        let fixtures = [
1410            r#"
1411                pred source(u32, i32).
1412                pred result(u32, u64).
1413                source(1, 2).
1414                result(X, sum(Y)) :- source(X, Y).
1415            "#,
1416            r#"
1417                pred source(u32, u32).
1418                pred result(u32, f64).
1419                source(1, 2).
1420                result(X, logsumexp(Y)) :- source(X, Y).
1421            "#,
1422        ];
1423
1424        for source in fixtures {
1425            let error = Compiler::new()
1426                .compile(source)
1427                .expect_err("unsupported aggregate input must fail during compilation");
1428            let message = error.to_string();
1429            assert!(message.contains("Unsupported aggregate input"), "{message}");
1430            assert!(message.contains("execution provider requires"), "{message}");
1431        }
1432    }
1433
1434    #[test]
1435    fn test_compile_with_stats_snapshot() {
1436        let mut compiler = Compiler::new();
1437        let source = r#"
1438            edge(1, 2).
1439            edge(2, 3).
1440            reach(X, Y) :- edge(X, Y).
1441        "#;
1442
1443        let _ = compiler.compile(source).expect("Initial compile failed");
1444        let edge_id = *compiler.rel_ids().get("edge").expect("edge rel_id missing");
1445
1446        let mut mgr = StatsManager::new();
1447        mgr.register_relation(edge_id);
1448        mgr.update_cardinality(edge_id, 42);
1449        let snapshot = mgr.snapshot();
1450
1451        let plan = compiler
1452            .compile_with_stats_snapshot(source, Some(&snapshot))
1453            .expect("Compile with snapshot failed");
1454        assert!(!plan.sccs.is_empty());
1455    }
1456
1457    #[test]
1458    fn test_compile_with_named_stats_snapshot_reorders_joins() {
1459        let mut compiler = Compiler::new();
1460        let source = r#"
1461            foo(1).
1462            edge(1).
1463            out(X) :- edge(X), foo(X).
1464        "#;
1465
1466        // Snapshot uses different RelIds than the compiler will assign for this program.
1467        // Map: RelId(0) -> edge (small), RelId(1) -> foo (big)
1468        let mut edge_stats = RelationStats::new(RelId(0));
1469        edge_stats.update_cardinality(10);
1470        let mut foo_stats = RelationStats::new(RelId(1));
1471        foo_stats.update_cardinality(10_000);
1472
1473        let snapshot = StatsSnapshot {
1474            relations: vec![edge_stats, foo_stats],
1475            join_selectivities: Vec::new(),
1476            rel_names: vec![
1477                (RelId(0), "edge".to_string()),
1478                (RelId(1), "foo".to_string()),
1479            ],
1480        };
1481
1482        let plan = compiler
1483            .compile_with_stats_snapshot(source, Some(&snapshot))
1484            .expect("Compile with named snapshot failed");
1485
1486        let foo_id = *compiler.rel_ids().get("foo").expect("foo rel_id missing");
1487        let edge_id = *compiler.rel_ids().get("edge").expect("edge rel_id missing");
1488
1489        let out_rule = plan
1490            .rules_by_scc
1491            .iter()
1492            .flatten()
1493            .find(|r| r.head == "out")
1494            .expect("out rule missing");
1495
1496        // Peel projections to reach the join.
1497        let mut node = &out_rule.body;
1498        while let RirNode::Project { input, .. } = node {
1499            node = input;
1500        }
1501
1502        match node {
1503            RirNode::ChainJoin {
1504                left,
1505                right,
1506                fallback,
1507                ..
1508            } => {
1509                // ChainJoin promotion wraps eligible two-atom joins after
1510                // stats-aware ordering. The chain node and its captured
1511                // fallback must agree on the build-side choice.
1512                assert!(matches!(**left, RirNode::Scan { rel } if rel == foo_id));
1513                assert!(matches!(**right, RirNode::Scan { rel } if rel == edge_id));
1514
1515                let mut fallback_node = fallback.as_ref();
1516                while let RirNode::Project { input, .. } = fallback_node {
1517                    fallback_node = input;
1518                }
1519                match fallback_node {
1520                    RirNode::Join { left, right, .. } => {
1521                        assert!(matches!(**left, RirNode::Scan { rel } if rel == foo_id));
1522                        assert!(matches!(**right, RirNode::Scan { rel } if rel == edge_id));
1523                    }
1524                    other => panic!("Expected ChainJoin fallback Join node, got {:?}", other),
1525                }
1526            }
1527            RirNode::Join { left, right, .. } => {
1528                // Prefer building on the smaller relation (right/build side).
1529                assert!(matches!(**left, RirNode::Scan { rel } if rel == foo_id));
1530                assert!(matches!(**right, RirNode::Scan { rel } if rel == edge_id));
1531            }
1532            other => panic!("Expected Join node, got {:?}", other),
1533        }
1534    }
1535
1536    fn helper_split_source() -> &'static str {
1537        r#"
1538            ab(0, 0). bc(0, 0). cd(0, 0). de(0, 0). ef(0, 0). af(0, 0).
1539            out(A, B, C, D, F) :-
1540                ab(A, B),
1541                bc(B, C),
1542                cd(C, D),
1543                de(D, E),
1544                ef(E, F),
1545                af(A, F).
1546        "#
1547    }
1548
1549    fn helper_split_snapshot(distinct_d: u64) -> StatsSnapshot {
1550        let mut snapshot_relations = Vec::new();
1551        for (idx, name) in ["ab", "bc", "cd", "de", "ef", "af"].iter().enumerate() {
1552            let mut rel_stats = RelationStats::new(RelId(idx as u32));
1553            rel_stats.update_cardinality(8192);
1554            if *name == "de" {
1555                let mut d_col = ColumnStats::new(0, ScalarType::U32);
1556                d_col.update_distinct(distinct_d);
1557                rel_stats.add_column(d_col);
1558            }
1559            snapshot_relations.push(rel_stats);
1560        }
1561        StatsSnapshot {
1562            relations: snapshot_relations,
1563            join_selectivities: Vec::new(),
1564            rel_names: ["ab", "bc", "cd", "de", "ef", "af"]
1565                .iter()
1566                .enumerate()
1567                .map(|(idx, name)| (RelId(idx as u32), (*name).to_string()))
1568                .collect(),
1569        }
1570    }
1571
1572    #[test]
1573    fn test_compile_with_named_stats_snapshot_creates_helper_relation() {
1574        let mut compiler = Compiler::new();
1575        let snapshot = helper_split_snapshot(1);
1576        let plan = compiler
1577            .compile_with_stats_snapshot(helper_split_source(), Some(&snapshot))
1578            .expect("compile with helper stats");
1579        let helper = compiler
1580            .rel_ids()
1581            .iter()
1582            .find_map(|(name, rel)| {
1583                name.starts_with("__kclique_helper_")
1584                    .then_some((name.clone(), *rel))
1585            })
1586            .expect("helper relation allocated");
1587
1588        let helper_rule_count = plan
1589            .rules_by_scc
1590            .iter()
1591            .flatten()
1592            .filter(|rule| rule.head == helper.0)
1593            .count();
1594        assert_eq!(helper_rule_count, 1);
1595
1596        let helper_rule = plan
1597            .rules_by_scc
1598            .iter()
1599            .flatten()
1600            .find(|rule| rule.head == helper.0)
1601            .expect("helper rule");
1602        assert!(
1603            matches!(helper_rule.body, RirNode::ChainJoin { .. }),
1604            "helper split output should be eligible for ChainJoin promotion"
1605        );
1606
1607        let out_rule = plan
1608            .rules_by_scc
1609            .iter()
1610            .flatten()
1611            .find(|rule| rule.head == "out")
1612            .expect("out rule");
1613        assert!(contains_scan(&out_rule.body, helper.1));
1614    }
1615
1616    #[test]
1617    fn test_compile_with_flat_named_stats_keeps_original_rule() {
1618        let mut compiler = Compiler::new();
1619        let snapshot = helper_split_snapshot(8192);
1620        let plan = compiler
1621            .compile_with_stats_snapshot(helper_split_source(), Some(&snapshot))
1622            .expect("compile with flat stats");
1623
1624        assert!(!compiler
1625            .rel_ids()
1626            .keys()
1627            .any(|name| name.starts_with("__kclique_helper_")));
1628        let out_rules = plan
1629            .rules_by_scc
1630            .iter()
1631            .flatten()
1632            .filter(|rule| rule.head == "out")
1633            .count();
1634        assert_eq!(out_rules, 1);
1635    }
1636
1637    fn contains_scan(node: &RirNode, rel: RelId) -> bool {
1638        match node {
1639            RirNode::Scan { rel: scan_rel } => *scan_rel == rel,
1640            RirNode::Join { left, right, .. } | RirNode::ChainJoin { left, right, .. } => {
1641                contains_scan(left, rel) || contains_scan(right, rel)
1642            }
1643            RirNode::Project { input, .. }
1644            | RirNode::Filter { input, .. }
1645            | RirNode::Distinct { input, .. }
1646            | RirNode::GroupBy { input, .. } => contains_scan(input, rel),
1647            RirNode::Union { inputs } => inputs.iter().any(|input| contains_scan(input, rel)),
1648            RirNode::Diff { left, right } => contains_scan(left, rel) || contains_scan(right, rel),
1649            RirNode::Fixpoint {
1650                base, recursive, ..
1651            } => contains_scan(base, rel) || contains_scan(recursive, rel),
1652            RirNode::MultiWayJoin { inputs, .. } => {
1653                inputs.iter().any(|input| contains_scan(input, rel))
1654            }
1655            RirNode::TensorMaskedJoin { rel_index, .. } => {
1656                rel_index.iter().any(|(input_rel, _)| *input_rel == rel)
1657            }
1658            RirNode::Unit => false,
1659        }
1660    }
1661}