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