Skip to main content

xlog_logic/
lower.rs

1//! Lowering from AST to IR
2//!
3//! This module transforms Datalog programs (AST) into the Relational IR (RIR)
4//! representation for execution. The lowering process:
5//!
6//! 1. Infers schemas from facts and predicate declarations
7//! 2. Tracks variable positions across atoms for join key computation
8//! 3. Builds left-deep join trees for multi-atom rule bodies
9//! 4. Handles negation via set difference (Diff) nodes
10//! 5. Wraps recursive predicates in Fixpoint nodes
11//! 6. Projects to match head variables
12
13use std::collections::{HashMap, HashSet};
14
15use xlog_core::{symbol, AggOp as CoreAggOp, RelId, Result, ScalarType, Schema, XlogError};
16use xlog_ir::{
17    CompareOp, CompiledRule, ConstValue, ExecutionPlan, Expr, JoinType, PlanBuilder, ProjectExpr,
18    RirMeta, RirNode, Scc, Stratum as IrStratum,
19};
20
21use crate::ast::{
22    AggOp, ArithExpr, Atom, BodyLiteral, CompOp, Comparison, IsExpr, LearnableRule, Program, Rule,
23    Term, TypeRef,
24};
25use crate::stratify::{build_dependency_graph, find_sccs_for_lowering, DepType};
26
27struct JoinPlan<'a> {
28    node: RirNode,
29    leaf_order: Vec<&'a Atom>,
30    leaf_order_idx: Vec<usize>,
31    var_pos: HashMap<String, usize>,
32    width: usize,
33    est_rows: f64,
34    total_cost: f64,
35}
36
37#[derive(Clone, Copy)]
38enum UserFunctionTypeEvidence {
39    RequireExpansion,
40    Defer,
41}
42
43#[derive(Clone, Copy)]
44enum ArithmeticTypeOperation {
45    Standard,
46    Modulo,
47    MinMax,
48    Power,
49}
50
51enum ArithmeticTypeTask<'a> {
52    Visit(&'a ArithExpr),
53    FinishBinary(ArithmeticTypeOperation),
54    FinishAbs,
55    FinishCast(ScalarType),
56    FinishFunctionArguments(usize),
57    FinishConditional,
58}
59
60#[derive(Clone, Copy)]
61enum ArithmeticExpressionOperation {
62    Add,
63    Sub,
64    Mul,
65    Div,
66    Mod,
67    Min,
68    Max,
69    Pow,
70}
71
72enum ArithmeticExpressionTask<'a> {
73    Visit(&'a ArithExpr),
74    FinishBinary(ArithmeticExpressionOperation),
75    FinishAbs,
76    FinishCast(ScalarType),
77    FinishConditional(CompOp),
78}
79
80fn resolve_pred_column_type(
81    predicate: &str,
82    index: usize,
83    typ: &TypeRef,
84    domains: &HashMap<String, ScalarType>,
85) -> Result<ScalarType> {
86    match typ {
87        TypeRef::Scalar(ty) => Ok(*ty),
88        TypeRef::Domain(name) => domains.get(name).copied().ok_or_else(|| {
89            XlogError::Compilation(format!(
90                "v0.8.5 unknown domain alias '{}' in predicate '{}' column {}",
91                name, predicate, index
92            ))
93        }),
94        TypeRef::List(_) | TypeRef::Term | TypeRef::Compound | TypeRef::PredRef => {
95            Ok(ScalarType::U64)
96        }
97    }
98}
99
100fn validate_lowerable_terms(program: &Program) -> Result<()> {
101    for rule in &program.rules {
102        validate_atom_terms(&rule.head, "rule head")?;
103        for lit in &rule.body {
104            match lit {
105                BodyLiteral::Positive(atom) => validate_atom_terms(atom, "positive body atom")?,
106                BodyLiteral::Negated(atom) => validate_atom_terms(atom, "negated body atom")?,
107                BodyLiteral::Epistemic(_) => {}
108                BodyLiteral::Comparison(cmp) => {
109                    validate_term_lowerable(&cmp.left, "comparison left operand")?;
110                    validate_term_lowerable(&cmp.right, "comparison right operand")?;
111                }
112                BodyLiteral::IsExpr(_) => {}
113                BodyLiteral::Univ(_) => {
114                    return Err(XlogError::Compilation(
115                        "v0.8.5 meta error: univ literal was not normalized before lowering"
116                            .to_string(),
117                    ));
118                }
119            }
120        }
121    }
122    for constraint in &program.constraints {
123        for lit in &constraint.body {
124            match lit {
125                BodyLiteral::Positive(atom) => validate_atom_terms(atom, "constraint body atom")?,
126                BodyLiteral::Negated(atom) => {
127                    validate_atom_terms(atom, "constraint negated body atom")?
128                }
129                BodyLiteral::Epistemic(_) => {}
130                BodyLiteral::Comparison(cmp) => {
131                    validate_term_lowerable(&cmp.left, "constraint comparison left operand")?;
132                    validate_term_lowerable(&cmp.right, "constraint comparison right operand")?;
133                }
134                BodyLiteral::IsExpr(_) => {}
135                BodyLiteral::Univ(_) => {
136                    return Err(XlogError::Compilation(
137                        "v0.8.5 meta error: univ literal was not normalized before lowering"
138                            .to_string(),
139                    ));
140                }
141            }
142        }
143    }
144    for query in &program.queries {
145        validate_atom_terms(&query.atom, "query atom")?;
146    }
147    for pf in &program.prob_facts {
148        validate_atom_terms(&pf.atom, "probabilistic fact")?;
149    }
150    for ad in &program.annotated_disjunctions {
151        for choice in &ad.choices {
152            validate_atom_terms(&choice.atom, "annotated disjunction choice")?;
153        }
154    }
155    for evidence in &program.evidence {
156        validate_atom_terms(&evidence.atom, "evidence atom")?;
157    }
158    for query in &program.prob_queries {
159        validate_atom_terms(&query.atom, "probabilistic query")?;
160    }
161    for neural in &program.neural_predicates {
162        validate_atom_terms(&neural.predicate, "neural predicate")?;
163    }
164    for learnable in &program.learnable_rules {
165        validate_atom_terms(&learnable.head, "learnable rule head")?;
166        for lit in &learnable.body {
167            if let BodyLiteral::Positive(atom) = lit {
168                validate_atom_terms(atom, "learnable rule body")?;
169            }
170        }
171    }
172    Ok(())
173}
174
175fn validate_atom_terms(atom: &Atom, context: &str) -> Result<()> {
176    for term in &atom.terms {
177        validate_term_lowerable(term, context)?;
178    }
179    Ok(())
180}
181
182fn validate_term_lowerable(term: &Term, context: &str) -> Result<()> {
183    match term {
184        Term::List(_) => Err(term_not_lowerable_error(context, "list")),
185        Term::Cons { .. } => Err(term_not_lowerable_error(context, "cons")),
186        Term::Compound { .. } => Err(term_not_lowerable_error(context, "compound")),
187        Term::PredRef(_) => Err(term_not_lowerable_error(context, "predref")),
188        Term::Variable(_)
189        | Term::Anonymous
190        | Term::Integer(_)
191        | Term::Float(_)
192        | Term::String(_)
193        | Term::Symbol(_)
194        | Term::Aggregate(_) => Ok(()),
195    }
196}
197
198fn term_not_lowerable_error(context: &str, kind: &str) -> XlogError {
199    XlogError::Compilation(format!(
200        "term form '{}' in {} is parsed but not lowerable by this execution path",
201        kind, context
202    ))
203}
204
205fn term_kind_for_lowering_error(term: &Term) -> &'static str {
206    match term {
207        Term::List(_) => "list",
208        Term::Cons { .. } => "cons",
209        Term::Compound { .. } => "compound",
210        Term::PredRef(_) => "predref",
211        Term::Variable(_)
212        | Term::Anonymous
213        | Term::Integer(_)
214        | Term::Float(_)
215        | Term::String(_)
216        | Term::Symbol(_)
217        | Term::Aggregate(_) => "term",
218    }
219}
220
221/// Lowerer transforms AST programs into RIR execution plans.
222pub struct Lowerer {
223    /// Inferred or declared schemas for each predicate
224    schemas: HashMap<String, Schema>,
225    /// Stratification result (predicates grouped by strata)
226    strata: Vec<Vec<String>>,
227    /// Estimated cardinality per predicate (for join ordering)
228    est_cardinality: HashMap<String, u64>,
229    /// Optional cardinality hints per predicate (e.g., from runtime statistics).
230    cardinality_hints: HashMap<String, u64>,
231    /// Next available relation ID
232    next_rel_id: u32,
233    /// Mapping from predicate names to relation IDs
234    rel_ids: HashMap<String, RelId>,
235    /// SCCs for the program (from stratification)
236    sccs: Vec<Scc>,
237    /// Maximum active rules for TensorMaskedJoin (default 32)
238    max_active_rules: usize,
239    /// Predicate -> SCC id, rebuilt with `sccs` in `build_sccs`. A predicate
240    /// belongs to exactly one SCC, so lookups replace the linear scan that
241    /// made `find_scc_for_predicate` O(rules x SCCs).
242    scc_by_pred: std::collections::HashMap<String, u32>,
243}
244
245impl Default for Lowerer {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251impl Lowerer {
252    /// Create a new lowerer instance
253    pub fn new() -> Self {
254        Self {
255            schemas: HashMap::new(),
256            strata: Vec::new(),
257            est_cardinality: HashMap::new(),
258            cardinality_hints: HashMap::new(),
259            next_rel_id: 0,
260            rel_ids: HashMap::new(),
261            sccs: Vec::new(),
262            max_active_rules: 32,
263            scc_by_pred: std::collections::HashMap::new(),
264        }
265    }
266
267    /// Set the maximum active rules for TensorMaskedJoin.
268    pub fn set_max_active_rules(&mut self, max: usize) {
269        self.max_active_rules = max;
270    }
271
272    /// Set the stratification result for ordering
273    pub(crate) fn set_strata(&mut self, strata: Vec<Vec<String>>) {
274        self.strata = strata;
275    }
276
277    /// Set cardinality hints (typically sourced from runtime statistics snapshots).
278    ///
279    /// These hints are used by lowering-time join ordering when available.
280    pub(crate) fn set_cardinality_hints(&mut self, hints: HashMap<String, u64>) {
281        self.cardinality_hints = hints;
282    }
283
284    /// Get the mapping from predicate names to relation IDs
285    pub fn rel_ids(&self) -> &HashMap<String, RelId> {
286        &self.rel_ids
287    }
288
289    /// Get the inferred schemas for predicates
290    pub fn schemas(&self) -> &HashMap<String, Schema> {
291        &self.schemas
292    }
293
294    pub(crate) fn create_helper_relation(&mut self, schema: Schema) -> (String, RelId) {
295        let name = format!("__kclique_helper_{}", self.next_rel_id);
296        let rel_id = self.get_or_create_rel_id(&name);
297        self.schemas.insert(name.clone(), schema);
298        (name, rel_id)
299    }
300
301    /// Get or allocate a relation ID for a predicate
302    fn get_or_create_rel_id(&mut self, name: &str) -> RelId {
303        if let Some(&id) = self.rel_ids.get(name) {
304            id
305        } else {
306            let id = RelId(self.next_rel_id);
307            self.next_rel_id += 1;
308            self.rel_ids.insert(name.to_string(), id);
309            id
310        }
311    }
312
313    /// Reject rules whose variables draw incompatible column types from
314    /// the predicate schemas they touch. The executor requires exact
315    /// schema equality when relations meet, so any conflict tolerated
316    /// here would surface later as an internal kernel schema error
317    /// instead of a source-level diagnostic.
318    fn validate_rule_types(&self, program: &Program) -> Result<()> {
319        let declared_predicates = program
320            .predicates
321            .iter()
322            .map(|declaration| declaration.name.as_str())
323            .collect::<HashSet<_>>();
324        for rule in &program.rules {
325            let var_types = self.infer_rule_variable_types(rule, |atom, index| {
326                self.schemas
327                    .get(&atom.predicate)
328                    .and_then(|schema| schema.column_type(index))
329            })?;
330            let Some(head_schema) = self.schemas.get(&rule.head.predicate) else {
331                continue;
332            };
333            for (j, term) in rule.head.terms.iter().enumerate() {
334                let Some((_, head_ty)) = head_schema.columns.get(j) else {
335                    continue;
336                };
337                match term {
338                    Term::Variable(name) => {
339                        let Some((body_ty, source)) = var_types.get(name) else {
340                            continue;
341                        };
342                        if body_ty != head_ty {
343                            return Err(XlogError::Compilation(format!(
344                                "Type mismatch in rule for '{}': variable {} is {:?} \
345                                 (from {}) but {} declares {:?} at position {}",
346                                rule.head.predicate,
347                                name,
348                                body_ty,
349                                source,
350                                rule.head.predicate,
351                                head_ty,
352                                j
353                            )));
354                        }
355                    }
356                    Term::Anonymous => {}
357                    Term::Aggregate(aggregate) => {
358                        let aggregate_ty =
359                            Self::infer_aggregate_result_type(rule, aggregate, &var_types)?
360                                .ok_or_else(|| {
361                                    XlogError::UnsafeVariable(aggregate.variable.clone())
362                                })?;
363                        if aggregate_ty != *head_ty {
364                            return Err(XlogError::Compilation(format!(
365                                "Type mismatch in rule for '{}': aggregate {:?} over {} produces \
366                                 {:?}, but the predicate schema requires {:?} at position {}",
367                                rule.head.predicate,
368                                aggregate.op,
369                                aggregate.variable,
370                                aggregate_ty,
371                                head_ty,
372                                j
373                            )));
374                        }
375                    }
376                    Term::Integer(_) | Term::Float(_) | Term::String(_) | Term::Symbol(_) => {
377                        if declared_predicates.contains(rule.head.predicate.as_str()) {
378                            term_to_typed_const_value(term, *head_ty).map_err(|error| {
379                                XlogError::Compilation(format!(
380                                    "Type mismatch in rule for '{}': head term at position {} is \
381                                     not compatible with {:?}: {}",
382                                    rule.head.predicate, j, head_ty, error
383                                ))
384                            })?;
385                        } else if term.inferred_scalar_type() != *head_ty {
386                            return Err(XlogError::Compilation(format!(
387                                "Type mismatch in rule for '{}': undeclared head term at position \
388                                 {} has inferred type {:?}, but another clause requires {:?}",
389                                rule.head.predicate,
390                                j,
391                                term.inferred_scalar_type(),
392                                head_ty
393                            )));
394                        }
395                    }
396                    _ if term.inferred_scalar_type() != *head_ty => {
397                        return Err(XlogError::Compilation(format!(
398                            "Type mismatch in rule for '{}': head term at position {} has type \
399                             {:?}, but the predicate schema requires {:?}",
400                            rule.head.predicate,
401                            j,
402                            term.inferred_scalar_type(),
403                            head_ty
404                        )));
405                    }
406                    _ => {}
407                }
408            }
409        }
410        Ok(())
411    }
412
413    fn infer_aggregate_result_type(
414        rule: &Rule,
415        aggregate: &crate::ast::AggExpr,
416        variable_types: &HashMap<String, (ScalarType, String)>,
417    ) -> Result<Option<ScalarType>> {
418        let Some((input_type, source)) = variable_types.get(&aggregate.variable) else {
419            return Ok(aggregate.input_independent_result_type());
420        };
421        aggregate
422            .result_type_for_input(*input_type)
423            .map(Some)
424            .ok_or_else(|| {
425                let required = match aggregate.op {
426                    AggOp::Count => "any scalar input",
427                    AggOp::Sum | AggOp::Min | AggOp::Max => "U32 or U64 input",
428                    AggOp::LogSumExp => "F64 input",
429                };
430                XlogError::Compilation(format!(
431                    "Unsupported aggregate input in rule for '{}': {:?}({}) receives {:?} from \
432                     {}, but the execution provider requires {}",
433                    rule.head.predicate,
434                    aggregate.op,
435                    aggregate.variable,
436                    input_type,
437                    source,
438                    required
439                ))
440            })
441    }
442
443    fn record_rule_variable_type(
444        rule: &Rule,
445        variable_types: &mut HashMap<String, (ScalarType, String)>,
446        variable: &str,
447        typ: ScalarType,
448        source: String,
449    ) -> Result<()> {
450        match variable_types.get(variable) {
451            Some((existing, existing_source)) if *existing != typ => {
452                Err(XlogError::Compilation(format!(
453                    "Type mismatch in rule for '{}': variable {} is {:?} (from {}) but {:?} \
454                     is required by {}",
455                    rule.head.predicate, variable, existing, existing_source, typ, source
456                )))
457            }
458            Some(_) => Ok(()),
459            None => {
460                variable_types.insert(variable.to_string(), (typ, source));
461                Ok(())
462            }
463        }
464    }
465
466    /// Collect all type evidence available inside a rule.
467    ///
468    /// Ordinary body atoms are considered together because lowering joins them
469    /// before evaluating arithmetic bindings. Arithmetic bindings are then
470    /// processed in source order so chained `is` expressions can propagate their
471    /// result types. Unknown inputs defer an arithmetic result until a later
472    /// schema-inference iteration; known incompatible evidence is rejected here.
473    fn infer_rule_variable_types_with_user_functions<F>(
474        &self,
475        rule: &Rule,
476        mut column_type: F,
477        user_functions: UserFunctionTypeEvidence,
478    ) -> Result<HashMap<String, (ScalarType, String)>>
479    where
480        F: FnMut(&Atom, usize) -> Option<ScalarType>,
481    {
482        let mut variable_types = HashMap::new();
483
484        for literal in &rule.body {
485            let atom = match literal {
486                BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => atom,
487                BodyLiteral::Epistemic(_)
488                | BodyLiteral::Comparison(_)
489                | BodyLiteral::IsExpr(_)
490                | BodyLiteral::Univ(_) => continue,
491            };
492            for (index, term) in atom.terms.iter().enumerate() {
493                let Term::Variable(variable) = term else {
494                    continue;
495                };
496                let Some(typ) = column_type(atom, index) else {
497                    continue;
498                };
499                Self::record_rule_variable_type(
500                    rule,
501                    &mut variable_types,
502                    variable,
503                    typ,
504                    format!("{} position {}", atom.predicate, index),
505                )?;
506            }
507        }
508
509        for literal in &rule.body {
510            let BodyLiteral::IsExpr(is_expr) = literal else {
511                continue;
512            };
513            let result_type = Self::infer_arith_type_from_known_variables(
514                &is_expr.expr,
515                &|variable| variable_types.get(variable).map(|(typ, _)| *typ),
516                user_functions,
517            )?;
518            if let Some(result_type) = result_type {
519                Self::record_rule_variable_type(
520                    rule,
521                    &mut variable_types,
522                    &is_expr.target,
523                    result_type,
524                    "an arithmetic binding".to_string(),
525                )?;
526            }
527        }
528
529        Ok(variable_types)
530    }
531
532    pub(crate) fn infer_rule_variable_types<F>(
533        &self,
534        rule: &Rule,
535        column_type: F,
536    ) -> Result<HashMap<String, (ScalarType, String)>>
537    where
538        F: FnMut(&Atom, usize) -> Option<ScalarType>,
539    {
540        self.infer_rule_variable_types_with_user_functions(
541            rule,
542            column_type,
543            UserFunctionTypeEvidence::RequireExpansion,
544        )
545    }
546
547    fn infer_rule_head_column_types_with_user_functions<F>(
548        &self,
549        rule: &Rule,
550        column_type: F,
551        user_functions: UserFunctionTypeEvidence,
552    ) -> Result<Vec<Option<ScalarType>>>
553    where
554        F: FnMut(&Atom, usize) -> Option<ScalarType>,
555    {
556        let variable_types =
557            self.infer_rule_variable_types_with_user_functions(rule, column_type, user_functions)?;
558        rule.head
559            .terms
560            .iter()
561            .map(|term| match term {
562                Term::Variable(name) => Ok(variable_types.get(name).map(|(typ, _)| *typ)),
563                Term::Aggregate(aggregate) => {
564                    Self::infer_aggregate_result_type(rule, aggregate, &variable_types)
565                }
566                Term::Anonymous => Ok(None),
567                _ => Ok(Some(term.inferred_scalar_type())),
568            })
569            .collect()
570    }
571
572    /// Infer each rule-head column from the same body, arithmetic, and aggregate
573    /// evidence used by schema inference during lowering.
574    ///
575    /// A `None` column has no statically known evidence yet. This path requires
576    /// user-defined function calls to have been expanded.
577    pub(crate) fn infer_rule_head_column_types<F>(
578        &self,
579        rule: &Rule,
580        column_type: F,
581    ) -> Result<Vec<Option<ScalarType>>>
582    where
583        F: FnMut(&Atom, usize) -> Option<ScalarType>,
584    {
585        self.infer_rule_head_column_types_with_user_functions(
586            rule,
587            column_type,
588            UserFunctionTypeEvidence::RequireExpansion,
589        )
590    }
591
592    /// Infer rule-head columns before user-defined functions have been expanded.
593    /// Function-call results remain unknown, while independent body, arithmetic,
594    /// aggregate, and head-term evidence is still validated and propagated.
595    pub(crate) fn infer_rule_head_column_types_before_function_expansion<F>(
596        &self,
597        rule: &Rule,
598        column_type: F,
599    ) -> Result<Vec<Option<ScalarType>>>
600    where
601        F: FnMut(&Atom, usize) -> Option<ScalarType>,
602    {
603        self.infer_rule_head_column_types_with_user_functions(
604            rule,
605            column_type,
606            UserFunctionTypeEvidence::Defer,
607        )
608    }
609
610    /// Infer schemas from facts and predicate declarations
611    pub(crate) fn infer_schemas(&mut self, program: &Program) -> Result<()> {
612        let domains: HashMap<String, ScalarType> = program
613            .domains
614            .iter()
615            .map(|domain| (domain.name.clone(), domain.typ))
616            .collect();
617
618        // First, use explicit predicate declarations
619        for pred_decl in &program.predicates {
620            let declared_columns = pred_decl.schema_columns();
621            let columns: Vec<(String, ScalarType)> = declared_columns
622                .iter()
623                .enumerate()
624                .map(|(i, col)| {
625                    let name = col.name.clone().unwrap_or_else(|| format!("c{}", i));
626                    resolve_pred_column_type(&pred_decl.name, i, &col.typ, &domains)
627                        .map(|ty| (name, ty))
628                })
629                .collect::<Result<Vec<_>>>()?;
630            self.schemas
631                .insert(pred_decl.name.clone(), Schema::new(columns));
632        }
633
634        // Then, infer from facts (if no declaration exists)
635        for rule in program.facts() {
636            let pred = &rule.head.predicate;
637            if !self.schemas.contains_key(pred) {
638                let columns: Vec<(String, ScalarType)> = rule
639                    .head
640                    .terms
641                    .iter()
642                    .enumerate()
643                    .map(|(i, term)| {
644                        let ty = term.inferred_scalar_type();
645                        (format!("c{}", i), ty)
646                    })
647                    .collect();
648                self.schemas.insert(pred.clone(), Schema::new(columns));
649            }
650        }
651
652        // Probabilistic facts and annotated-disjunction choices are also
653        // ground schema evidence. Register them before the body-only fallback
654        // so a rule variable does not default an otherwise typed predicate to
655        // U64 merely because its facts live in a probabilistic AST collection.
656        for pf in &program.prob_facts {
657            let pred = &pf.atom.predicate;
658            if self.schemas.contains_key(pred) {
659                continue;
660            }
661            let columns: Vec<(String, ScalarType)> = pf
662                .atom
663                .terms
664                .iter()
665                .enumerate()
666                .map(|(i, term)| (format!("c{}", i), term.inferred_scalar_type()))
667                .collect();
668            self.schemas.insert(pred.clone(), Schema::new(columns));
669        }
670
671        for ad in &program.annotated_disjunctions {
672            for choice in &ad.choices {
673                let pred = &choice.atom.predicate;
674                if self.schemas.contains_key(pred) {
675                    continue;
676                }
677                let columns: Vec<(String, ScalarType)> = choice
678                    .atom
679                    .terms
680                    .iter()
681                    .enumerate()
682                    .map(|(i, term)| (format!("c{}", i), term.inferred_scalar_type()))
683                    .collect();
684                self.schemas.insert(pred.clone(), Schema::new(columns));
685            }
686        }
687
688        // Infer schemas for extensional predicates that occur only in rule bodies
689        // before propagating rule-head types. This lets aggregates and ordinary
690        // head variables consume the same body-column types that execution will
691        // use, while leaving derived predicates to the fixed-point pass below.
692        let derived_predicates = program
693            .rules
694            .iter()
695            .filter(|rule| !rule.body.is_empty())
696            .map(|rule| rule.head.predicate.as_str())
697            .collect::<HashSet<_>>();
698        for rule in &program.rules {
699            for lit in &rule.body {
700                let atom = match lit {
701                    BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => atom,
702                    BodyLiteral::Epistemic(_)
703                    | BodyLiteral::Comparison(_)
704                    | BodyLiteral::IsExpr(_)
705                    | BodyLiteral::Univ(_) => continue,
706                };
707                let pred = &atom.predicate;
708                if self.schemas.contains_key(pred) || derived_predicates.contains(pred.as_str()) {
709                    continue;
710                }
711                let columns: Vec<(String, ScalarType)> = atom
712                    .terms
713                    .iter()
714                    .enumerate()
715                    .map(|(i, term)| (format!("c{}", i), term.inferred_scalar_type()))
716                    .collect();
717                let schema = Schema::new(columns)
718                    .with_sort_labels(sort_labels_from_terms(&atom.terms))
719                    .expect("body sort labels match inferred schema arity");
720                self.schemas.insert(pred.clone(), schema);
721            }
722        }
723
724        // Propagate body-derived rule-head types to a fixed point before
725        // defaulting variables that have no type anchor. Columns converge
726        // independently so an unresolved sibling cannot hide known evidence.
727        let mut inferred_rule_columns: HashMap<String, Vec<Option<ScalarType>>> = HashMap::new();
728        for rule in &program.rules {
729            if self.schemas.contains_key(&rule.head.predicate) {
730                continue;
731            }
732            inferred_rule_columns
733                .entry(rule.head.predicate.clone())
734                .or_insert_with(|| vec![None; rule.head.terms.len()]);
735        }
736
737        loop {
738            let mut changed = false;
739            for rule in &program.rules {
740                let pred = &rule.head.predicate;
741                if self.schemas.contains_key(pred) {
742                    continue;
743                }
744
745                let Some(current_columns) = inferred_rule_columns.get(pred) else {
746                    continue;
747                };
748                if current_columns.len() != rule.head.terms.len() {
749                    continue;
750                }
751
752                let resolved_columns = self.infer_rule_head_column_types(rule, |atom, index| {
753                    self.schemas
754                        .get(&atom.predicate)
755                        .and_then(|schema| schema.column_type(index))
756                        .or_else(|| {
757                            inferred_rule_columns
758                                .get(&atom.predicate)
759                                .and_then(|columns| columns.get(index))
760                                .copied()
761                                .flatten()
762                        })
763                })?;
764
765                let columns = inferred_rule_columns
766                    .get_mut(pred)
767                    .expect("rule-head inference entry exists");
768                for (index, (column, resolved)) in
769                    columns.iter_mut().zip(resolved_columns).enumerate()
770                {
771                    match (*column, resolved) {
772                        (None, Some(resolved)) => {
773                            *column = Some(resolved);
774                            changed = true;
775                        }
776                        (Some(existing), Some(resolved)) if existing != resolved => {
777                            return Err(XlogError::Compilation(format!(
778                                "Conflicting inferred schema for predicate '{}': column {} is \
779                                 {:?} in one rule and {:?} in another",
780                                pred,
781                                index + 1,
782                                existing,
783                                resolved
784                            )));
785                        }
786                        (None, None) | (Some(_), None) | (Some(_), Some(_)) => {}
787                    }
788                }
789            }
790            if !changed {
791                break;
792            }
793        }
794        for rule in &program.rules {
795            let pred = &rule.head.predicate;
796            if !self.schemas.contains_key(pred) {
797                let inferred_columns = inferred_rule_columns
798                    .get(pred)
799                    .expect("undeclared rule head has an inference entry");
800                let columns = rule
801                    .head
802                    .terms
803                    .iter()
804                    .zip(inferred_columns)
805                    .enumerate()
806                    .map(|(index, (term, inferred))| {
807                        (
808                            format!("c{index}"),
809                            inferred.unwrap_or_else(|| term.inferred_scalar_type()),
810                        )
811                    })
812                    .collect();
813                let schema = Schema::new(columns)
814                    .with_sort_labels(sort_labels_from_terms(&rule.head.terms))
815                    .expect("rule head sort labels match inferred schema arity");
816                self.schemas.insert(pred.clone(), schema);
817            }
818        }
819
820        Ok(())
821    }
822
823    /// Infer predicate schemas and validate the type flow within every rule.
824    ///
825    /// This applies the schema/type contract shared by execution routes without
826    /// preprocessing, stratifying, validating unrelated term forms, or building
827    /// an execution plan. The inferred schemas remain available through
828    /// [`Lowerer::schemas`].
829    pub fn infer_and_validate_schemas(&mut self, program: &Program) -> Result<()> {
830        self.infer_schemas(program)?;
831        self.validate_rule_types(program)
832    }
833
834    fn infer_cardinalities(&mut self, program: &Program) {
835        self.est_cardinality.clear();
836
837        let mut fact_counts: HashMap<String, u64> = HashMap::new();
838        for fact in program.facts() {
839            *fact_counts.entry(fact.head.predicate.clone()).or_insert(0) += 1;
840        }
841
842        for pred in self.schemas.keys() {
843            let est = self
844                .cardinality_hints
845                .get(pred)
846                .copied()
847                .or_else(|| fact_counts.get(pred).copied())
848                .unwrap_or(1000)
849                .max(1);
850            self.est_cardinality.insert(pred.clone(), est);
851        }
852    }
853
854    /// Build SCCs from the dependency graph
855    fn build_sccs(&mut self, program: &Program) {
856        let graph = build_dependency_graph(program);
857        let scc_groups = find_sccs_for_lowering(&graph);
858
859        self.sccs.clear();
860        self.scc_by_pred.clear();
861        for (id, predicates) in scc_groups.iter().enumerate() {
862            // An SCC is recursive if it has more than one predicate
863            // or if a single predicate depends on itself positively
864            let is_recursive = if predicates.len() > 1 {
865                true
866            } else {
867                let pred = &predicates[0];
868                graph
869                    .outgoing(pred)
870                    .iter()
871                    .any(|e| e.to == *pred && e.dep_type == DepType::Positive)
872            };
873
874            for p in predicates {
875                self.scc_by_pred.insert(p.clone(), id as u32);
876            }
877            self.sccs.push(Scc {
878                id: id as u32,
879                predicates: predicates.clone(),
880                is_recursive,
881            });
882        }
883    }
884
885    fn prepare_program_for_lowering(&mut self, program: &Program) -> Result<()> {
886        validate_lowerable_terms(program)?;
887        self.infer_and_validate_schemas(program)?;
888        self.infer_cardinalities(program);
889
890        // Pre-allocate RelIds for declared predicates so schema-only programs
891        // can populate relation stores before any facts or executable rules
892        // mention those relations. This keeps ILP candidate generation and
893        // runtime relation upload aligned with declared schemas.
894        for pred_decl in &program.predicates {
895            self.get_or_create_rel_id(&pred_decl.name);
896        }
897        // Facts are grouped and materialized directly into the relation store.
898        // They still need stable relation IDs even when no declaration or rule
899        // mentions their predicates.
900        for fact in program.facts() {
901            self.get_or_create_rel_id(&fact.head.predicate);
902        }
903
904        Ok(())
905    }
906
907    /// Validate every source-level contract enforced while lowering, without
908    /// requiring a stratification or constructing an execution plan.
909    ///
910    /// Epistemic preparation uses this after replacing modal literals with their
911    /// validation-only ordinary counterparts. It intentionally exercises the same
912    /// schema inference, rule type checks, constant conversion, arithmetic ordering,
913    /// negation lowering, and head projection as production lowering.
914    pub(crate) fn validate_program_without_plan(&mut self, program: &Program) -> Result<()> {
915        self.prepare_program_for_lowering(program)?;
916
917        for rule in program.proper_rules() {
918            self.lower_rule(rule)?;
919        }
920
921        // Match the relation allocation and validation performed by `lower_program`
922        // for learnable rules as well. These rules cannot contain modal literals, but
923        // they may share declarations and schemas with the authored program.
924        for learnable in &program.learnable_rules {
925            self.get_or_create_rel_id(&learnable.head.predicate);
926            for lit in &learnable.body {
927                if let BodyLiteral::Positive(atom) = lit {
928                    self.get_or_create_rel_id(&atom.predicate);
929                }
930            }
931        }
932        for learnable in &program.learnable_rules {
933            self.lower_learnable_rule(learnable)?;
934        }
935
936        Ok(())
937    }
938
939    /// Lower an entire program to an execution plan
940    pub fn lower_program(&mut self, program: &Program) -> Result<ExecutionPlan> {
941        self.prepare_program_for_lowering(program)?;
942
943        // Build SCCs
944        self.build_sccs(program);
945
946        // Build execution plan
947        let mut builder = PlanBuilder::new();
948
949        // Add SCCs to the builder
950        for scc in &self.sccs {
951            builder.add_scc(scc.clone());
952        }
953
954        // Build strata from our strata field
955        for (id, preds) in self.strata.iter().enumerate() {
956            // Find which SCCs belong to this stratum
957            let pred_set: std::collections::HashSet<&str> =
958                preds.iter().map(|s| s.as_str()).collect();
959            let scc_ids: Vec<u32> = self
960                .sccs
961                .iter()
962                .filter(|scc| scc.predicates.iter().any(|p| pred_set.contains(p.as_str())))
963                .map(|scc| scc.id)
964                .collect();
965
966            if !scc_ids.is_empty() {
967                builder.add_stratum(IrStratum {
968                    id: id as u32,
969                    sccs: scc_ids,
970                });
971            }
972        }
973
974        // Lower each rule
975        let mut rules_by_pred: HashMap<String, Vec<&Rule>> = HashMap::new();
976        for rule in program.proper_rules() {
977            rules_by_pred
978                .entry(rule.head.predicate.clone())
979                .or_default()
980                .push(rule);
981        }
982
983        // Lower proper rules in sorted head order: HashMap iteration order
984        // varies per process, which used to make rule order (and RelId
985        // assignment for head-first predicates) process-nondeterministic.
986        let mut preds_sorted: Vec<&String> = rules_by_pred.keys().collect();
987        preds_sorted.sort();
988        for pred in preds_sorted {
989            let rules = &rules_by_pred[pred];
990            let scc_id = self.find_scc_for_predicate(pred);
991
992            for rule in rules {
993                let body = self.lower_rule(rule)?;
994                let meta = self.create_meta_for_predicate(pred);
995
996                builder.add_rule(
997                    scc_id,
998                    CompiledRule {
999                        head: pred.clone(),
1000                        body,
1001                        meta,
1002                    },
1003                );
1004            }
1005        }
1006
1007        // Lower learnable rules into tensor-masked joins.
1008        // Pre-allocate RelIds for ALL learnable predicates (heads + bodies)
1009        // so every lower_learnable_rule snapshot is complete.
1010        for learnable in &program.learnable_rules {
1011            self.get_or_create_rel_id(&learnable.head.predicate);
1012            for lit in &learnable.body {
1013                if let BodyLiteral::Positive(atom) = lit {
1014                    self.get_or_create_rel_id(&atom.predicate);
1015                }
1016            }
1017        }
1018        for learnable in &program.learnable_rules {
1019            let head_pred = &learnable.head.predicate;
1020            let scc_id = self.find_scc_for_predicate(head_pred);
1021            let body = self.lower_learnable_rule(learnable)?;
1022            let meta = self.create_meta_for_predicate(head_pred);
1023            builder.add_rule(
1024                scc_id,
1025                CompiledRule {
1026                    head: head_pred.clone(),
1027                    body,
1028                    meta,
1029                },
1030            );
1031        }
1032
1033        let mut plan = builder.build();
1034        // Record relation arities for downstream generic multiway shape
1035        // promoters that size Scan leaves from these values.
1036        // One pre-pass over the AST covers every predicate the lowerer
1037        // assigned a RelId: rule heads, positive/negated body atoms,
1038        // and facts.
1039        for rule in program.proper_rules() {
1040            if let Some(&id) = self.rel_ids.get(&rule.head.predicate) {
1041                plan.rel_arities.insert(id, rule.head.terms.len());
1042            }
1043            for lit in &rule.body {
1044                let atom = match lit {
1045                    BodyLiteral::Positive(a) | BodyLiteral::Negated(a) => a,
1046                    _ => continue,
1047                };
1048                if let Some(&id) = self.rel_ids.get(&atom.predicate) {
1049                    plan.rel_arities.insert(id, atom.terms.len());
1050                }
1051            }
1052        }
1053        for fact in program.facts() {
1054            if let Some(&id) = self.rel_ids.get(&fact.head.predicate) {
1055                plan.rel_arities.insert(id, fact.head.terms.len());
1056            }
1057        }
1058        Ok(plan)
1059    }
1060
1061    /// Find the SCC ID for a predicate
1062    fn find_scc_for_predicate(&self, pred: &str) -> u32 {
1063        self.scc_by_pred.get(pred).copied().unwrap_or(0)
1064    }
1065
1066    /// Create metadata for a predicate
1067    fn create_meta_for_predicate(&self, pred: &str) -> RirMeta {
1068        let schema = self
1069            .schemas
1070            .get(pred)
1071            .cloned()
1072            .unwrap_or_else(|| Schema::new(vec![]));
1073        RirMeta::with_schema(schema)
1074    }
1075
1076    /// Lower a learnable rule template into a TensorMaskedJoin node.
1077    /// Validates that the body has exactly two positive atoms.
1078    /// Sorts rel_index by RelId for deterministic tensor dimension mapping.
1079    /// Uses get_or_create_rel_id for heads so head-only predicates are handled.
1080    fn lower_learnable_rule(&mut self, rule: &LearnableRule) -> Result<RirNode> {
1081        // Validate body shape before indexing fixed body positions.
1082        if rule.body.len() != 2 {
1083            return Err(XlogError::Compilation(format!(
1084                "learnable rule '{}' requires exactly 2 body literals, got {}",
1085                rule.mask_name,
1086                rule.body.len()
1087            )));
1088        }
1089        for (idx, lit) in rule.body.iter().enumerate() {
1090            match lit {
1091                BodyLiteral::Positive(_) => {}
1092                _ => {
1093                    return Err(XlogError::Compilation(format!(
1094                        "learnable rule '{}' body[{}]: only positive atoms allowed",
1095                        rule.mask_name, idx
1096                    )));
1097                }
1098            }
1099        }
1100
1101        // Sort by RelId for deterministic tensor dimension mapping.
1102        let mut rel_index: Vec<(RelId, String)> = self
1103            .rel_ids()
1104            .iter()
1105            .map(|(name, id)| (*id, name.clone()))
1106            .collect();
1107        rel_index.sort_by_key(|(id, _)| id.0);
1108        let schema_size = rel_index.len();
1109
1110        let (left_keys, right_keys) =
1111            self.extract_template_join_keys(&rule.body[0], &rule.body[1])?;
1112
1113        let head_rel_name = rule.head.predicate.clone();
1114        // Allocate lazily because head-only predicates may not have a RelId yet.
1115        let head_rel_id = self.get_or_create_rel_id(&head_rel_name);
1116
1117        // Compute head projection: map head variables to join result columns.
1118        // Join result layout: [left_col_0..left_col_n, right_col_0..right_col_m].
1119        let left_atom = rule.body[0].atom().unwrap();
1120        let right_atom = rule.body[1].atom().unwrap();
1121        let left_arity = left_atom.terms.len();
1122
1123        // Build variable -> first-occurrence column mapping over joined result
1124        let mut var_to_col: HashMap<String, usize> = HashMap::new();
1125        for (i, term) in left_atom.terms.iter().enumerate() {
1126            if let Some(name) = term.variable_name() {
1127                var_to_col.entry(name.to_string()).or_insert(i);
1128            }
1129        }
1130        for (i, term) in right_atom.terms.iter().enumerate() {
1131            if let Some(name) = term.variable_name() {
1132                var_to_col.entry(name.to_string()).or_insert(left_arity + i);
1133            }
1134        }
1135
1136        let mut head_projection: Vec<usize> = Vec::new();
1137        for term in &rule.head.terms {
1138            if let Some(name) = term.variable_name() {
1139                let col = var_to_col.get(name).ok_or_else(|| {
1140                    XlogError::Compilation(format!(
1141                        "Learnable rule head variable '{}' not found in body atoms \
1142                         ({}, {}). All head variables must appear in the body.",
1143                        name, left_atom.predicate, right_atom.predicate,
1144                    ))
1145                })?;
1146                head_projection.push(*col);
1147            } else {
1148                return Err(XlogError::Compilation(format!(
1149                    "Learnable rule head must contain only variables, \
1150                     found constant {:?} in head of '{}'",
1151                    term, head_rel_name,
1152                )));
1153            }
1154        }
1155
1156        // Infer schema for head predicate from the learnable rule if not already set.
1157        // The head's column types come from the projected join columns.
1158        if !self.schemas.contains_key(&head_rel_name) {
1159            let columns: Vec<(String, ScalarType)> = head_projection
1160                .iter()
1161                .enumerate()
1162                .map(|(i, &col)| {
1163                    // Determine the type from left or right atom's schema
1164                    let ty = if col < left_arity {
1165                        self.schemas
1166                            .get(&left_atom.predicate)
1167                            .and_then(|s| s.column_type(col))
1168                            .unwrap_or(ScalarType::U32)
1169                    } else {
1170                        self.schemas
1171                            .get(&right_atom.predicate)
1172                            .and_then(|s| s.column_type(col - left_arity))
1173                            .unwrap_or(ScalarType::U32)
1174                    };
1175                    (format!("c{}", i), ty)
1176                })
1177                .collect();
1178            self.schemas
1179                .insert(head_rel_name.clone(), Schema::new(columns));
1180        }
1181
1182        Ok(RirNode::TensorMaskedJoin {
1183            mask_name: rule.mask_name.clone(),
1184            schema_size,
1185            left_keys,
1186            right_keys,
1187            rel_index,
1188            head_rel_name,
1189            head_rel_id,
1190            max_active_rules: self.max_active_rules,
1191            head_projection,
1192        })
1193    }
1194
1195    /// Extract join keys from two body literals' shared variables.
1196    /// For `b1(X, Z), b2(Z, Y)`, the shared variable Z gives left_keys=[1], right_keys=[0].
1197    fn extract_template_join_keys(
1198        &self,
1199        left: &BodyLiteral,
1200        right: &BodyLiteral,
1201    ) -> Result<(Vec<usize>, Vec<usize>)> {
1202        let left_atom = left
1203            .atom()
1204            .ok_or_else(|| XlogError::Compilation("Learnable body[0] is not an atom".into()))?;
1205        let right_atom = right
1206            .atom()
1207            .ok_or_else(|| XlogError::Compilation("Learnable body[1] is not an atom".into()))?;
1208
1209        let mut left_keys = Vec::new();
1210        let mut right_keys = Vec::new();
1211
1212        for (li, lt) in left_atom.terms.iter().enumerate() {
1213            if let Some(lname) = lt.variable_name() {
1214                for (ri, rt) in right_atom.terms.iter().enumerate() {
1215                    if let Some(rname) = rt.variable_name() {
1216                        if lname == rname {
1217                            left_keys.push(li);
1218                            right_keys.push(ri);
1219                        }
1220                    }
1221                }
1222            }
1223        }
1224
1225        Ok((left_keys, right_keys))
1226    }
1227
1228    /// Lower a single rule to an RIR node
1229    fn lower_rule(&mut self, rule: &Rule) -> Result<RirNode> {
1230        if let Some(lit) = rule.body.iter().find_map(|lit| match lit {
1231            BodyLiteral::Epistemic(lit) => Some(lit),
1232            _ => None,
1233        }) {
1234            return Err(XlogError::UnsupportedEpistemicConstruct {
1235                construct: "RIR lowering boundary".to_string(),
1236                context: format!("{:?} {}({})", lit.op, lit.atom.predicate, lit.atom.arity()),
1237            });
1238        }
1239
1240        // Split body literals.
1241        let (positive_atoms, negated_atoms, comparisons, is_exprs) =
1242            Self::split_body_literals(&rule.body);
1243
1244        // Allocate RelIds for all body predicates in source order so join planning
1245        // does not influence identifier assignment.
1246        for lit in &rule.body {
1247            match lit {
1248                BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
1249                    self.get_or_create_rel_id(&atom.predicate);
1250                }
1251                BodyLiteral::Epistemic(_)
1252                | BodyLiteral::Comparison(_)
1253                | BodyLiteral::IsExpr(_)
1254                | BodyLiteral::Univ(_) => {}
1255            }
1256        }
1257
1258        // Plan positive atoms (join tree shape + leaf order).
1259        //
1260        // Rules with no positive atoms are legal for nullary/ground heads in our
1261        // probabilistic profiles (e.g. `q() :- not p().`). Lower them by seeding
1262        // the body with a unit relation ({()}) and applying filters/negations.
1263        let (positive_root, leaf_order) = if positive_atoms.is_empty() {
1264            (RirNode::Unit, Vec::new())
1265        } else {
1266            self.plan_positive_atoms(&positive_atoms)?
1267        };
1268
1269        // Build variable environment from the planned leaf order (matches join output layout:
1270        // left subtree columns then right subtree columns).
1271        let mut var_env = VariableEnv::new();
1272        let mut current_col = 0;
1273        for atom in &leaf_order {
1274            let schema = self.schemas.get(&atom.predicate);
1275            for (i, term) in atom.terms.iter().enumerate() {
1276                if let Term::Variable(name) = term {
1277                    if name == "_" {
1278                        continue;
1279                    }
1280                    var_env.add_occurrence(name, atom.predicate.clone(), i, current_col + i);
1281                    // Also record the type for this variable (first occurrence wins)
1282                    if !var_env.types.contains_key(name) {
1283                        let typ = schema
1284                            .and_then(|s| s.column_type(i))
1285                            .unwrap_or(ScalarType::I64); // Default to I64 for arithmetic
1286                        var_env.types.insert(name.to_string(), typ);
1287                    }
1288                }
1289            }
1290            current_col += atom.terms.len();
1291        }
1292        var_env.total_cols = current_col;
1293
1294        // Lower the body starting from the planned positive join root.
1295        let body_node = self.lower_body_parts(
1296            positive_root,
1297            &negated_atoms,
1298            &comparisons,
1299            &is_exprs,
1300            &mut var_env,
1301        )?;
1302
1303        if rule.has_aggregation() {
1304            return self.lower_aggregate_rule(&rule.head, body_node, &var_env);
1305        }
1306
1307        // Project to head terms (variables and constants).
1308        let projection_exprs = self.compute_head_projection(&rule.head, &var_env)?;
1309
1310        if Self::is_identity_projection(&projection_exprs, var_env.column_count()) {
1311            Ok(body_node)
1312        } else {
1313            Ok(RirNode::Project {
1314                input: Box::new(body_node),
1315                columns: projection_exprs,
1316            })
1317        }
1318    }
1319
1320    fn split_body_literals(
1321        body: &[BodyLiteral],
1322    ) -> (Vec<&Atom>, Vec<&Atom>, Vec<&Comparison>, Vec<&IsExpr>) {
1323        let mut positive_atoms: Vec<&Atom> = Vec::new();
1324        let mut negated_atoms: Vec<&Atom> = Vec::new();
1325        let mut comparisons: Vec<&Comparison> = Vec::new();
1326        let mut is_exprs: Vec<&IsExpr> = Vec::new();
1327
1328        for lit in body {
1329            match lit {
1330                BodyLiteral::Positive(atom) => positive_atoms.push(atom),
1331                BodyLiteral::Negated(atom) => negated_atoms.push(atom),
1332                BodyLiteral::Epistemic(_) => {}
1333                BodyLiteral::Comparison(cmp) => comparisons.push(cmp),
1334                BodyLiteral::IsExpr(is_expr) => is_exprs.push(is_expr),
1335                BodyLiteral::Univ(_) => {}
1336            }
1337        }
1338
1339        (positive_atoms, negated_atoms, comparisons, is_exprs)
1340    }
1341
1342    fn atom_vars(atom: &Atom) -> std::collections::HashSet<String> {
1343        atom.terms
1344            .iter()
1345            .flat_map(|t| t.variables().into_iter())
1346            .filter(|name| *name != "_")
1347            .map(ToOwned::to_owned)
1348            .collect()
1349    }
1350
1351    fn estimate_atom_rows(&self, atom: &Atom) -> f64 {
1352        let base = self
1353            .est_cardinality
1354            .get(&atom.predicate)
1355            .copied()
1356            .unwrap_or(1000)
1357            .max(1) as f64;
1358
1359        let const_count = atom
1360            .terms
1361            .iter()
1362            .filter(|t| term_to_const_value(t).is_some())
1363            .count();
1364
1365        // Equality constants are usually selective; use a conservative default.
1366        let selectivity = 0.1_f64.powi(const_count as i32);
1367        (base * selectivity).max(1.0)
1368    }
1369
1370    fn build_cartesian_join(
1371        &self,
1372        left: RirNode,
1373        right: RirNode,
1374        left_width: usize,
1375        right_width: usize,
1376    ) -> RirNode {
1377        // Implement cross join by appending a constant key column to both inputs and joining on it,
1378        // then projecting away the constant columns.
1379        let left_const_col =
1380            ProjectExpr::Computed(Expr::Const(ConstValue::U32(0)), ScalarType::U32);
1381        let right_const_col =
1382            ProjectExpr::Computed(Expr::Const(ConstValue::U32(0)), ScalarType::U32);
1383
1384        let mut left_cols: Vec<ProjectExpr> = (0..left_width).map(ProjectExpr::Column).collect();
1385        left_cols.push(left_const_col);
1386        let left_aug = RirNode::Project {
1387            input: Box::new(left),
1388            columns: left_cols,
1389        };
1390
1391        let mut right_cols: Vec<ProjectExpr> = (0..right_width).map(ProjectExpr::Column).collect();
1392        right_cols.push(right_const_col);
1393        let right_aug = RirNode::Project {
1394            input: Box::new(right),
1395            columns: right_cols,
1396        };
1397
1398        let joined = RirNode::Join {
1399            left: Box::new(left_aug),
1400            right: Box::new(right_aug),
1401            left_keys: vec![left_width],
1402            right_keys: vec![right_width],
1403            join_type: JoinType::Inner,
1404        };
1405
1406        let mut keep: Vec<ProjectExpr> = Vec::with_capacity(left_width + right_width);
1407        keep.extend((0..left_width).map(ProjectExpr::Column));
1408        let right_start = left_width + 1;
1409        keep.extend((right_start..right_start + right_width).map(ProjectExpr::Column));
1410
1411        RirNode::Project {
1412            input: Box::new(joined),
1413            columns: keep,
1414        }
1415    }
1416
1417    fn make_leaf_plan<'a>(&mut self, atom: &'a Atom, orig_idx: usize) -> Result<JoinPlan<'a>> {
1418        let rel_id = self.get_or_create_rel_id(&atom.predicate);
1419        let scan = RirNode::Scan { rel: rel_id };
1420        let node = self.apply_constant_filters(scan, atom, 0)?;
1421
1422        let mut var_pos: HashMap<String, usize> = HashMap::new();
1423        for (i, term) in atom.terms.iter().enumerate() {
1424            if let Term::Variable(name) = term {
1425                if name != "_" {
1426                    var_pos.entry(name.clone()).or_insert(i);
1427                }
1428            }
1429        }
1430
1431        let est_rows = self.estimate_atom_rows(atom);
1432        Ok(JoinPlan {
1433            node,
1434            leaf_order: vec![atom],
1435            leaf_order_idx: vec![orig_idx],
1436            var_pos,
1437            width: atom.terms.len(),
1438            est_rows,
1439            total_cost: est_rows,
1440        })
1441    }
1442
1443    fn join_plans<'a>(&self, left: &JoinPlan<'a>, right: &JoinPlan<'a>) -> JoinPlan<'a> {
1444        let shared_vars: Vec<&String> = left
1445            .var_pos
1446            .keys()
1447            .filter(|v| right.var_pos.contains_key(*v))
1448            .collect();
1449
1450        let node = if shared_vars.is_empty() {
1451            self.build_cartesian_join(
1452                left.node.clone(),
1453                right.node.clone(),
1454                left.width,
1455                right.width,
1456            )
1457        } else {
1458            let mut key_pairs: Vec<(usize, usize)> = shared_vars
1459                .iter()
1460                .filter_map(|v| {
1461                    Some((
1462                        left.var_pos.get(*v).copied()?,
1463                        right.var_pos.get(*v).copied()?,
1464                    ))
1465                })
1466                .collect();
1467            key_pairs.sort_unstable();
1468
1469            let (left_keys, right_keys): (Vec<usize>, Vec<usize>) = key_pairs.into_iter().unzip();
1470
1471            RirNode::Join {
1472                left: Box::new(left.node.clone()),
1473                right: Box::new(right.node.clone()),
1474                left_keys,
1475                right_keys,
1476                join_type: JoinType::Inner,
1477            }
1478        };
1479
1480        let mut leaf_order = left.leaf_order.clone();
1481        leaf_order.extend(right.leaf_order.iter().copied());
1482
1483        let mut leaf_order_idx = left.leaf_order_idx.clone();
1484        leaf_order_idx.extend_from_slice(&right.leaf_order_idx);
1485
1486        let mut var_pos = left.var_pos.clone();
1487        for (var, pos) in &right.var_pos {
1488            var_pos.entry(var.clone()).or_insert(left.width + *pos);
1489        }
1490
1491        let shared = shared_vars.len();
1492        let mut selectivity = if shared == 0 {
1493            1.0
1494        } else {
1495            0.1_f64.powi(shared as i32)
1496        };
1497        if shared == 0 {
1498            // Penalize cartesian joins strongly.
1499            selectivity *= 1.0e6;
1500        }
1501
1502        let output_rows = (left.est_rows * right.est_rows * selectivity).max(1.0);
1503
1504        // Hash join cost is sensitive to which side is build (right) and probe (left).
1505        let build_cost = right.est_rows;
1506        let probe_cost = left.est_rows * 0.5;
1507        let total_cost = left.total_cost + right.total_cost + build_cost + probe_cost + output_rows;
1508
1509        JoinPlan {
1510            node,
1511            leaf_order,
1512            leaf_order_idx,
1513            var_pos,
1514            width: left.width + right.width,
1515            est_rows: output_rows,
1516            total_cost,
1517        }
1518    }
1519
1520    fn plan_positive_atoms_bushy<'a>(
1521        &mut self,
1522        atoms: &[&'a Atom],
1523    ) -> Result<(RirNode, Vec<&'a Atom>)> {
1524        let n = atoms.len();
1525        if n == 0 {
1526            return Err(XlogError::Compilation("Empty rule body".to_string()));
1527        }
1528        if n == 1 {
1529            let plan = self.make_leaf_plan(atoms[0], 0)?;
1530            return Ok((plan.node, plan.leaf_order));
1531        }
1532
1533        let size = 1usize << n;
1534        let mut best: Vec<Option<JoinPlan<'a>>> = (0..size).map(|_| None).collect();
1535
1536        for (i, atom) in atoms.iter().enumerate() {
1537            best[1usize << i] = Some(self.make_leaf_plan(atom, i)?);
1538        }
1539
1540        fn lex_lt(a: &[usize], b: &[usize]) -> bool {
1541            for (ai, bi) in a.iter().zip(b.iter()) {
1542                if ai != bi {
1543                    return ai < bi;
1544                }
1545            }
1546            a.len() < b.len()
1547        }
1548
1549        for mask in 1..size {
1550            if mask.count_ones() <= 1 {
1551                continue;
1552            }
1553
1554            let mut best_for_mask: Option<JoinPlan<'a>> = None;
1555
1556            let mut sub = (mask - 1) & mask;
1557            while sub > 0 {
1558                let a = sub;
1559                let b = mask ^ a;
1560                if b == 0 {
1561                    sub = (sub - 1) & mask;
1562                    continue;
1563                }
1564
1565                let (Some(plan_a), Some(plan_b)) = (&best[a], &best[b]) else {
1566                    sub = (sub - 1) & mask;
1567                    continue;
1568                };
1569
1570                // Consider both orientations: A ⋈ B and B ⋈ A.
1571                for (left, right) in [(plan_a, plan_b), (plan_b, plan_a)] {
1572                    let cand = self.join_plans(left, right);
1573                    let replace = match &best_for_mask {
1574                        None => true,
1575                        Some(current) => {
1576                            if cand.total_cost < current.total_cost {
1577                                true
1578                            } else if (cand.total_cost - current.total_cost).abs() < 1e-9 {
1579                                lex_lt(&cand.leaf_order_idx, &current.leaf_order_idx)
1580                            } else {
1581                                false
1582                            }
1583                        }
1584                    };
1585
1586                    if replace {
1587                        best_for_mask = Some(cand);
1588                    }
1589                }
1590
1591                sub = (sub - 1) & mask;
1592            }
1593
1594            best[mask] = best_for_mask;
1595        }
1596
1597        let full_mask = size - 1;
1598        if let Some(plan) = best[full_mask].take() {
1599            return Ok((plan.node, plan.leaf_order));
1600        }
1601
1602        // Should be unreachable, but fall back to greedy ordering.
1603        let ordered = self.order_positive_atoms_greedy(atoms);
1604        let mut dummy_env = VariableEnv::new();
1605        let node = self.build_join_tree(&ordered, &mut dummy_env)?;
1606        Ok((node, ordered))
1607    }
1608
1609    fn plan_positive_atoms<'a>(&mut self, atoms: &[&'a Atom]) -> Result<(RirNode, Vec<&'a Atom>)> {
1610        if atoms.len() <= 1 {
1611            if atoms.is_empty() {
1612                return Err(XlogError::Compilation("Empty rule body".to_string()));
1613            }
1614            let plan = self.make_leaf_plan(atoms[0], 0)?;
1615            return Ok((plan.node, plan.leaf_order));
1616        }
1617
1618        const MAX_BUSHY_DP_ATOMS: usize = 10;
1619        if atoms.len() <= MAX_BUSHY_DP_ATOMS {
1620            return self.plan_positive_atoms_bushy(atoms);
1621        }
1622
1623        // Greedy bushy join planning for large rules (scales beyond exponential DP).
1624        self.plan_positive_atoms_bushy_greedy(atoms)
1625    }
1626
1627    fn plan_positive_atoms_bushy_greedy<'a>(
1628        &mut self,
1629        atoms: &[&'a Atom],
1630    ) -> Result<(RirNode, Vec<&'a Atom>)> {
1631        if atoms.is_empty() {
1632            return Err(XlogError::Compilation("Empty rule body".to_string()));
1633        }
1634
1635        fn lex_lt(a: &[usize], b: &[usize]) -> bool {
1636            for (ai, bi) in a.iter().zip(b.iter()) {
1637                if ai != bi {
1638                    return ai < bi;
1639                }
1640            }
1641            a.len() < b.len()
1642        }
1643
1644        let mut plans: Vec<JoinPlan<'a>> = Vec::with_capacity(atoms.len());
1645        for (idx, atom) in atoms.iter().enumerate() {
1646            plans.push(self.make_leaf_plan(atom, idx)?);
1647        }
1648
1649        while plans.len() > 1 {
1650            let mut best_pair: Option<(usize, usize, JoinPlan<'a>)> = None;
1651
1652            for i in 0..plans.len() {
1653                for j in (i + 1)..plans.len() {
1654                    let a = &plans[i];
1655                    let b = &plans[j];
1656
1657                    let cand_ab = self.join_plans(a, b);
1658                    let cand_ba = self.join_plans(b, a);
1659
1660                    let cand = if cand_ab.total_cost < cand_ba.total_cost
1661                        || (cand_ab.total_cost - cand_ba.total_cost).abs() < 1e-9
1662                            && lex_lt(&cand_ab.leaf_order_idx, &cand_ba.leaf_order_idx)
1663                    {
1664                        cand_ab
1665                    } else {
1666                        cand_ba
1667                    };
1668
1669                    let replace = match &best_pair {
1670                        None => true,
1671                        Some((_bi, _bj, best)) => {
1672                            if cand.total_cost < best.total_cost {
1673                                true
1674                            } else if (cand.total_cost - best.total_cost).abs() < 1e-9 {
1675                                lex_lt(&cand.leaf_order_idx, &best.leaf_order_idx)
1676                            } else {
1677                                false
1678                            }
1679                        }
1680                    };
1681
1682                    if replace {
1683                        best_pair = Some((i, j, cand));
1684                    }
1685                }
1686            }
1687
1688            let Some((i, j, joined)) = best_pair else {
1689                break;
1690            };
1691
1692            // Remove joined inputs from the plan list and replace with the join.
1693            let (a, b) = if i < j { (i, j) } else { (j, i) };
1694            plans.remove(b);
1695            plans.remove(a);
1696            plans.push(joined);
1697        }
1698
1699        let plan = plans
1700            .pop()
1701            .ok_or_else(|| XlogError::Compilation("Join planning failed".to_string()))?;
1702        Ok((plan.node, plan.leaf_order))
1703    }
1704
1705    fn order_positive_atoms_greedy<'a>(&self, atoms: &[&'a Atom]) -> Vec<&'a Atom> {
1706        let mut remaining: Vec<(usize, &Atom)> = atoms.iter().copied().enumerate().collect();
1707        let mut ordered: Vec<&Atom> = Vec::with_capacity(atoms.len());
1708        let mut bound_vars: HashSet<String> = HashSet::new();
1709
1710        while !remaining.is_empty() {
1711            let pick_idx = if ordered.is_empty() {
1712                remaining
1713                    .iter()
1714                    .enumerate()
1715                    .min_by(|(_, a), (_, b)| {
1716                        let (ai, aa) = **a;
1717                        let (bi, bb) = **b;
1718                        self.estimate_atom_rows(aa)
1719                            .partial_cmp(&self.estimate_atom_rows(bb))
1720                            .unwrap_or(std::cmp::Ordering::Equal)
1721                            .then(ai.cmp(&bi))
1722                    })
1723                    .map(|(idx, _)| idx)
1724                    .unwrap()
1725            } else {
1726                remaining
1727                    .iter()
1728                    .enumerate()
1729                    .min_by(|(_, a), (_, b)| {
1730                        let (ai, aa) = **a;
1731                        let (bi, bb) = **b;
1732
1733                        let a_vars = Self::atom_vars(aa);
1734                        let b_vars = Self::atom_vars(bb);
1735
1736                        let a_shared = a_vars.intersection(&bound_vars).count();
1737                        let b_shared = b_vars.intersection(&bound_vars).count();
1738
1739                        let a_score = if a_shared == 0 {
1740                            self.estimate_atom_rows(aa) * 1.0e12
1741                        } else {
1742                            self.estimate_atom_rows(aa) / a_shared as f64
1743                        };
1744                        let b_score = if b_shared == 0 {
1745                            self.estimate_atom_rows(bb) * 1.0e12
1746                        } else {
1747                            self.estimate_atom_rows(bb) / b_shared as f64
1748                        };
1749
1750                        a_score
1751                            .partial_cmp(&b_score)
1752                            .unwrap_or(std::cmp::Ordering::Equal)
1753                            .then(ai.cmp(&bi))
1754                    })
1755                    .map(|(idx, _)| idx)
1756                    .unwrap()
1757            };
1758
1759            let (_orig_idx, atom) = remaining.remove(pick_idx);
1760            ordered.push(atom);
1761            bound_vars.extend(Self::atom_vars(atom));
1762        }
1763
1764        ordered
1765    }
1766
1767    fn lower_body_parts(
1768        &mut self,
1769        positive_root: RirNode,
1770        negated_atoms: &[&Atom],
1771        comparisons: &[&Comparison],
1772        is_exprs: &[&IsExpr],
1773        var_env: &mut VariableEnv,
1774    ) -> Result<RirNode> {
1775        let mut result = positive_root;
1776
1777        // Apply comparisons as filters.
1778        for cmp in comparisons {
1779            result = self.apply_comparison(result, cmp, var_env)?;
1780        }
1781
1782        // Apply is-expressions (must be after atoms that bind the input variables).
1783        for is_expr in is_exprs {
1784            result = self.lower_is_expr(is_expr, result, var_env)?;
1785        }
1786
1787        // Handle negated atoms via Diff / semi-join.
1788        for neg_atom in negated_atoms {
1789            result = self.apply_negation(result, neg_atom, var_env)?;
1790        }
1791
1792        Ok(result)
1793    }
1794
1795    /// Build a left-deep join tree from positive atoms
1796    fn build_join_tree(&mut self, atoms: &[&Atom], var_env: &mut VariableEnv) -> Result<RirNode> {
1797        if atoms.is_empty() {
1798            return Err(XlogError::Compilation("Empty rule body".to_string()));
1799        }
1800
1801        // Start with the first atom as a scan
1802        let first_atom = atoms[0];
1803        let rel_id = self.get_or_create_rel_id(&first_atom.predicate);
1804        let mut result = RirNode::Scan { rel: rel_id };
1805        let mut result_vars = self.collect_atom_vars(first_atom);
1806        let mut result_width = first_atom.terms.len();
1807
1808        // Apply constant filters if any
1809        result = self.apply_constant_filters(result, first_atom, 0)?;
1810
1811        // Join with remaining atoms (left-deep)
1812        for atom in atoms.iter().skip(1) {
1813            let right_rel_id = self.get_or_create_rel_id(&atom.predicate);
1814            let right_scan = RirNode::Scan { rel: right_rel_id };
1815
1816            // Apply constant filters to the right side
1817            let right_filtered = self.apply_constant_filters(right_scan, atom, 0)?;
1818
1819            // Compute join keys based on shared variables
1820            let (left_keys, right_keys) = self.compute_join_keys(&result_vars, atom, result_width);
1821
1822            if left_keys.is_empty() {
1823                // Cartesian product (no shared variables)
1824                result = RirNode::Join {
1825                    left: Box::new(result),
1826                    right: Box::new(right_filtered),
1827                    left_keys: vec![],
1828                    right_keys: vec![],
1829                    join_type: JoinType::Inner,
1830                };
1831            } else {
1832                result = RirNode::Join {
1833                    left: Box::new(result),
1834                    right: Box::new(right_filtered),
1835                    left_keys,
1836                    right_keys,
1837                    join_type: JoinType::Inner,
1838                };
1839            }
1840
1841            // Update result vars for the next iteration
1842            for (i, term) in atom.terms.iter().enumerate() {
1843                if let Term::Variable(name) = term {
1844                    result_vars.push((name.clone(), result_width + i));
1845                }
1846            }
1847            result_width += atom.terms.len();
1848        }
1849
1850        // Update var_env with final positions
1851        var_env.total_cols = result_width;
1852
1853        Ok(result)
1854    }
1855
1856    /// Collect variable names and their positions within an atom
1857    fn collect_atom_vars(&self, atom: &Atom) -> Vec<(String, usize)> {
1858        atom.terms
1859            .iter()
1860            .enumerate()
1861            .filter_map(|(i, term)| {
1862                if let Term::Variable(name) = term {
1863                    Some((name.clone(), i))
1864                } else {
1865                    None
1866                }
1867            })
1868            .collect()
1869    }
1870
1871    /// Compute join keys between the current result and a new atom
1872    fn compute_join_keys(
1873        &self,
1874        left_vars: &[(String, usize)],
1875        right_atom: &Atom,
1876        _left_width: usize,
1877    ) -> (Vec<usize>, Vec<usize>) {
1878        let mut left_keys = Vec::new();
1879        let mut right_keys = Vec::new();
1880
1881        for (right_idx, term) in right_atom.terms.iter().enumerate() {
1882            if let Term::Variable(name) = term {
1883                // Find if this variable exists in the left side
1884                for (left_name, left_idx) in left_vars {
1885                    if left_name == name {
1886                        left_keys.push(*left_idx);
1887                        right_keys.push(right_idx);
1888                        break; // Only use first occurrence for join key
1889                    }
1890                }
1891            }
1892        }
1893
1894        (left_keys, right_keys)
1895    }
1896
1897    /// Apply constant filters for an atom
1898    fn apply_constant_filters(
1899        &self,
1900        input: RirNode,
1901        atom: &Atom,
1902        _base_col: usize,
1903    ) -> Result<RirNode> {
1904        let mut filters = Vec::new();
1905        let mut first_var_col: HashMap<&str, usize> = HashMap::new();
1906        let schema = self.schemas.get(&atom.predicate).ok_or_else(|| {
1907            XlogError::Compilation(format!("Missing schema for predicate {}", atom.predicate))
1908        })?;
1909
1910        for (i, term) in atom.terms.iter().enumerate() {
1911            if let Term::Variable(name) = term {
1912                if name != "_" {
1913                    if let Some(&first) = first_var_col.get(name.as_str()) {
1914                        filters.push(Expr::Compare {
1915                            left: Box::new(Expr::Column(first)),
1916                            op: CompareOp::Eq,
1917                            right: Box::new(Expr::Column(i)),
1918                        });
1919                    } else {
1920                        first_var_col.insert(name.as_str(), i);
1921                    }
1922                }
1923            }
1924
1925            let col_type = schema.column_type(i).ok_or_else(|| {
1926                XlogError::Compilation(format!(
1927                    "Missing column type for {} column {}",
1928                    atom.predicate, i
1929                ))
1930            })?;
1931            if let Some(const_val) = term_to_typed_const_value(term, col_type)? {
1932                filters.push(Expr::Compare {
1933                    left: Box::new(Expr::Column(i)),
1934                    op: CompareOp::Eq,
1935                    right: Box::new(Expr::Const(const_val)),
1936                });
1937            }
1938        }
1939
1940        if filters.is_empty() {
1941            Ok(input)
1942        } else {
1943            let predicate = if filters.len() == 1 {
1944                filters.pop().unwrap()
1945            } else {
1946                Expr::And(filters)
1947            };
1948
1949            Ok(RirNode::Filter {
1950                input: Box::new(input),
1951                predicate,
1952            })
1953        }
1954    }
1955
1956    /// Apply a comparison as a filter
1957    fn apply_comparison(
1958        &self,
1959        input: RirNode,
1960        cmp: &Comparison,
1961        var_env: &VariableEnv,
1962    ) -> Result<RirNode> {
1963        let (left_expr, right_expr) = match (&cmp.left, &cmp.right) {
1964            (Term::Variable(name), term) => {
1965                let col = var_env.get_column(name).ok_or_else(|| {
1966                    XlogError::Compilation(format!("Variable {} not found in environment", name))
1967                })?;
1968                let typ = var_env.get_type(name).ok_or_else(|| {
1969                    XlogError::Compilation(format!("Missing type for variable {}", name))
1970                })?;
1971                if let Some(const_val) = term_to_typed_const_value(term, typ)? {
1972                    (Expr::Column(col), Expr::Const(const_val))
1973                } else {
1974                    (
1975                        self.term_to_expr(&cmp.left, var_env)?,
1976                        self.term_to_expr(&cmp.right, var_env)?,
1977                    )
1978                }
1979            }
1980            (term, Term::Variable(name)) => {
1981                let col = var_env.get_column(name).ok_or_else(|| {
1982                    XlogError::Compilation(format!("Variable {} not found in environment", name))
1983                })?;
1984                let typ = var_env.get_type(name).ok_or_else(|| {
1985                    XlogError::Compilation(format!("Missing type for variable {}", name))
1986                })?;
1987                if let Some(const_val) = term_to_typed_const_value(term, typ)? {
1988                    (Expr::Const(const_val), Expr::Column(col))
1989                } else {
1990                    (
1991                        self.term_to_expr(&cmp.left, var_env)?,
1992                        self.term_to_expr(&cmp.right, var_env)?,
1993                    )
1994                }
1995            }
1996            _ => (
1997                self.term_to_expr(&cmp.left, var_env)?,
1998                self.term_to_expr(&cmp.right, var_env)?,
1999            ),
2000        };
2001
2002        let op = match cmp.op {
2003            CompOp::Eq => CompareOp::Eq,
2004            CompOp::Ne => CompareOp::Ne,
2005            CompOp::Lt => CompareOp::Lt,
2006            CompOp::Le => CompareOp::Le,
2007            CompOp::Gt => CompareOp::Gt,
2008            CompOp::Ge => CompareOp::Ge,
2009        };
2010
2011        Ok(RirNode::Filter {
2012            input: Box::new(input),
2013            predicate: Expr::Compare {
2014                left: Box::new(left_expr),
2015                op,
2016                right: Box::new(right_expr),
2017            },
2018        })
2019    }
2020
2021    /// Convert a term to an expression
2022    fn term_to_expr(&self, term: &Term, var_env: &VariableEnv) -> Result<Expr> {
2023        match term {
2024            Term::Variable(name) => {
2025                if let Some(col) = var_env.get_column(name) {
2026                    Ok(Expr::Column(col))
2027                } else {
2028                    Err(XlogError::Compilation(format!(
2029                        "Variable {} not found in environment",
2030                        name
2031                    )))
2032                }
2033            }
2034            Term::Anonymous => Err(XlogError::Compilation(
2035                "Anonymous wildcard '_' not allowed in comparisons".to_string(),
2036            )),
2037            Term::Integer(i) => Ok(Expr::Const(ConstValue::I64(*i))),
2038            Term::Float(f) => Ok(Expr::Const(ConstValue::F64(*f))),
2039            Term::String(s) => Ok(Expr::Const(ConstValue::Symbol(s.clone()))),
2040            Term::Symbol(id) => Ok(Expr::Const(ConstValue::Symbol(symbol::resolve(*id)))),
2041            Term::Aggregate(_) => Err(XlogError::Compilation(
2042                "Aggregates not allowed in comparisons".to_string(),
2043            )),
2044            Term::List(_) | Term::Cons { .. } | Term::Compound { .. } | Term::PredRef(_) => Err(
2045                term_not_lowerable_error("comparison", term_kind_for_lowering_error(term)),
2046            ),
2047        }
2048    }
2049
2050    /// Apply negation via set difference
2051    fn apply_negation(
2052        &mut self,
2053        input: RirNode,
2054        neg_atom: &Atom,
2055        var_env: &VariableEnv,
2056    ) -> Result<RirNode> {
2057        let rel_id = self.get_or_create_rel_id(&neg_atom.predicate);
2058        let neg_scan = RirNode::Scan { rel: rel_id };
2059
2060        // Apply constant filters to the negated atom
2061        let neg_filtered = self.apply_constant_filters(neg_scan, neg_atom, 0)?;
2062
2063        // Find which columns from the input correspond to variables in the negated atom
2064        let mut input_cols = Vec::new();
2065        let mut neg_cols = Vec::new();
2066
2067        for (neg_idx, term) in neg_atom.terms.iter().enumerate() {
2068            if let Term::Variable(name) = term {
2069                if let Some(col) = var_env.get_column(name) {
2070                    input_cols.push(col);
2071                    neg_cols.push(neg_idx);
2072                }
2073            }
2074        }
2075
2076        if input_cols.is_empty() {
2077            // A negated atom with no shared variables is a Boolean existence gate over
2078            // the entire positive input, not a tuple difference. Give both sides the
2079            // same synthetic key, anti-join on that key, then remove it. If the
2080            // negated atom has any matching row, every input row is rejected; if it is
2081            // empty, the anti-join returns the input unchanged. This also preserves
2082            // arbitrary input schemas, including the zero-arity unit used by
2083            // negation-only rules.
2084            let input_width = var_env.column_count();
2085            let join_key =
2086                || ProjectExpr::Computed(Expr::Const(ConstValue::U32(0)), ScalarType::U32);
2087
2088            let mut keyed_input_columns: Vec<ProjectExpr> =
2089                (0..input_width).map(ProjectExpr::Column).collect();
2090            keyed_input_columns.push(join_key());
2091            let keyed_input = RirNode::Project {
2092                input: Box::new(input),
2093                columns: keyed_input_columns,
2094            };
2095            let keyed_negation = RirNode::Project {
2096                input: Box::new(neg_filtered),
2097                columns: vec![join_key()],
2098            };
2099            let gated_input = RirNode::Join {
2100                left: Box::new(keyed_input),
2101                right: Box::new(keyed_negation),
2102                left_keys: vec![input_width],
2103                right_keys: vec![0],
2104                join_type: JoinType::Anti,
2105            };
2106
2107            Ok(RirNode::Project {
2108                input: Box::new(gated_input),
2109                columns: (0..input_width).map(ProjectExpr::Column).collect(),
2110            })
2111        } else {
2112            // Project the negated atom to only the shared variable columns
2113            let neg_projected = if neg_cols.len() < neg_atom.terms.len() {
2114                let neg_proj_exprs: Vec<ProjectExpr> =
2115                    neg_cols.iter().map(|&c| ProjectExpr::Column(c)).collect();
2116                RirNode::Project {
2117                    input: Box::new(neg_filtered),
2118                    columns: neg_proj_exprs,
2119                }
2120            } else {
2121                neg_filtered
2122            };
2123
2124            // Project input to matching columns for the diff, then diff
2125            // Actually, for proper anti-join semantics we need to be careful.
2126            // The Diff operation subtracts matching tuples.
2127            // We need to project input to the shared columns, diff, then rejoin.
2128
2129            // Simpler approach: project input to shared columns, diff with negated,
2130            // then rejoin with original
2131            let input_proj_exprs: Vec<ProjectExpr> =
2132                input_cols.iter().map(|&c| ProjectExpr::Column(c)).collect();
2133            let input_projected = RirNode::Project {
2134                input: Box::new(input.clone()),
2135                columns: input_proj_exprs,
2136            };
2137
2138            // The Diff gives us the keys that should be kept
2139            let kept_keys = RirNode::Diff {
2140                left: Box::new(input_projected),
2141                right: Box::new(neg_projected),
2142            };
2143
2144            // Join back with original input to get full tuples
2145            // This effectively filters the input to only rows where the key
2146            // is not in the negated relation
2147            Ok(RirNode::Join {
2148                left: Box::new(input),
2149                right: Box::new(kept_keys),
2150                left_keys: input_cols.clone(),
2151                right_keys: (0..input_cols.len()).collect(),
2152                join_type: JoinType::Semi,
2153            })
2154        }
2155    }
2156
2157    fn is_identity_projection(proj: &[ProjectExpr], input_cols: usize) -> bool {
2158        if proj.len() != input_cols {
2159            return false;
2160        }
2161        proj.iter()
2162            .enumerate()
2163            .all(|(i, e)| matches!(e, ProjectExpr::Column(c) if *c == i))
2164    }
2165
2166    /// Build a projection list that matches the rule head term order.
2167    ///
2168    /// For non-aggregate rules this supports:
2169    /// - Variables (column passthrough)
2170    /// - Constants (computed constant columns)
2171    fn compute_head_projection(
2172        &self,
2173        head: &Atom,
2174        var_env: &VariableEnv,
2175    ) -> Result<Vec<ProjectExpr>> {
2176        let mut cols = Vec::with_capacity(head.terms.len());
2177
2178        for (index, term) in head.terms.iter().enumerate() {
2179            match term {
2180                Term::Variable(name) => {
2181                    let col = var_env
2182                        .get_column(name)
2183                        .ok_or_else(|| XlogError::UnsafeVariable(name.clone()))?;
2184                    cols.push(ProjectExpr::Column(col));
2185                }
2186                Term::Anonymous => {
2187                    return Err(XlogError::Compilation(
2188                        "Anonymous wildcard '_' not allowed in rule head".to_string(),
2189                    ));
2190                }
2191                Term::Aggregate(_) => {
2192                    return Err(XlogError::Compilation(
2193                        "Aggregate term in non-aggregate rule head".to_string(),
2194                    ));
2195                }
2196                Term::Integer(_) | Term::Float(_) | Term::String(_) | Term::Symbol(_) => {
2197                    let typ = self
2198                        .schemas
2199                        .get(&head.predicate)
2200                        .and_then(|schema| schema.column_type(index))
2201                        .ok_or_else(|| {
2202                            XlogError::Compilation(format!(
2203                                "Missing schema type for '{}' column {}",
2204                                head.predicate, index
2205                            ))
2206                        })?;
2207                    let value = term_to_typed_const_value(term, typ)?.ok_or_else(|| {
2208                        XlogError::Compilation("Expected constant term".to_string())
2209                    })?;
2210                    cols.push(ProjectExpr::Computed(Expr::Const(value), typ));
2211                }
2212                Term::List(_) | Term::Cons { .. } | Term::Compound { .. } | Term::PredRef(_) => {
2213                    return Err(term_not_lowerable_error(
2214                        "rule head projection",
2215                        term_kind_for_lowering_error(term),
2216                    ));
2217                }
2218            }
2219        }
2220
2221        Ok(cols)
2222    }
2223
2224    /// Lower an aggregate rule head into `GroupBy` + final projection.
2225    fn lower_aggregate_rule(
2226        &mut self,
2227        head: &Atom,
2228        body: RirNode,
2229        var_env: &VariableEnv,
2230    ) -> Result<RirNode> {
2231        // Collect unique group keys in head order.
2232        let mut key_vars: Vec<String> = Vec::new();
2233        let mut key_var_to_pos: HashMap<String, usize> = HashMap::new();
2234        let mut key_src_cols: Vec<usize> = Vec::new();
2235
2236        // Collect unique aggregate specs (op, var) in head order.
2237        let mut agg_specs: Vec<(AggOp, String)> = Vec::new();
2238        let mut agg_to_pos: HashMap<(AggOp, String), usize> = HashMap::new();
2239        let mut value_vars: Vec<String> = Vec::new();
2240        let mut value_var_to_pos: HashMap<String, usize> = HashMap::new();
2241        let mut value_src_cols: Vec<usize> = Vec::new();
2242
2243        for term in &head.terms {
2244            match term {
2245                Term::Variable(name) => {
2246                    if !key_var_to_pos.contains_key(name) {
2247                        let col = var_env
2248                            .get_column(name)
2249                            .ok_or_else(|| XlogError::UnsafeVariable(name.clone()))?;
2250                        let pos = key_vars.len();
2251                        key_vars.push(name.clone());
2252                        key_var_to_pos.insert(name.clone(), pos);
2253                        key_src_cols.push(col);
2254                    }
2255                }
2256                Term::Aggregate(agg) => {
2257                    let key = (agg.op, agg.variable.clone());
2258                    if let std::collections::hash_map::Entry::Vacant(entry) = agg_to_pos.entry(key)
2259                    {
2260                        // Ensure the aggregated variable is bound.
2261                        let col = var_env
2262                            .get_column(&agg.variable)
2263                            .ok_or_else(|| XlogError::UnsafeVariable(agg.variable.clone()))?;
2264
2265                        // Ensure the value variable exists in the groupby input.
2266                        let value_pos = *value_var_to_pos
2267                            .entry(agg.variable.clone())
2268                            .or_insert_with(|| {
2269                                let p = value_vars.len();
2270                                value_vars.push(agg.variable.clone());
2271                                value_src_cols.push(col);
2272                                p
2273                            });
2274
2275                        let agg_pos = agg_specs.len();
2276                        agg_specs.push((agg.op, agg.variable.clone()));
2277                        entry.insert(agg_pos);
2278
2279                        // Keep clippy happy about unused value_pos in insert_with closure.
2280                        let _ = value_pos;
2281                    }
2282                }
2283                Term::Anonymous => {
2284                    return Err(XlogError::Compilation(
2285                        "Anonymous wildcard '_' not allowed in rule head".to_string(),
2286                    ));
2287                }
2288                Term::Integer(_) | Term::Float(_) | Term::String(_) | Term::Symbol(_) => {
2289                    // Constants are allowed in the head; they are projected after aggregation.
2290                }
2291                Term::List(_) | Term::Cons { .. } | Term::Compound { .. } | Term::PredRef(_) => {
2292                    return Err(term_not_lowerable_error(
2293                        "aggregate rule head",
2294                        term_kind_for_lowering_error(term),
2295                    ));
2296                }
2297            }
2298        }
2299
2300        if agg_specs.is_empty() {
2301            return Err(XlogError::Compilation(
2302                "Rule marked as aggregate but no aggregate terms found".to_string(),
2303            ));
2304        }
2305
2306        // Build groupby input: [keys..., values...]. For global aggregates (no keys),
2307        // synthesize a constant key column so GroupBy is well-defined.
2308        let mut group_input_cols: Vec<ProjectExpr> = Vec::new();
2309        let mut key_cols: Vec<usize> = Vec::new();
2310
2311        if key_src_cols.is_empty() {
2312            group_input_cols.push(ProjectExpr::Computed(
2313                Expr::Const(ConstValue::U32(0)),
2314                ScalarType::U32,
2315            ));
2316            key_cols.push(0);
2317        } else {
2318            for (i, &col) in key_src_cols.iter().enumerate() {
2319                group_input_cols.push(ProjectExpr::Column(col));
2320                key_cols.push(i);
2321            }
2322        }
2323
2324        let value_offset = group_input_cols.len();
2325        for &col in &value_src_cols {
2326            group_input_cols.push(ProjectExpr::Column(col));
2327        }
2328
2329        let group_input = RirNode::Project {
2330            input: Box::new(body),
2331            columns: group_input_cols,
2332        };
2333
2334        // Build multi-aggregation spec list (value_col indices are in the group_input schema).
2335        let mut aggs: Vec<(usize, CoreAggOp)> = Vec::with_capacity(agg_specs.len());
2336        for (op, var) in &agg_specs {
2337            let value_pos = *value_var_to_pos
2338                .get(var)
2339                .ok_or_else(|| XlogError::UnsafeVariable(var.clone()))?;
2340            let value_col = value_offset + value_pos;
2341            aggs.push((value_col, convert_agg_op(op)));
2342        }
2343
2344        let groupby = RirNode::GroupBy {
2345            input: Box::new(group_input),
2346            key_cols,
2347            aggs,
2348        };
2349
2350        // Final projection to match head term order:
2351        // - variables map to group key columns
2352        // - aggregates map to groupby output agg columns (after keys)
2353        // - constants are computed columns
2354        let key_count = if key_src_cols.is_empty() {
2355            1
2356        } else {
2357            key_vars.len()
2358        };
2359
2360        let mut final_proj: Vec<ProjectExpr> = Vec::with_capacity(head.terms.len());
2361        for (index, term) in head.terms.iter().enumerate() {
2362            match term {
2363                Term::Variable(name) => {
2364                    let idx = if key_src_cols.is_empty() {
2365                        // Global aggregates have no key vars in the output; binding a variable in the head
2366                        // is a semantic error because it would be unbound.
2367                        return Err(XlogError::UnsafeVariable(name.clone()));
2368                    } else {
2369                        *key_var_to_pos
2370                            .get(name)
2371                            .ok_or_else(|| XlogError::UnsafeVariable(name.clone()))?
2372                    };
2373                    final_proj.push(ProjectExpr::Column(idx));
2374                }
2375                Term::Aggregate(agg) => {
2376                    let pos = *agg_to_pos
2377                        .get(&(agg.op, agg.variable.clone()))
2378                        .ok_or_else(|| XlogError::UnsafeVariable(agg.variable.clone()))?;
2379                    final_proj.push(ProjectExpr::Column(key_count + pos));
2380                }
2381                Term::Anonymous => {
2382                    return Err(XlogError::Compilation(
2383                        "Anonymous wildcard '_' not allowed in rule head".to_string(),
2384                    ));
2385                }
2386                Term::Integer(_) | Term::Float(_) | Term::String(_) | Term::Symbol(_) => {
2387                    let typ = self
2388                        .schemas
2389                        .get(&head.predicate)
2390                        .and_then(|schema| schema.column_type(index))
2391                        .ok_or_else(|| {
2392                            XlogError::Compilation(format!(
2393                                "Missing schema type for '{}' column {}",
2394                                head.predicate, index
2395                            ))
2396                        })?;
2397                    let value = term_to_typed_const_value(term, typ)?.ok_or_else(|| {
2398                        XlogError::Compilation("Expected constant term".to_string())
2399                    })?;
2400                    final_proj.push(ProjectExpr::Computed(Expr::Const(value), typ));
2401                }
2402                Term::List(_) | Term::Cons { .. } | Term::Compound { .. } | Term::PredRef(_) => {
2403                    return Err(term_not_lowerable_error(
2404                        "aggregate rule projection",
2405                        term_kind_for_lowering_error(term),
2406                    ));
2407                }
2408            }
2409        }
2410
2411        if final_proj.is_empty() {
2412            return Err(XlogError::Compilation(
2413                "Aggregate rule produced empty head projection".to_string(),
2414            ));
2415        }
2416
2417        Ok(RirNode::Project {
2418            input: Box::new(groupby),
2419            columns: final_proj,
2420        })
2421    }
2422
2423    /// Infer an arithmetic result when every type needed for that result is
2424    /// currently known. An explicit cast fixes its result type independently of
2425    /// the operand, which lets schema inference propagate the target type before
2426    /// all upstream variable schemas have converged.
2427    fn infer_arith_type_from_known_variables<F>(
2428        expr: &ArithExpr,
2429        variable_type: &F,
2430        user_functions: UserFunctionTypeEvidence,
2431    ) -> Result<Option<ScalarType>>
2432    where
2433        F: Fn(&str) -> Option<ScalarType>,
2434    {
2435        let mut tasks = vec![ArithmeticTypeTask::Visit(expr)];
2436        let mut values: Vec<Option<ScalarType>> = Vec::new();
2437        while let Some(task) = tasks.pop() {
2438            match task {
2439                ArithmeticTypeTask::Visit(expression) => match expression {
2440                    ArithExpr::Variable(name) => values.push(variable_type(name)),
2441                    ArithExpr::Integer(_) => values.push(Some(ScalarType::I64)),
2442                    ArithExpr::Float(_) => values.push(Some(ScalarType::F64)),
2443                    ArithExpr::Add(left, right)
2444                    | ArithExpr::Sub(left, right)
2445                    | ArithExpr::Mul(left, right)
2446                    | ArithExpr::Div(left, right) => {
2447                        tasks.push(ArithmeticTypeTask::FinishBinary(
2448                            ArithmeticTypeOperation::Standard,
2449                        ));
2450                        tasks.push(ArithmeticTypeTask::Visit(right));
2451                        tasks.push(ArithmeticTypeTask::Visit(left));
2452                    }
2453                    ArithExpr::Mod(left, right) => {
2454                        tasks.push(ArithmeticTypeTask::FinishBinary(
2455                            ArithmeticTypeOperation::Modulo,
2456                        ));
2457                        tasks.push(ArithmeticTypeTask::Visit(right));
2458                        tasks.push(ArithmeticTypeTask::Visit(left));
2459                    }
2460                    ArithExpr::Min(left, right) | ArithExpr::Max(left, right) => {
2461                        tasks.push(ArithmeticTypeTask::FinishBinary(
2462                            ArithmeticTypeOperation::MinMax,
2463                        ));
2464                        tasks.push(ArithmeticTypeTask::Visit(right));
2465                        tasks.push(ArithmeticTypeTask::Visit(left));
2466                    }
2467                    ArithExpr::Pow(left, right) => {
2468                        tasks.push(ArithmeticTypeTask::FinishBinary(
2469                            ArithmeticTypeOperation::Power,
2470                        ));
2471                        tasks.push(ArithmeticTypeTask::Visit(right));
2472                        tasks.push(ArithmeticTypeTask::Visit(left));
2473                    }
2474                    ArithExpr::Abs(inner) => {
2475                        tasks.push(ArithmeticTypeTask::FinishAbs);
2476                        tasks.push(ArithmeticTypeTask::Visit(inner));
2477                    }
2478                    ArithExpr::Cast(inner, target) => {
2479                        tasks.push(ArithmeticTypeTask::FinishCast(*target));
2480                        tasks.push(ArithmeticTypeTask::Visit(inner));
2481                    }
2482                    ArithExpr::FuncCall { name, args } => match user_functions {
2483                        UserFunctionTypeEvidence::RequireExpansion => {
2484                            return Err(XlogError::Compilation(format!(
2485                                "User-defined function '{}' must be inlined before lowering",
2486                                name
2487                            )));
2488                        }
2489                        UserFunctionTypeEvidence::Defer => {
2490                            tasks.push(ArithmeticTypeTask::FinishFunctionArguments(args.len()));
2491                            tasks.extend(args.iter().rev().map(ArithmeticTypeTask::Visit));
2492                        }
2493                    },
2494                    ArithExpr::Conditional {
2495                        then_expr,
2496                        else_expr,
2497                        ..
2498                    } => {
2499                        tasks.push(ArithmeticTypeTask::FinishConditional);
2500                        tasks.push(ArithmeticTypeTask::Visit(else_expr));
2501                        tasks.push(ArithmeticTypeTask::Visit(then_expr));
2502                    }
2503                },
2504                ArithmeticTypeTask::FinishBinary(operation) => {
2505                    let right = values.pop().expect("right arithmetic type is inferred");
2506                    let left = values.pop().expect("left arithmetic type is inferred");
2507                    values.push(Self::finish_binary_arithmetic_type(operation, left, right)?);
2508                }
2509                ArithmeticTypeTask::FinishAbs => {
2510                    let inferred = values.pop().expect("absolute-value type is inferred");
2511                    if let Some(typ) = inferred {
2512                        if !Self::is_numeric_type(&typ) {
2513                            return Err(XlogError::Compilation(format!(
2514                                "abs requires numeric type, got {:?}",
2515                                typ
2516                            )));
2517                        }
2518                    }
2519                    values.push(inferred);
2520                }
2521                ArithmeticTypeTask::FinishCast(target) => {
2522                    values.pop().expect("cast operand type is inferred");
2523                    values.push(Some(target));
2524                }
2525                ArithmeticTypeTask::FinishFunctionArguments(argument_count) => {
2526                    let start = values
2527                        .len()
2528                        .checked_sub(argument_count)
2529                        .expect("function argument types are inferred");
2530                    values.truncate(start);
2531                    values.push(None);
2532                }
2533                ArithmeticTypeTask::FinishConditional => {
2534                    let else_type = values.pop().expect("else branch type is inferred");
2535                    let then_type = values.pop().expect("then branch type is inferred");
2536                    let inferred = match (then_type, else_type) {
2537                        (Some(then_type), Some(else_type)) => {
2538                            if then_type != else_type {
2539                                return Err(XlogError::Compilation(format!(
2540                                    "Conditional branches have different types: {:?} vs {:?}",
2541                                    then_type, else_type
2542                                )));
2543                            }
2544                            Some(then_type)
2545                        }
2546                        _ => None,
2547                    };
2548                    values.push(inferred);
2549                }
2550            }
2551        }
2552        let inferred = values
2553            .pop()
2554            .expect("arithmetic expression produces one inferred type");
2555        debug_assert!(values.is_empty());
2556        Ok(inferred)
2557    }
2558
2559    fn finish_binary_arithmetic_type(
2560        operation: ArithmeticTypeOperation,
2561        left: Option<ScalarType>,
2562        right: Option<ScalarType>,
2563    ) -> Result<Option<ScalarType>> {
2564        let matching_numeric_type = |context: &str| -> Result<Option<ScalarType>> {
2565            if let (Some(left), Some(right)) = (left, right) {
2566                if left != right {
2567                    return Err(XlogError::Compilation(format!(
2568                        "Type mismatch in {context}: {:?} vs {:?}",
2569                        left, right
2570                    )));
2571                }
2572            }
2573            for typ in [left, right].into_iter().flatten() {
2574                if !Self::is_numeric_type(&typ) {
2575                    return Err(XlogError::Compilation(format!(
2576                        "{context} requires numeric type, got {:?}",
2577                        typ
2578                    )));
2579                }
2580            }
2581            Ok(match (left, right) {
2582                (Some(left), Some(_)) => Some(left),
2583                _ => None,
2584            })
2585        };
2586
2587        match operation {
2588            ArithmeticTypeOperation::Standard => {
2589                if let (Some(left), Some(right)) = (left, right) {
2590                    if left != right {
2591                        return Err(XlogError::Compilation(format!(
2592                            "Type mismatch in arithmetic: {:?} vs {:?}. Use cast() for conversion.",
2593                            left, right
2594                        )));
2595                    }
2596                }
2597                for typ in [left, right].into_iter().flatten() {
2598                    if !Self::is_numeric_type(&typ) {
2599                        return Err(XlogError::Compilation(format!(
2600                            "Arithmetic requires numeric type, got {:?}",
2601                            typ
2602                        )));
2603                    }
2604                }
2605                Ok(match (left, right) {
2606                    (Some(left), Some(_)) => Some(left),
2607                    _ => None,
2608                })
2609            }
2610            ArithmeticTypeOperation::Modulo => {
2611                if let (Some(left), Some(right)) = (left, right) {
2612                    if left != right {
2613                        return Err(XlogError::Compilation(format!(
2614                            "Type mismatch in mod: {:?} vs {:?}",
2615                            left, right
2616                        )));
2617                    }
2618                }
2619                for typ in [left, right].into_iter().flatten() {
2620                    if matches!(typ, ScalarType::F32 | ScalarType::F64) {
2621                        return Err(XlogError::Compilation(
2622                            "Modulo (%) not supported for floating point".into(),
2623                        ));
2624                    }
2625                    if !Self::is_numeric_type(&typ) {
2626                        return Err(XlogError::Compilation(format!(
2627                            "Modulo (%) requires integer operands, got {:?}",
2628                            typ
2629                        )));
2630                    }
2631                }
2632                Ok(match (left, right) {
2633                    (Some(left), Some(_)) => Some(left),
2634                    _ => None,
2635                })
2636            }
2637            ArithmeticTypeOperation::MinMax => matching_numeric_type("min/max"),
2638            ArithmeticTypeOperation::Power => {
2639                if let Some(invalid) = [left, right]
2640                    .into_iter()
2641                    .flatten()
2642                    .find(|typ| !Self::is_numeric_type(typ))
2643                {
2644                    let detail = match (left, right) {
2645                        (Some(left), Some(right)) => format!("{:?} and {:?}", left, right),
2646                        _ => format!("{:?}", invalid),
2647                    };
2648                    return Err(XlogError::Compilation(format!(
2649                        "pow requires numeric operands, got {detail}"
2650                    )));
2651                }
2652                Ok((left.is_some() && right.is_some()).then_some(ScalarType::F64))
2653            }
2654        }
2655    }
2656    /// Infer the result type of an arithmetic expression (strict same-type).
2657    pub(crate) fn infer_arith_type(
2658        &self,
2659        expr: &ArithExpr,
2660        var_env: &VariableEnv,
2661    ) -> Result<ScalarType> {
2662        if let Some(typ) = Self::infer_arith_type_from_known_variables(
2663            expr,
2664            &|variable| var_env.get_type(variable),
2665            UserFunctionTypeEvidence::RequireExpansion,
2666        )? {
2667            return Ok(typ);
2668        }
2669
2670        let variable = expr
2671            .variables()
2672            .into_iter()
2673            .find(|variable| var_env.get_type(variable).is_none())
2674            .unwrap_or("<unknown>");
2675        Err(XlogError::Compilation(format!(
2676            "Unknown variable {} in arithmetic",
2677            variable
2678        )))
2679    }
2680
2681    fn is_numeric_type(t: &ScalarType) -> bool {
2682        matches!(
2683            t,
2684            ScalarType::I32
2685                | ScalarType::I64
2686                | ScalarType::U32
2687                | ScalarType::U64
2688                | ScalarType::F32
2689                | ScalarType::F64
2690        )
2691    }
2692
2693    /// Convert ArithExpr to IR Expr
2694    fn arith_to_expr(&self, arith: &ArithExpr, var_env: &VariableEnv) -> Result<Expr> {
2695        let mut tasks = vec![ArithmeticExpressionTask::Visit(arith)];
2696        let mut values = Vec::new();
2697        while let Some(task) = tasks.pop() {
2698            match task {
2699                ArithmeticExpressionTask::Visit(expression) => match expression {
2700                    ArithExpr::Variable(name) => {
2701                        let column = var_env.get_column(name).ok_or_else(|| {
2702                            XlogError::Compilation(format!(
2703                                "Variable {} not bound before use in arithmetic",
2704                                name
2705                            ))
2706                        })?;
2707                        values.push(Expr::Column(column));
2708                    }
2709                    ArithExpr::Integer(value) => {
2710                        values.push(Expr::Const(ConstValue::I64(*value)));
2711                    }
2712                    ArithExpr::Float(value) => {
2713                        values.push(Expr::Const(ConstValue::F64(*value)));
2714                    }
2715                    ArithExpr::Add(left, right) => Self::schedule_arithmetic_expression(
2716                        &mut tasks,
2717                        left,
2718                        right,
2719                        ArithmeticExpressionOperation::Add,
2720                    ),
2721                    ArithExpr::Sub(left, right) => Self::schedule_arithmetic_expression(
2722                        &mut tasks,
2723                        left,
2724                        right,
2725                        ArithmeticExpressionOperation::Sub,
2726                    ),
2727                    ArithExpr::Mul(left, right) => Self::schedule_arithmetic_expression(
2728                        &mut tasks,
2729                        left,
2730                        right,
2731                        ArithmeticExpressionOperation::Mul,
2732                    ),
2733                    ArithExpr::Div(left, right) => Self::schedule_arithmetic_expression(
2734                        &mut tasks,
2735                        left,
2736                        right,
2737                        ArithmeticExpressionOperation::Div,
2738                    ),
2739                    ArithExpr::Mod(left, right) => Self::schedule_arithmetic_expression(
2740                        &mut tasks,
2741                        left,
2742                        right,
2743                        ArithmeticExpressionOperation::Mod,
2744                    ),
2745                    ArithExpr::Min(left, right) => Self::schedule_arithmetic_expression(
2746                        &mut tasks,
2747                        left,
2748                        right,
2749                        ArithmeticExpressionOperation::Min,
2750                    ),
2751                    ArithExpr::Max(left, right) => Self::schedule_arithmetic_expression(
2752                        &mut tasks,
2753                        left,
2754                        right,
2755                        ArithmeticExpressionOperation::Max,
2756                    ),
2757                    ArithExpr::Pow(left, right) => Self::schedule_arithmetic_expression(
2758                        &mut tasks,
2759                        left,
2760                        right,
2761                        ArithmeticExpressionOperation::Pow,
2762                    ),
2763                    ArithExpr::Abs(inner) => {
2764                        tasks.push(ArithmeticExpressionTask::FinishAbs);
2765                        tasks.push(ArithmeticExpressionTask::Visit(inner));
2766                    }
2767                    ArithExpr::Cast(inner, target) => {
2768                        tasks.push(ArithmeticExpressionTask::FinishCast(*target));
2769                        tasks.push(ArithmeticExpressionTask::Visit(inner));
2770                    }
2771                    ArithExpr::FuncCall { name, .. } => {
2772                        return Err(XlogError::Compilation(format!(
2773                            "User-defined function '{}' must be inlined before lowering",
2774                            name
2775                        )));
2776                    }
2777                    ArithExpr::Conditional {
2778                        cond_left,
2779                        cond_op,
2780                        cond_right,
2781                        then_expr,
2782                        else_expr,
2783                    } => {
2784                        tasks.push(ArithmeticExpressionTask::FinishConditional(*cond_op));
2785                        tasks.push(ArithmeticExpressionTask::Visit(else_expr));
2786                        tasks.push(ArithmeticExpressionTask::Visit(then_expr));
2787                        tasks.push(ArithmeticExpressionTask::Visit(cond_right));
2788                        tasks.push(ArithmeticExpressionTask::Visit(cond_left));
2789                    }
2790                },
2791                ArithmeticExpressionTask::FinishBinary(operation) => {
2792                    let right = values
2793                        .pop()
2794                        .expect("right arithmetic expression is lowered");
2795                    let left = values.pop().expect("left arithmetic expression is lowered");
2796                    values.push(match operation {
2797                        ArithmeticExpressionOperation::Add => {
2798                            Expr::Add(Box::new(left), Box::new(right))
2799                        }
2800                        ArithmeticExpressionOperation::Sub => {
2801                            Expr::Sub(Box::new(left), Box::new(right))
2802                        }
2803                        ArithmeticExpressionOperation::Mul => {
2804                            Expr::Mul(Box::new(left), Box::new(right))
2805                        }
2806                        ArithmeticExpressionOperation::Div => {
2807                            Expr::Div(Box::new(left), Box::new(right))
2808                        }
2809                        ArithmeticExpressionOperation::Mod => {
2810                            Expr::Mod(Box::new(left), Box::new(right))
2811                        }
2812                        ArithmeticExpressionOperation::Min => {
2813                            Expr::Min(Box::new(left), Box::new(right))
2814                        }
2815                        ArithmeticExpressionOperation::Max => {
2816                            Expr::Max(Box::new(left), Box::new(right))
2817                        }
2818                        ArithmeticExpressionOperation::Pow => {
2819                            Expr::Pow(Box::new(left), Box::new(right))
2820                        }
2821                    });
2822                }
2823                ArithmeticExpressionTask::FinishAbs => {
2824                    let inner = values.pop().expect("absolute-value expression is lowered");
2825                    values.push(Expr::Abs(Box::new(inner)));
2826                }
2827                ArithmeticExpressionTask::FinishCast(target) => {
2828                    let inner = values.pop().expect("cast expression is lowered");
2829                    values.push(Expr::Cast(Box::new(inner), target));
2830                }
2831                ArithmeticExpressionTask::FinishConditional(op) => {
2832                    let else_expr = values.pop().expect("else expression is lowered");
2833                    let then_expr = values.pop().expect("then expression is lowered");
2834                    let right = values.pop().expect("condition right side is lowered");
2835                    let left = values.pop().expect("condition left side is lowered");
2836                    let op = match op {
2837                        CompOp::Eq => CompareOp::Eq,
2838                        CompOp::Ne => CompareOp::Ne,
2839                        CompOp::Lt => CompareOp::Lt,
2840                        CompOp::Le => CompareOp::Le,
2841                        CompOp::Gt => CompareOp::Gt,
2842                        CompOp::Ge => CompareOp::Ge,
2843                    };
2844                    values.push(Expr::Conditional {
2845                        condition: Box::new(Expr::Compare {
2846                            left: Box::new(left),
2847                            op,
2848                            right: Box::new(right),
2849                        }),
2850                        then_expr: Box::new(then_expr),
2851                        else_expr: Box::new(else_expr),
2852                    });
2853                }
2854            }
2855        }
2856        let expression = values
2857            .pop()
2858            .expect("arithmetic expression produces one lowered value");
2859        debug_assert!(values.is_empty());
2860        Ok(expression)
2861    }
2862
2863    fn schedule_arithmetic_expression<'a>(
2864        tasks: &mut Vec<ArithmeticExpressionTask<'a>>,
2865        left: &'a ArithExpr,
2866        right: &'a ArithExpr,
2867        operation: ArithmeticExpressionOperation,
2868    ) {
2869        tasks.push(ArithmeticExpressionTask::FinishBinary(operation));
2870        tasks.push(ArithmeticExpressionTask::Visit(right));
2871        tasks.push(ArithmeticExpressionTask::Visit(left));
2872    }
2873    /// Lower an is-expression to a Project node with computed column
2874    fn lower_is_expr(
2875        &mut self,
2876        is_expr: &IsExpr,
2877        input: RirNode,
2878        var_env: &mut VariableEnv,
2879    ) -> Result<RirNode> {
2880        // 1. Verify target is NOT already bound
2881        if var_env.contains(&is_expr.target) {
2882            return Err(XlogError::Compilation(format!(
2883                "Variable {} already bound; 'is' requires fresh variable",
2884                is_expr.target
2885            )));
2886        }
2887
2888        // 2. Verify all variables in expression are bound
2889        for var in is_expr.expr.variables() {
2890            if !var_env.contains(var) {
2891                return Err(XlogError::Compilation(format!(
2892                    "Variable {} used in arithmetic but not bound",
2893                    var
2894                )));
2895            }
2896        }
2897
2898        // 3. Infer result type
2899        let result_type = self.infer_arith_type(&is_expr.expr, var_env)?;
2900
2901        // 4. Convert expression to IR
2902        let ir_expr = self.arith_to_expr(&is_expr.expr, var_env)?;
2903
2904        // 5. Build projection: pass through all existing columns + add computed column
2905        let num_cols = var_env.column_count();
2906        let mut proj_exprs: Vec<ProjectExpr> = (0..num_cols).map(ProjectExpr::Column).collect();
2907        proj_exprs.push(ProjectExpr::Computed(ir_expr, result_type));
2908
2909        // 6. Bind the new variable
2910        var_env.bind(&is_expr.target, num_cols, result_type);
2911
2912        Ok(RirNode::Project {
2913            input: Box::new(input),
2914            columns: proj_exprs,
2915        })
2916    }
2917}
2918
2919/// Track variable occurrences and column positions
2920pub(crate) struct VariableEnv {
2921    /// Maps variable name to list of (predicate, position in atom, global column)
2922    occurrences: HashMap<String, Vec<(String, usize, usize)>>,
2923    /// Total columns in current result
2924    total_cols: usize,
2925    /// Maps variable name to its type (for type inference)
2926    types: HashMap<String, ScalarType>,
2927}
2928
2929impl VariableEnv {
2930    fn new() -> Self {
2931        Self {
2932            occurrences: HashMap::new(),
2933            total_cols: 0,
2934            types: HashMap::new(),
2935        }
2936    }
2937
2938    fn add_occurrence(&mut self, var: &str, pred: String, atom_pos: usize, global_col: usize) {
2939        self.occurrences
2940            .entry(var.to_string())
2941            .or_default()
2942            .push((pred, atom_pos, global_col));
2943    }
2944
2945    fn get_column(&self, var: &str) -> Option<usize> {
2946        self.occurrences
2947            .get(var)
2948            .and_then(|occs| occs.first())
2949            .map(|(_, _, col)| *col)
2950    }
2951
2952    /// Bind a variable to a column with a specific type (for type inference)
2953    fn bind(&mut self, name: &str, column: usize, typ: ScalarType) {
2954        self.types.insert(name.to_string(), typ);
2955        // Also add occurrence for column lookup
2956        self.occurrences
2957            .entry(name.to_string())
2958            .or_default()
2959            .push(("".to_string(), 0, column));
2960        // Update total_cols to account for the new computed column
2961        // This is critical for chained is-expressions where each adds a column
2962        if column >= self.total_cols {
2963            self.total_cols = column + 1;
2964        }
2965    }
2966
2967    /// Get the type of a bound variable
2968    fn get_type(&self, name: &str) -> Option<ScalarType> {
2969        self.types.get(name).copied()
2970    }
2971
2972    /// Check if a variable is bound
2973    fn contains(&self, name: &str) -> bool {
2974        self.occurrences.contains_key(name)
2975    }
2976
2977    /// Get the current column count (for adding new computed columns)
2978    fn column_count(&self) -> usize {
2979        self.total_cols
2980    }
2981}
2982
2983fn sort_labels_from_terms(terms: &[Term]) -> Vec<String> {
2984    terms
2985        .iter()
2986        .enumerate()
2987        .map(|(idx, term)| match term {
2988            Term::Variable(name) if !name.trim().is_empty() => name.clone(),
2989            Term::Aggregate(agg) => format!("{:?}_{}", agg.op, agg.variable),
2990            Term::List(_) => format!("list{}", idx),
2991            Term::Cons { .. } => format!("cons{}", idx),
2992            Term::Compound { functor, .. } => functor.clone(),
2993            Term::PredRef(name) => name.clone(),
2994            _ => format!("c{}", idx),
2995        })
2996        .collect()
2997}
2998
2999/// Convert a term to a constant value (if it is a constant)
3000fn term_to_const_value(term: &Term) -> Option<ConstValue> {
3001    match term {
3002        Term::Integer(i) => Some(ConstValue::I64(*i)),
3003        Term::Float(f) => Some(ConstValue::F64(*f)),
3004        Term::String(s) => Some(ConstValue::Symbol(s.clone())),
3005        Term::Symbol(id) => Some(ConstValue::Symbol(symbol::resolve(*id))),
3006        Term::Variable(_)
3007        | Term::Anonymous
3008        | Term::Aggregate(_)
3009        | Term::List(_)
3010        | Term::Cons { .. }
3011        | Term::Compound { .. }
3012        | Term::PredRef(_) => None,
3013    }
3014}
3015
3016pub(crate) fn term_to_typed_const_value(
3017    term: &Term,
3018    expected: ScalarType,
3019) -> Result<Option<ConstValue>> {
3020    let const_val = match term {
3021        Term::Integer(i) => match expected {
3022            ScalarType::U32 => {
3023                if *i >= 0 && *i <= u32::MAX as i64 {
3024                    ConstValue::U32(*i as u32)
3025                } else {
3026                    return Err(XlogError::Compilation(format!(
3027                        "Integer literal {} out of range for {:?}",
3028                        i, expected
3029                    )));
3030                }
3031            }
3032            ScalarType::U64 => {
3033                if *i >= 0 {
3034                    ConstValue::U64(*i as u64)
3035                } else {
3036                    return Err(XlogError::Compilation(format!(
3037                        "Integer literal {} out of range for {:?}",
3038                        i, expected
3039                    )));
3040                }
3041            }
3042            ScalarType::I32 => {
3043                if *i >= i32::MIN as i64 && *i <= i32::MAX as i64 {
3044                    ConstValue::I32(*i as i32)
3045                } else {
3046                    return Err(XlogError::Compilation(format!(
3047                        "Integer literal {} out of range for {:?}",
3048                        i, expected
3049                    )));
3050                }
3051            }
3052            ScalarType::I64 => ConstValue::I64(*i),
3053            ScalarType::F32 => {
3054                let value = *i as f64;
3055                if value < f32::MIN as f64 || value > f32::MAX as f64 {
3056                    return Err(XlogError::Compilation(format!(
3057                        "Integer literal {} out of range for {:?}",
3058                        i, expected
3059                    )));
3060                }
3061                ConstValue::F32(value as f32)
3062            }
3063            ScalarType::F64 => ConstValue::F64(*i as f64),
3064            ScalarType::Bool => {
3065                if *i == 0 || *i == 1 {
3066                    ConstValue::Bool(*i == 1)
3067                } else {
3068                    return Err(XlogError::Compilation(format!(
3069                        "Integer literal {} not valid for {:?}",
3070                        i, expected
3071                    )));
3072                }
3073            }
3074            ScalarType::Symbol => {
3075                return Err(XlogError::Compilation(format!(
3076                    "Integer literal {} not valid for {:?}",
3077                    i, expected
3078                )));
3079            }
3080        },
3081        Term::Float(f) => match expected {
3082            ScalarType::F32 => {
3083                if !f.is_finite() {
3084                    return Err(XlogError::Compilation(format!(
3085                        "Float literal {} not valid for {:?}",
3086                        f, expected
3087                    )));
3088                }
3089                if *f < f32::MIN as f64 || *f > f32::MAX as f64 {
3090                    return Err(XlogError::Compilation(format!(
3091                        "Float literal {} out of range for {:?}",
3092                        f, expected
3093                    )));
3094                }
3095                ConstValue::F32(*f as f32)
3096            }
3097            ScalarType::F64 => ConstValue::F64(*f),
3098            ScalarType::U32
3099            | ScalarType::U64
3100            | ScalarType::I32
3101            | ScalarType::I64
3102            | ScalarType::Bool
3103            | ScalarType::Symbol => {
3104                return Err(XlogError::Compilation(format!(
3105                    "Float literal {} not valid for {:?}",
3106                    f, expected
3107                )));
3108            }
3109        },
3110        Term::String(s) => {
3111            if expected == ScalarType::Symbol {
3112                ConstValue::Symbol(s.clone())
3113            } else {
3114                return Err(XlogError::Compilation(format!(
3115                    "String literal {} not valid for {:?}",
3116                    s, expected
3117                )));
3118            }
3119        }
3120        Term::Symbol(id) => {
3121            let value = symbol::resolve(*id);
3122            match expected {
3123                ScalarType::Symbol => ConstValue::Symbol(value),
3124                ScalarType::Bool if matches!(value.as_str(), "true" | "false") => {
3125                    ConstValue::Bool(value == "true")
3126                }
3127                _ => {
3128                    return Err(XlogError::Compilation(format!(
3129                        "Symbol literal {} not valid for {:?}",
3130                        value, expected
3131                    )));
3132                }
3133            }
3134        }
3135        Term::Variable(_)
3136        | Term::Anonymous
3137        | Term::Aggregate(_)
3138        | Term::List(_)
3139        | Term::Cons { .. }
3140        | Term::Compound { .. }
3141        | Term::PredRef(_) => return Ok(None),
3142    };
3143
3144    Ok(Some(const_val))
3145}
3146
3147/// Convert AST AggOp to core AggOp
3148fn convert_agg_op(op: &AggOp) -> CoreAggOp {
3149    match op {
3150        AggOp::Count => CoreAggOp::Count,
3151        AggOp::Sum => CoreAggOp::Sum,
3152        AggOp::Min => CoreAggOp::Min,
3153        AggOp::Max => CoreAggOp::Max,
3154        AggOp::LogSumExp => CoreAggOp::LogSumExp,
3155    }
3156}
3157
3158// Export the find_sccs_for_lowering function from stratify
3159// We need to add this to the stratify module
3160
3161#[cfg(test)]
3162mod arith_type_tests {
3163    use super::*;
3164    use crate::ast::ArithExpr;
3165
3166    #[test]
3167    fn test_arith_type_inference_same_type() {
3168        // X + Y where both are i64 should succeed and return i64
3169        let lowerer = Lowerer::new();
3170        let mut var_env = VariableEnv::new();
3171        var_env.bind("X", 0, ScalarType::I64);
3172        var_env.bind("Y", 1, ScalarType::I64);
3173
3174        let expr = ArithExpr::Add(
3175            Box::new(ArithExpr::Variable("X".to_string())),
3176            Box::new(ArithExpr::Variable("Y".to_string())),
3177        );
3178        let result = lowerer.infer_arith_type(&expr, &var_env);
3179        assert!(result.is_ok());
3180        assert_eq!(result.unwrap(), ScalarType::I64);
3181    }
3182
3183    #[test]
3184    fn test_arith_type_inference_mismatch() {
3185        // X + Y where X is i64 and Y is f64 should fail
3186        let lowerer = Lowerer::new();
3187        let mut var_env = VariableEnv::new();
3188        var_env.bind("X", 0, ScalarType::I64);
3189        var_env.bind("Y", 1, ScalarType::F64);
3190
3191        let expr = ArithExpr::Add(
3192            Box::new(ArithExpr::Variable("X".to_string())),
3193            Box::new(ArithExpr::Variable("Y".to_string())),
3194        );
3195        let result = lowerer.infer_arith_type(&expr, &var_env);
3196        assert!(result.is_err());
3197    }
3198}
3199
3200#[cfg(test)]
3201mod tests {
3202    use super::*;
3203    use crate::ast::*;
3204
3205    fn pred_decl(name: &str, types: Vec<ScalarType>) -> PredDecl {
3206        let type_refs: Vec<TypeRef> = types.into_iter().map(TypeRef::Scalar).collect();
3207        let columns = type_refs
3208            .iter()
3209            .cloned()
3210            .map(|typ| PredColumn { name: None, typ })
3211            .collect();
3212        PredDecl {
3213            name: name.to_string(),
3214            types: type_refs,
3215            columns,
3216            is_private: false,
3217        }
3218    }
3219
3220    /// Helper to create a simple edge atom
3221    fn edge_atom(x: &str, y: &str) -> Atom {
3222        Atom {
3223            predicate: "edge".to_string(),
3224            terms: vec![Term::Variable(x.to_string()), Term::Variable(y.to_string())],
3225        }
3226    }
3227
3228    /// Helper to create a reach atom
3229    fn reach_atom(x: &str, y: &str) -> Atom {
3230        Atom {
3231            predicate: "reach".to_string(),
3232            terms: vec![Term::Variable(x.to_string()), Term::Variable(y.to_string())],
3233        }
3234    }
3235
3236    /// Helper to create a node atom
3237    fn node_atom(x: &str) -> Atom {
3238        Atom {
3239            predicate: "node".to_string(),
3240            terms: vec![Term::Variable(x.to_string())],
3241        }
3242    }
3243
3244    #[test]
3245    fn test_lowerer_new() {
3246        let lowerer = Lowerer::new();
3247        assert!(lowerer.schemas.is_empty());
3248        assert!(lowerer.strata.is_empty());
3249        assert_eq!(lowerer.next_rel_id, 0);
3250    }
3251
3252    #[test]
3253    fn test_get_or_create_rel_id() {
3254        let mut lowerer = Lowerer::new();
3255        let id1 = lowerer.get_or_create_rel_id("edge");
3256        let id2 = lowerer.get_or_create_rel_id("reach");
3257        let id3 = lowerer.get_or_create_rel_id("edge");
3258
3259        assert_eq!(id1, RelId(0));
3260        assert_eq!(id2, RelId(1));
3261        assert_eq!(id3, RelId(0)); // Same as id1
3262    }
3263
3264    #[test]
3265    fn test_infer_schemas_from_facts() {
3266        let mut program = Program::new();
3267        program.rules.push(Rule {
3268            head: Atom {
3269                predicate: "edge".to_string(),
3270                terms: vec![Term::Integer(1), Term::Integer(2)],
3271            },
3272            body: vec![],
3273        });
3274
3275        let mut lowerer = Lowerer::new();
3276        lowerer.infer_schemas(&program).unwrap();
3277
3278        assert!(lowerer.schemas.contains_key("edge"));
3279        let schema = lowerer.schemas.get("edge").unwrap();
3280        assert_eq!(schema.arity(), 2);
3281    }
3282
3283    #[test]
3284    fn test_infer_schemas_propagates_through_reversed_rule_chains() {
3285        let mut program = Program::new();
3286        program.rules.push(Rule {
3287            head: Atom {
3288                predicate: "shared".to_string(),
3289                terms: vec![Term::Variable("X".to_string())],
3290            },
3291            body: vec![BodyLiteral::Positive(Atom {
3292                predicate: "intermediate".to_string(),
3293                terms: vec![Term::Variable("X".to_string())],
3294            })],
3295        });
3296        program.rules.push(Rule {
3297            head: Atom {
3298                predicate: "intermediate".to_string(),
3299                terms: vec![Term::Variable("X".to_string())],
3300            },
3301            body: vec![BodyLiteral::Positive(Atom {
3302                predicate: "source".to_string(),
3303                terms: vec![Term::Variable("X".to_string())],
3304            })],
3305        });
3306        program.rules.push(Rule {
3307            head: Atom {
3308                predicate: "source".to_string(),
3309                terms: vec![Term::Symbol(symbol::intern("value"))],
3310            },
3311            body: vec![],
3312        });
3313
3314        let mut lowerer = Lowerer::new();
3315        lowerer.infer_schemas(&program).unwrap();
3316
3317        assert_eq!(
3318            lowerer
3319                .schemas
3320                .get("shared")
3321                .and_then(|schema| schema.column_type(0)),
3322            Some(ScalarType::Symbol)
3323        );
3324    }
3325
3326    #[test]
3327    fn test_lower_simple_rule() {
3328        // reach(X, Y) :- edge(X, Y).
3329        let rule = Rule {
3330            head: reach_atom("X", "Y"),
3331            body: vec![BodyLiteral::Positive(edge_atom("X", "Y"))],
3332        };
3333
3334        let mut lowerer = Lowerer::new();
3335        lowerer.schemas.insert(
3336            "edge".to_string(),
3337            Schema::new(vec![
3338                ("c0".to_string(), ScalarType::U32),
3339                ("c1".to_string(), ScalarType::U32),
3340            ]),
3341        );
3342
3343        let result = lowerer.lower_rule(&rule);
3344        assert!(result.is_ok());
3345
3346        let node = result.unwrap();
3347        // Should be just a scan (no projection needed since columns match)
3348        assert!(matches!(node, RirNode::Scan { .. }));
3349    }
3350
3351    #[test]
3352    fn test_lower_join_rule() {
3353        // reach(X, Z) :- reach(X, Y), edge(Y, Z).
3354        let rule = Rule {
3355            head: Atom {
3356                predicate: "reach".to_string(),
3357                terms: vec![
3358                    Term::Variable("X".to_string()),
3359                    Term::Variable("Z".to_string()),
3360                ],
3361            },
3362            body: vec![
3363                BodyLiteral::Positive(reach_atom("X", "Y")),
3364                BodyLiteral::Positive(edge_atom("Y", "Z")),
3365            ],
3366        };
3367
3368        let mut lowerer = Lowerer::new();
3369        lowerer.schemas.insert(
3370            "reach".to_string(),
3371            Schema::new(vec![
3372                ("c0".to_string(), ScalarType::U32),
3373                ("c1".to_string(), ScalarType::U32),
3374            ]),
3375        );
3376        lowerer.schemas.insert(
3377            "edge".to_string(),
3378            Schema::new(vec![
3379                ("c0".to_string(), ScalarType::U32),
3380                ("c1".to_string(), ScalarType::U32),
3381            ]),
3382        );
3383
3384        let result = lowerer.lower_rule(&rule);
3385        assert!(result.is_ok());
3386
3387        let node = result.unwrap();
3388        // Should be Project(Join(Scan, Scan))
3389        if let RirNode::Project { input, columns } = node {
3390            // X from reach (col 0), Z from edge (col 3)
3391            assert_eq!(
3392                columns,
3393                vec![ProjectExpr::Column(0), ProjectExpr::Column(3)]
3394            );
3395            assert!(matches!(*input, RirNode::Join { .. }));
3396            if let RirNode::Join {
3397                left_keys,
3398                right_keys,
3399                ..
3400            } = *input
3401            {
3402                assert_eq!(left_keys, vec![1]); // Y in reach (position 1)
3403                assert_eq!(right_keys, vec![0]); // Y in edge (position 0)
3404            }
3405        } else {
3406            panic!("Expected Project node");
3407        }
3408    }
3409
3410    #[test]
3411    fn test_join_order_prefers_smaller_relation() {
3412        // out(X) :- big(X), small(X).
3413        let rule = Rule {
3414            head: Atom {
3415                predicate: "out".to_string(),
3416                terms: vec![Term::Variable("X".to_string())],
3417            },
3418            body: vec![
3419                BodyLiteral::Positive(Atom {
3420                    predicate: "big".to_string(),
3421                    terms: vec![Term::Variable("X".to_string())],
3422                }),
3423                BodyLiteral::Positive(Atom {
3424                    predicate: "small".to_string(),
3425                    terms: vec![Term::Variable("X".to_string())],
3426                }),
3427            ],
3428        };
3429
3430        let mut lowerer = Lowerer::new();
3431        lowerer.schemas.insert(
3432            "big".to_string(),
3433            Schema::new(vec![("c0".to_string(), ScalarType::U32)]),
3434        );
3435        lowerer.schemas.insert(
3436            "small".to_string(),
3437            Schema::new(vec![("c0".to_string(), ScalarType::U32)]),
3438        );
3439
3440        // Ensure stable RelIds independent of join order.
3441        let big_id = lowerer.get_or_create_rel_id("big");
3442        let small_id = lowerer.get_or_create_rel_id("small");
3443        assert_eq!(big_id, RelId(0));
3444        assert_eq!(small_id, RelId(1));
3445
3446        // Prefer scanning the smaller relation first.
3447        lowerer.est_cardinality.insert("big".to_string(), 10_000);
3448        lowerer.est_cardinality.insert("small".to_string(), 10);
3449
3450        let node = lowerer.lower_rule(&rule).unwrap();
3451        let join = match node {
3452            RirNode::Project { input, .. } => *input,
3453            other => other,
3454        };
3455
3456        match join {
3457            RirNode::Join { left, right, .. } => {
3458                // Prefer building the hash table on the smaller relation (right/build side).
3459                assert!(matches!(*left, RirNode::Scan { rel } if rel == big_id));
3460                assert!(matches!(*right, RirNode::Scan { rel } if rel == small_id));
3461            }
3462            other => panic!("Expected Join node, got {:?}", other),
3463        }
3464    }
3465
3466    #[test]
3467    fn test_lower_negation() {
3468        // isolated(X) :- node(X), not edge(X, _).
3469        let rule = Rule {
3470            head: Atom {
3471                predicate: "isolated".to_string(),
3472                terms: vec![Term::Variable("X".to_string())],
3473            },
3474            body: vec![
3475                BodyLiteral::Positive(node_atom("X")),
3476                BodyLiteral::Negated(Atom {
3477                    predicate: "edge".to_string(),
3478                    terms: vec![
3479                        Term::Variable("X".to_string()),
3480                        Term::Variable("_".to_string()),
3481                    ],
3482                }),
3483            ],
3484        };
3485
3486        let mut lowerer = Lowerer::new();
3487        lowerer.schemas.insert(
3488            "node".to_string(),
3489            Schema::new(vec![("c0".to_string(), ScalarType::U32)]),
3490        );
3491        lowerer.schemas.insert(
3492            "edge".to_string(),
3493            Schema::new(vec![
3494                ("c0".to_string(), ScalarType::U32),
3495                ("c1".to_string(), ScalarType::U32),
3496            ]),
3497        );
3498
3499        let result = lowerer.lower_rule(&rule);
3500        assert!(result.is_ok());
3501
3502        // The result should involve a Diff or semi-join for negation
3503        let node = result.unwrap();
3504        // Verify the structure contains the negation handling
3505        fn contains_diff_or_semi(node: &RirNode) -> bool {
3506            match node {
3507                RirNode::Diff { .. } => true,
3508                RirNode::Join {
3509                    join_type: JoinType::Semi,
3510                    ..
3511                } => true,
3512                RirNode::Join { left, right, .. } => {
3513                    contains_diff_or_semi(left) || contains_diff_or_semi(right)
3514                }
3515                RirNode::Project { input, .. } => contains_diff_or_semi(input),
3516                RirNode::Filter { input, .. } => contains_diff_or_semi(input),
3517                _ => false,
3518            }
3519        }
3520        assert!(contains_diff_or_semi(&node));
3521    }
3522
3523    #[test]
3524    fn test_lower_ground_negation_preserves_the_positive_input_schema() {
3525        // ok(X) :- x(X), not p(3).
3526        let rule = Rule {
3527            head: Atom {
3528                predicate: "ok".to_string(),
3529                terms: vec![Term::Variable("X".to_string())],
3530            },
3531            body: vec![
3532                BodyLiteral::Positive(Atom {
3533                    predicate: "x".to_string(),
3534                    terms: vec![Term::Variable("X".to_string())],
3535                }),
3536                BodyLiteral::Negated(Atom {
3537                    predicate: "p".to_string(),
3538                    terms: vec![Term::Integer(3)],
3539                }),
3540            ],
3541        };
3542
3543        let mut lowerer = Lowerer::new();
3544        for predicate in ["ok", "x", "p"] {
3545            lowerer.schemas.insert(
3546                predicate.to_string(),
3547                Schema::new(vec![("c0".to_string(), ScalarType::U32)]),
3548            );
3549        }
3550
3551        let node = lowerer.lower_rule(&rule).expect("lower ground negation");
3552
3553        let RirNode::Project { input, columns } = node else {
3554            panic!("ground negation must project away its internal existence key");
3555        };
3556        assert_eq!(columns, vec![ProjectExpr::Column(0)]);
3557        assert!(matches!(
3558            *input,
3559            RirNode::Join {
3560                left_keys,
3561                right_keys,
3562                join_type: JoinType::Anti,
3563                ..
3564            } if left_keys == vec![1] && right_keys == vec![0]
3565        ));
3566    }
3567
3568    #[test]
3569    fn test_lower_comparison() {
3570        // greater(X, Y) :- pair(X, Y), X > Y.
3571        let rule = Rule {
3572            head: Atom {
3573                predicate: "greater".to_string(),
3574                terms: vec![
3575                    Term::Variable("X".to_string()),
3576                    Term::Variable("Y".to_string()),
3577                ],
3578            },
3579            body: vec![
3580                BodyLiteral::Positive(Atom {
3581                    predicate: "pair".to_string(),
3582                    terms: vec![
3583                        Term::Variable("X".to_string()),
3584                        Term::Variable("Y".to_string()),
3585                    ],
3586                }),
3587                BodyLiteral::Comparison(Comparison {
3588                    left: Term::Variable("X".to_string()),
3589                    op: CompOp::Gt,
3590                    right: Term::Variable("Y".to_string()),
3591                }),
3592            ],
3593        };
3594
3595        let mut lowerer = Lowerer::new();
3596        lowerer.schemas.insert(
3597            "pair".to_string(),
3598            Schema::new(vec![
3599                ("c0".to_string(), ScalarType::U32),
3600                ("c1".to_string(), ScalarType::U32),
3601            ]),
3602        );
3603
3604        let result = lowerer.lower_rule(&rule);
3605        assert!(result.is_ok());
3606
3607        let node = result.unwrap();
3608        // Should contain a Filter node
3609        fn contains_filter(node: &RirNode) -> bool {
3610            match node {
3611                RirNode::Filter { .. } => true,
3612                RirNode::Project { input, .. } => contains_filter(input),
3613                RirNode::Join { left, right, .. } => {
3614                    contains_filter(left) || contains_filter(right)
3615                }
3616                _ => false,
3617            }
3618        }
3619        assert!(contains_filter(&node));
3620    }
3621
3622    #[test]
3623    fn test_lower_constant_filter() {
3624        // specific_edge(Y) :- edge(1, Y).
3625        let rule = Rule {
3626            head: Atom {
3627                predicate: "specific_edge".to_string(),
3628                terms: vec![Term::Variable("Y".to_string())],
3629            },
3630            body: vec![BodyLiteral::Positive(Atom {
3631                predicate: "edge".to_string(),
3632                terms: vec![Term::Integer(1), Term::Variable("Y".to_string())],
3633            })],
3634        };
3635
3636        let mut lowerer = Lowerer::new();
3637        lowerer.schemas.insert(
3638            "edge".to_string(),
3639            Schema::new(vec![
3640                ("c0".to_string(), ScalarType::U32),
3641                ("c1".to_string(), ScalarType::U32),
3642            ]),
3643        );
3644
3645        let result = lowerer.lower_rule(&rule);
3646        assert!(result.is_ok());
3647
3648        let node = result.unwrap();
3649        // Should contain a Filter for the constant 1
3650        fn has_const_filter(node: &RirNode) -> bool {
3651            match node {
3652                RirNode::Filter {
3653                    predicate: Expr::Compare { right, .. },
3654                    ..
3655                } => matches!(**right, Expr::Const(_)),
3656                RirNode::Project { input, .. } => has_const_filter(input),
3657                _ => false,
3658            }
3659        }
3660        assert!(has_const_filter(&node));
3661    }
3662
3663    #[test]
3664    fn test_lower_repeated_variable_filter() {
3665        // self_loop(X) :- edge(X, X).
3666        let rule = Rule {
3667            head: Atom {
3668                predicate: "self_loop".to_string(),
3669                terms: vec![Term::Variable("X".to_string())],
3670            },
3671            body: vec![BodyLiteral::Positive(Atom {
3672                predicate: "edge".to_string(),
3673                terms: vec![
3674                    Term::Variable("X".to_string()),
3675                    Term::Variable("X".to_string()),
3676                ],
3677            })],
3678        };
3679
3680        let mut lowerer = Lowerer::new();
3681        lowerer.schemas.insert(
3682            "edge".to_string(),
3683            Schema::new(vec![
3684                ("c0".to_string(), ScalarType::U32),
3685                ("c1".to_string(), ScalarType::U32),
3686            ]),
3687        );
3688
3689        let node = lowerer.lower_rule(&rule).expect("lower_rule failed");
3690
3691        fn has_col_eq_filter(node: &RirNode) -> bool {
3692            match node {
3693                RirNode::Filter { predicate, .. } => match predicate {
3694                    Expr::Compare {
3695                        left,
3696                        op: CompareOp::Eq,
3697                        right,
3698                    } => {
3699                        matches!((&**left, &**right), (Expr::Column(0), Expr::Column(1)))
3700                            || matches!((&**left, &**right), (Expr::Column(1), Expr::Column(0)))
3701                    }
3702                    Expr::And(exprs) => exprs.iter().any(|e| match e {
3703                        Expr::Compare {
3704                            left,
3705                            op: CompareOp::Eq,
3706                            right,
3707                        } => {
3708                            matches!((&**left, &**right), (Expr::Column(0), Expr::Column(1)))
3709                                || matches!((&**left, &**right), (Expr::Column(1), Expr::Column(0)))
3710                        }
3711                        _ => false,
3712                    }),
3713                    _ => false,
3714                },
3715                RirNode::Project { input, .. } => has_col_eq_filter(input),
3716                _ => false,
3717            }
3718        }
3719
3720        assert!(has_col_eq_filter(&node));
3721    }
3722
3723    #[test]
3724    fn test_lower_program_simple() {
3725        let mut program = Program::new();
3726
3727        // edge(1, 2).
3728        program.rules.push(Rule {
3729            head: Atom {
3730                predicate: "edge".to_string(),
3731                terms: vec![Term::Integer(1), Term::Integer(2)],
3732            },
3733            body: vec![],
3734        });
3735
3736        // reach(X, Y) :- edge(X, Y).
3737        program.rules.push(Rule {
3738            head: reach_atom("X", "Y"),
3739            body: vec![BodyLiteral::Positive(edge_atom("X", "Y"))],
3740        });
3741
3742        let mut lowerer = Lowerer::new();
3743        lowerer.set_strata(vec![vec!["edge".to_string()], vec!["reach".to_string()]]);
3744
3745        let result = lowerer.lower_program(&program);
3746        assert!(result.is_ok());
3747
3748        let plan = result.unwrap();
3749        assert!(!plan.sccs.is_empty());
3750    }
3751
3752    #[test]
3753    fn facts_are_metadata_and_not_executable_rules() {
3754        let mut program = Program::new();
3755        for value in [1, 2, 2] {
3756            program.rules.push(Rule {
3757                head: Atom {
3758                    predicate: "base".to_string(),
3759                    terms: vec![Term::Integer(value)],
3760                },
3761                body: vec![],
3762            });
3763        }
3764
3765        let mut lowerer = Lowerer::new();
3766        lowerer.set_strata(vec![vec!["base".to_string()]]);
3767
3768        let plan = lowerer.lower_program(&program).unwrap();
3769
3770        assert_eq!(
3771            plan.rules_by_scc.iter().map(Vec::len).sum::<usize>(),
3772            0,
3773            "facts are materialized by the relation loader, not executed as rules"
3774        );
3775        assert_eq!(lowerer.schemas.get("base").unwrap().arity(), 1);
3776        assert_eq!(lowerer.est_cardinality.get("base"), Some(&3));
3777        assert!(lowerer
3778            .sccs
3779            .iter()
3780            .any(|scc| scc.predicates.iter().any(|predicate| predicate == "base")));
3781
3782        let base_id = lowerer.rel_ids().get("base").copied().unwrap();
3783        assert_eq!(plan.rel_arities.get(&base_id), Some(&1));
3784    }
3785
3786    #[test]
3787    fn test_variable_env() {
3788        let mut env = VariableEnv::new();
3789        env.add_occurrence("X", "edge".to_string(), 0, 0);
3790        env.add_occurrence("Y", "edge".to_string(), 1, 1);
3791        env.add_occurrence("Y", "node".to_string(), 0, 2);
3792
3793        assert_eq!(env.get_column("X"), Some(0));
3794        assert_eq!(env.get_column("Y"), Some(1)); // First occurrence
3795        assert_eq!(env.get_column("Z"), None);
3796    }
3797
3798    #[test]
3799    fn test_inferred_scalar_type() {
3800        assert_eq!(
3801            Term::Variable("X".to_string()).inferred_scalar_type(),
3802            ScalarType::U64
3803        );
3804        assert_eq!(Term::Integer(42).inferred_scalar_type(), ScalarType::U32);
3805        assert_eq!(
3806            Term::Integer(i64::MAX).inferred_scalar_type(),
3807            ScalarType::I64
3808        );
3809        assert_eq!(Term::Float(3.25).inferred_scalar_type(), ScalarType::F64);
3810        assert_eq!(
3811            Term::Symbol(symbol::intern("foo")).inferred_scalar_type(),
3812            ScalarType::Symbol
3813        );
3814    }
3815
3816    #[test]
3817    fn test_convert_agg_op() {
3818        assert_eq!(convert_agg_op(&AggOp::Count), CoreAggOp::Count);
3819        assert_eq!(convert_agg_op(&AggOp::Sum), CoreAggOp::Sum);
3820        assert_eq!(convert_agg_op(&AggOp::Min), CoreAggOp::Min);
3821        assert_eq!(convert_agg_op(&AggOp::Max), CoreAggOp::Max);
3822        assert_eq!(convert_agg_op(&AggOp::LogSumExp), CoreAggOp::LogSumExp);
3823    }
3824
3825    #[test]
3826    fn test_variable_env_bind_updates_total_cols() {
3827        // Test that bind() properly updates total_cols for chained is-expressions
3828        let mut env = VariableEnv::new();
3829        env.total_cols = 2; // Simulate 2 columns from atoms
3830
3831        // Bind first computed variable at column 2
3832        env.bind("A", 2, ScalarType::I64);
3833        assert_eq!(
3834            env.column_count(),
3835            3,
3836            "total_cols should be 3 after first bind"
3837        );
3838        assert_eq!(env.get_column("A"), Some(2));
3839
3840        // Bind second computed variable at column 3
3841        env.bind("B", 3, ScalarType::I64);
3842        assert_eq!(
3843            env.column_count(),
3844            4,
3845            "total_cols should be 4 after second bind"
3846        );
3847        assert_eq!(env.get_column("B"), Some(3));
3848    }
3849
3850    #[test]
3851    fn test_lower_chained_is_expressions() {
3852        // result(A, B) :- input(X, Y), A is X + Y, B is A * 2.
3853        // This tests that chained is-expressions correctly update column indices
3854        let rule = Rule {
3855            head: Atom {
3856                predicate: "result".to_string(),
3857                terms: vec![
3858                    Term::Variable("A".to_string()),
3859                    Term::Variable("B".to_string()),
3860                ],
3861            },
3862            body: vec![
3863                BodyLiteral::Positive(Atom {
3864                    predicate: "input".to_string(),
3865                    terms: vec![
3866                        Term::Variable("X".to_string()),
3867                        Term::Variable("Y".to_string()),
3868                    ],
3869                }),
3870                BodyLiteral::IsExpr(IsExpr {
3871                    target: "A".to_string(),
3872                    expr: ArithExpr::Add(
3873                        Box::new(ArithExpr::Variable("X".to_string())),
3874                        Box::new(ArithExpr::Variable("Y".to_string())),
3875                    ),
3876                }),
3877                BodyLiteral::IsExpr(IsExpr {
3878                    target: "B".to_string(),
3879                    expr: ArithExpr::Mul(
3880                        Box::new(ArithExpr::Variable("A".to_string())),
3881                        Box::new(ArithExpr::Integer(2)),
3882                    ),
3883                }),
3884            ],
3885        };
3886
3887        let mut lowerer = Lowerer::new();
3888        lowerer.schemas.insert(
3889            "input".to_string(),
3890            Schema::new(vec![
3891                ("c0".to_string(), ScalarType::I64),
3892                ("c1".to_string(), ScalarType::I64),
3893            ]),
3894        );
3895
3896        let result = lowerer.lower_rule(&rule);
3897        assert!(
3898            result.is_ok(),
3899            "Lowering chained is-expressions should succeed: {:?}",
3900            result.err()
3901        );
3902
3903        let node = result.unwrap();
3904
3905        // The structure should be:
3906        // Project([col 2, col 3]) <-- final projection for A, B
3907        //   Project([col 0, col 1, col 2, A*2]) <-- second is-expr adds B at col 3
3908        //     Project([col 0, col 1, X+Y]) <-- first is-expr adds A at col 2
3909        //       Scan(input)
3910
3911        // Verify we have nested Project nodes
3912        fn count_projects(node: &RirNode) -> usize {
3913            match node {
3914                RirNode::Project { input, .. } => 1 + count_projects(input),
3915                _ => 0,
3916            }
3917        }
3918
3919        // We expect 3 Project nodes: 2 for is-expressions + 1 for final head projection
3920        let project_count = count_projects(&node);
3921        assert!(
3922            project_count >= 2,
3923            "Expected at least 2 Project nodes for chained is-exprs, got {}",
3924            project_count
3925        );
3926
3927        // Verify the final projection references columns 2 and 3 (A and B)
3928        if let RirNode::Project { columns, .. } = &node {
3929            assert_eq!(columns.len(), 2, "Head has 2 variables");
3930            // A should be at column 2, B at column 3
3931            assert_eq!(columns[0], ProjectExpr::Column(2), "A should be column 2");
3932            assert_eq!(columns[1], ProjectExpr::Column(3), "B should be column 3");
3933        } else {
3934            panic!("Expected top-level Project node");
3935        }
3936    }
3937
3938    #[test]
3939    fn test_u64_comparison_type_from_pred_decl() {
3940        // Test that u64 type from pred decl is preserved in comparison lowering
3941        let mut program = Program::new();
3942
3943        // pred count_data(symbol, u64).
3944        program.predicates.push(pred_decl(
3945            "count_data",
3946            vec![ScalarType::Symbol, ScalarType::U64],
3947        ));
3948
3949        // count_data(alice, 5).
3950        program.rules.push(Rule {
3951            head: Atom {
3952                predicate: "count_data".to_string(),
3953                terms: vec![
3954                    Term::Symbol(xlog_core::symbol::intern("alice")),
3955                    Term::Integer(5),
3956                ],
3957            },
3958            body: vec![],
3959        });
3960
3961        // pred big_count(symbol, u64).
3962        program.predicates.push(pred_decl(
3963            "big_count",
3964            vec![ScalarType::Symbol, ScalarType::U64],
3965        ));
3966
3967        // big_count(Name, Count) :- count_data(Name, Count), Count >= 3.
3968        program.rules.push(Rule {
3969            head: Atom {
3970                predicate: "big_count".to_string(),
3971                terms: vec![
3972                    Term::Variable("Name".to_string()),
3973                    Term::Variable("Count".to_string()),
3974                ],
3975            },
3976            body: vec![
3977                BodyLiteral::Positive(Atom {
3978                    predicate: "count_data".to_string(),
3979                    terms: vec![
3980                        Term::Variable("Name".to_string()),
3981                        Term::Variable("Count".to_string()),
3982                    ],
3983                }),
3984                BodyLiteral::Comparison(Comparison {
3985                    left: Term::Variable("Count".to_string()),
3986                    op: CompOp::Ge,
3987                    right: Term::Integer(3),
3988                }),
3989            ],
3990        });
3991
3992        let mut lowerer = Lowerer::new();
3993        lowerer.infer_schemas(&program).unwrap();
3994
3995        // Verify schema has correct types
3996        let schema = lowerer
3997            .schemas
3998            .get("count_data")
3999            .expect("schema for count_data");
4000        assert_eq!(
4001            schema.column_type(0),
4002            Some(ScalarType::Symbol),
4003            "First column should be Symbol"
4004        );
4005        assert_eq!(
4006            schema.column_type(1),
4007            Some(ScalarType::U64),
4008            "Second column should be U64"
4009        );
4010
4011        // Now test lowering the rule with comparison
4012        lowerer.set_strata(vec![
4013            vec!["count_data".to_string()],
4014            vec!["big_count".to_string()],
4015        ]);
4016        lowerer.build_sccs(&program);
4017
4018        let rule = &program.rules[1]; // big_count rule
4019        let result = lowerer.lower_rule(rule);
4020        assert!(
4021            result.is_ok(),
4022            "Lowering should succeed: {:?}",
4023            result.err()
4024        );
4025
4026        // Check that the filter has the correct constant type
4027        fn find_compare_const(node: &RirNode) -> Option<&ConstValue> {
4028            match node {
4029                RirNode::Filter { predicate, input } => {
4030                    if let Expr::Compare { right, .. } = predicate {
4031                        if let Expr::Const(val) = right.as_ref() {
4032                            return Some(val);
4033                        }
4034                    }
4035                    find_compare_const(input)
4036                }
4037                RirNode::Project { input, .. } => find_compare_const(input),
4038                RirNode::Join { left, right, .. } => {
4039                    find_compare_const(left).or_else(|| find_compare_const(right))
4040                }
4041                _ => None,
4042            }
4043        }
4044
4045        let node = result.unwrap();
4046        let const_val = find_compare_const(&node);
4047        assert!(const_val.is_some(), "Should find a constant in comparison");
4048
4049        // The constant should be U64(3), not I64(3)
4050        match const_val.unwrap() {
4051            ConstValue::U64(v) => assert_eq!(*v, 3, "Value should be 3"),
4052            other => panic!("Expected U64(3), got {:?}", other),
4053        }
4054    }
4055
4056    #[test]
4057    fn test_u64_comparison_with_aggregation() {
4058        use crate::ast::AggExpr;
4059
4060        // Test aggregation + comparison case
4061        let mut program = Program::new();
4062
4063        // pred reports_to(symbol, symbol).
4064        program.predicates.push(pred_decl(
4065            "reports_to",
4066            vec![ScalarType::Symbol, ScalarType::Symbol],
4067        ));
4068
4069        // reports_to facts
4070        program.rules.push(Rule {
4071            head: Atom {
4072                predicate: "reports_to".to_string(),
4073                terms: vec![
4074                    Term::Symbol(xlog_core::symbol::intern("alice")),
4075                    Term::Symbol(xlog_core::symbol::intern("bob")),
4076                ],
4077            },
4078            body: vec![],
4079        });
4080        program.rules.push(Rule {
4081            head: Atom {
4082                predicate: "reports_to".to_string(),
4083                terms: vec![
4084                    Term::Symbol(xlog_core::symbol::intern("carol")),
4085                    Term::Symbol(xlog_core::symbol::intern("bob")),
4086                ],
4087            },
4088            body: vec![],
4089        });
4090
4091        // pred direct_count(symbol, u64).
4092        program.predicates.push(pred_decl(
4093            "direct_count",
4094            vec![ScalarType::Symbol, ScalarType::U64],
4095        ));
4096
4097        // direct_count(Mgr, count(Emp)) :- reports_to(Emp, Mgr).
4098        program.rules.push(Rule {
4099            head: Atom {
4100                predicate: "direct_count".to_string(),
4101                terms: vec![
4102                    Term::Variable("Mgr".to_string()),
4103                    Term::Aggregate(AggExpr {
4104                        op: AggOp::Count,
4105                        variable: "Emp".to_string(),
4106                    }),
4107                ],
4108            },
4109            body: vec![BodyLiteral::Positive(Atom {
4110                predicate: "reports_to".to_string(),
4111                terms: vec![
4112                    Term::Variable("Emp".to_string()),
4113                    Term::Variable("Mgr".to_string()),
4114                ],
4115            })],
4116        });
4117
4118        // pred big_manager(symbol, u64).
4119        program.predicates.push(pred_decl(
4120            "big_manager",
4121            vec![ScalarType::Symbol, ScalarType::U64],
4122        ));
4123
4124        // big_manager(Mgr, Count) :- direct_count(Mgr, Count), Count >= 2.
4125        program.rules.push(Rule {
4126            head: Atom {
4127                predicate: "big_manager".to_string(),
4128                terms: vec![
4129                    Term::Variable("Mgr".to_string()),
4130                    Term::Variable("Count".to_string()),
4131                ],
4132            },
4133            body: vec![
4134                BodyLiteral::Positive(Atom {
4135                    predicate: "direct_count".to_string(),
4136                    terms: vec![
4137                        Term::Variable("Mgr".to_string()),
4138                        Term::Variable("Count".to_string()),
4139                    ],
4140                }),
4141                BodyLiteral::Comparison(Comparison {
4142                    left: Term::Variable("Count".to_string()),
4143                    op: CompOp::Ge,
4144                    right: Term::Integer(2),
4145                }),
4146            ],
4147        });
4148
4149        let mut lowerer = Lowerer::new();
4150        lowerer.infer_schemas(&program).unwrap();
4151
4152        // Verify schema has correct types
4153        let schema = lowerer
4154            .schemas
4155            .get("direct_count")
4156            .expect("schema for direct_count");
4157        assert_eq!(
4158            schema.column_type(0),
4159            Some(ScalarType::Symbol),
4160            "First column should be Symbol"
4161        );
4162        assert_eq!(
4163            schema.column_type(1),
4164            Some(ScalarType::U64),
4165            "Second column should be U64"
4166        );
4167
4168        lowerer.set_strata(vec![
4169            vec!["reports_to".to_string()],
4170            vec!["direct_count".to_string()],
4171            vec!["big_manager".to_string()],
4172        ]);
4173        lowerer.build_sccs(&program);
4174
4175        // Lower the big_manager rule (index 3: after 2 facts + aggregation rule)
4176        let big_manager_rule = &program.rules[3];
4177        let result = lowerer.lower_rule(big_manager_rule);
4178        assert!(
4179            result.is_ok(),
4180            "Lowering should succeed: {:?}",
4181            result.err()
4182        );
4183
4184        // Check that the filter has the correct constant type
4185        fn find_compare_const(node: &RirNode) -> Option<&ConstValue> {
4186            match node {
4187                RirNode::Filter { predicate, input } => {
4188                    if let Expr::Compare { right, .. } = predicate {
4189                        if let Expr::Const(val) = right.as_ref() {
4190                            return Some(val);
4191                        }
4192                    }
4193                    find_compare_const(input)
4194                }
4195                RirNode::Project { input, .. } => find_compare_const(input),
4196                RirNode::Join { left, right, .. } => {
4197                    find_compare_const(left).or_else(|| find_compare_const(right))
4198                }
4199                _ => None,
4200            }
4201        }
4202
4203        let node = result.unwrap();
4204        let const_val = find_compare_const(&node);
4205        assert!(const_val.is_some(), "Should find a constant in comparison");
4206
4207        // The constant should be U64(2), not I64(2)
4208        match const_val.unwrap() {
4209            ConstValue::U64(v) => assert_eq!(*v, 2, "Value should be 2"),
4210            other => panic!("Expected U64(2), got {:?}", other),
4211        }
4212    }
4213
4214    #[test]
4215    fn declared_head_constant_is_projected_with_the_schema_type() {
4216        let program = crate::parse_program(
4217            r#"
4218            pred real(f64).
4219            seed().
4220            real(1) :- seed().
4221        "#,
4222        )
4223        .expect("parse typed head-constant fixture");
4224        let mut lowerer = Lowerer::new();
4225        lowerer
4226            .infer_schemas(&program)
4227            .expect("infer declared schemas");
4228        lowerer
4229            .validate_rule_types(&program)
4230            .expect("validate supported literal conversion");
4231
4232        let node = lowerer
4233            .lower_rule(&program.rules[1])
4234            .expect("lower typed head constant");
4235        let RirNode::Project { columns, .. } = node else {
4236            panic!("constant rule head should lower to a projection");
4237        };
4238        assert_eq!(
4239            columns,
4240            vec![ProjectExpr::Computed(
4241                Expr::Const(ConstValue::F64(1.0)),
4242                ScalarType::F64,
4243            )]
4244        );
4245    }
4246}
4247
4248#[cfg(test)]
4249mod cross_predicate_type_tests {
4250    use super::*;
4251
4252    // Regression for the paper's deliberately ill-typed example: the head
4253    // declaration constrains bridge column 0 to symbol while the body
4254    // constrains the same variable to u32 through connected/node. The
4255    // mismatch must surface as a compilation error naming the predicate
4256    // and argument positions, never as a kernel schema dump.
4257    const ILL_TYPED_BRIDGE: &str = r#"
4258pred node(u32, symbol).
4259pred connected(u32, u32).
4260pred bridge(symbol, u32).
4261
4262node(1, "alice").
4263connected(1, 2).
4264
4265bridge(A, B) :- connected(A, B), node(A, _).
4266
4267?- bridge(W, X).
4268"#;
4269
4270    #[test]
4271    fn cross_predicate_schema_mismatch_is_a_compilation_error() {
4272        let program = crate::parse_program(ILL_TYPED_BRIDGE).expect("example parses");
4273        let mut lowerer = Lowerer::new();
4274        let err = lowerer
4275            .lower_program(&program)
4276            .expect_err("ill-typed program must not lower");
4277        let msg = err.to_string();
4278        assert!(
4279            matches!(err, xlog_core::XlogError::Compilation(_)),
4280            "expected a compilation error, got: {msg}"
4281        );
4282        assert!(
4283            msg.contains("bridge"),
4284            "must name the head predicate: {msg}"
4285        );
4286        assert!(
4287            msg.contains("connected") || msg.contains("node"),
4288            "must name the conflicting body predicate: {msg}"
4289        );
4290        assert!(msg.contains('A'), "must name the variable: {msg}");
4291        assert!(msg.contains("position 0"), "must name the position: {msg}");
4292    }
4293
4294    #[test]
4295    fn well_typed_cross_predicate_rule_still_lowers() {
4296        let source = r#"
4297pred node(u32, symbol).
4298pred connected(u32, u32).
4299pred bridge(u32, u32).
4300
4301node(1, "alice").
4302connected(1, 2).
4303
4304bridge(A, B) :- connected(A, B), node(A, _).
4305
4306?- bridge(W, X).
4307"#;
4308        let program = crate::parse_program(source).expect("example parses");
4309        let mut lowerer = Lowerer::new();
4310        lowerer
4311            .lower_program(&program)
4312            .expect("well-typed program lowers");
4313    }
4314
4315    #[test]
4316    fn probabilistic_fact_schema_precedes_body_only_variable_defaults() {
4317        let source = r#"
43180.5::possible(0).
4319observed(0).
4320accepted(X) :- possible(X), observed(X).
4321?- accepted(X).
4322"#;
4323        let program = crate::parse_program(source).expect("probabilistic join parses");
4324        let mut lowerer = Lowerer::new();
4325        lowerer
4326            .lower_program(&program)
4327            .expect("probabilistic facts provide body predicate schemas");
4328        assert_eq!(
4329            lowerer
4330                .schemas()
4331                .get("possible")
4332                .expect("possible schema")
4333                .column_type(0),
4334            Some(ScalarType::U32)
4335        );
4336    }
4337
4338    #[test]
4339    fn annotated_disjunction_schema_precedes_body_only_variable_defaults() {
4340        let source = r#"
43410.5::possible(0); 0.5::possible(1).
4342observed(0).
4343accepted(X) :- possible(X), observed(X).
4344?- accepted(X).
4345"#;
4346        let program = crate::parse_program(source).expect("annotated-disjunction join parses");
4347        let mut lowerer = Lowerer::new();
4348        lowerer
4349            .lower_program(&program)
4350            .expect("annotated-disjunction choices provide body predicate schemas");
4351        assert_eq!(
4352            lowerer
4353                .schemas()
4354                .get("possible")
4355                .expect("possible schema")
4356                .column_type(0),
4357            Some(ScalarType::U32)
4358        );
4359    }
4360
4361    #[test]
4362    fn body_body_schema_conflict_is_a_compilation_error() {
4363        // The same variable drawing incompatible types from two body atoms
4364        // must be rejected even when the head is consistent with one side.
4365        let source = r#"
4366pred label(symbol).
4367pred count(u32).
4368pred out(u32).
4369
4370label("x").
4371count(1).
4372
4373out(A) :- count(A), label(A).
4374
4375?- out(W).
4376"#;
4377        let program = crate::parse_program(source).expect("example parses");
4378        let mut lowerer = Lowerer::new();
4379        let err = lowerer
4380            .lower_program(&program)
4381            .expect_err("body-body conflict must not lower");
4382        assert!(
4383            matches!(err, xlog_core::XlogError::Compilation(_)),
4384            "expected a compilation error, got: {err}"
4385        );
4386    }
4387}