1use 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
175pub(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
264pub struct Lowerer {
266 schemas: HashMap<String, Schema>,
268 strata: Vec<Vec<String>>,
270 est_cardinality: HashMap<String, u64>,
272 cardinality_hints: HashMap<String, u64>,
274 next_rel_id: u32,
276 rel_ids: HashMap<String, RelId>,
278 sccs: Vec<Scc>,
280 max_active_rules: usize,
282 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 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 pub fn set_max_active_rules(&mut self, max: usize) {
312 self.max_active_rules = max;
313 }
314
315 pub(crate) fn set_strata(&mut self, strata: Vec<Vec<String>>) {
317 self.strata = strata;
318 }
319
320 pub(crate) fn set_cardinality_hints(&mut self, hints: HashMap<String, u64>) {
324 self.cardinality_hints = hints;
325 }
326
327 pub fn rel_ids(&self) -> &HashMap<String, RelId> {
329 &self.rel_ids
330 }
331
332 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 for pred_decl in &program.predicates {
939 self.get_or_create_rel_id(&pred_decl.name);
940 }
941 for fact in program.facts() {
945 self.get_or_create_rel_id(&fact.head.predicate);
946 }
947
948 Ok(())
949 }
950
951 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 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 pub fn lower_program(&mut self, program: &Program) -> Result<ExecutionPlan> {
985 self.prepare_program_for_lowering(program)?;
986
987 self.build_sccs(program);
989
990 let mut builder = PlanBuilder::new();
992
993 for scc in &self.sccs {
995 builder.add_scc(scc.clone());
996 }
997
998 for (id, preds) in self.strata.iter().enumerate() {
1000 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 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 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 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 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 fn find_scc_for_predicate(&self, pred: &str) -> u32 {
1107 self.scc_by_pred.get(pred).copied().unwrap_or(0)
1108 }
1109
1110 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 fn lower_learnable_rule(&mut self, rule: &LearnableRule) -> Result<RirNode> {
1125 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 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 let head_rel_id = self.get_or_create_rel_id(&head_rel_name);
1160
1161 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 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 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 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 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 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 let (positive_atoms, negated_atoms, comparisons, is_exprs) =
1286 Self::split_body_literals(&rule.body);
1287
1288 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 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 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 if !var_env.types.contains_key(name) {
1327 let typ = schema
1328 .and_then(|s| s.column_type(i))
1329 .unwrap_or(ScalarType::I64); 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 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 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 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 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 selectivity *= 1.0e6;
1544 }
1545
1546 let output_rows = (left.est_rows * right.est_rows * selectivity).max(1.0);
1547
1548 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 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, ¤t.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 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 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 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 for cmp in comparisons {
1823 result = self.apply_comparison(result, cmp, var_env)?;
1824 }
1825
1826 for is_expr in is_exprs {
1828 result = self.lower_is_expr(is_expr, result, var_env)?;
1829 }
1830
1831 for neg_atom in negated_atoms {
1833 result = self.apply_negation(result, neg_atom, var_env)?;
1834 }
1835
1836 Ok(result)
1837 }
1838
1839 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 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 result = self.apply_constant_filters(result, first_atom, 0)?;
1854
1855 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 let right_filtered = self.apply_constant_filters(right_scan, atom, 0)?;
1862
1863 let (left_keys, right_keys) = self.compute_join_keys(&result_vars, atom, result_width);
1865
1866 if left_keys.is_empty() {
1867 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 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 var_env.total_cols = result_width;
1896
1897 Ok(result)
1898 }
1899
1900 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 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 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; }
1934 }
1935 }
1936 }
1937
1938 (left_keys, right_keys)
1939 }
1940
1941 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 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 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 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 let neg_filtered = self.apply_constant_filters(neg_scan, neg_atom, 0)?;
2106
2107 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 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 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 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 let kept_keys = RirNode::Diff {
2184 left: Box::new(input_projected),
2185 right: Box::new(neg_projected),
2186 };
2187
2188 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 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 fn lower_aggregate_rule(
2270 &mut self,
2271 head: &Atom,
2272 body: RirNode,
2273 var_env: &VariableEnv,
2274 ) -> Result<RirNode> {
2275 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 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 let col = var_env
2306 .get_column(&agg.variable)
2307 .ok_or_else(|| XlogError::UnsafeVariable(agg.variable.clone()))?;
2308
2309 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 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 }
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 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 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 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 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 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 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 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 fn lower_is_expr(
2919 &mut self,
2920 is_expr: &IsExpr,
2921 input: RirNode,
2922 var_env: &mut VariableEnv,
2923 ) -> Result<RirNode> {
2924 let result_type = self.infer_arith_type(&is_expr.expr, var_env)?;
2927
2928 let ir_expr = self.arith_to_expr(&is_expr.expr, var_env)?;
2930
2931 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 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
2946pub(crate) struct VariableEnv {
2948 occurrences: HashMap<String, Vec<(String, usize, usize)>>,
2950 total_cols: usize,
2952 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 fn bind(&mut self, name: &str, column: usize, typ: ScalarType) {
2981 self.types.insert(name.to_string(), typ);
2982 self.occurrences
2984 .entry(name.to_string())
2985 .or_default()
2986 .push(("".to_string(), 0, column));
2987 if column >= self.total_cols {
2990 self.total_cols = column + 1;
2991 }
2992 }
2993
2994 fn get_type(&self, name: &str) -> Option<ScalarType> {
2996 self.types.get(name).copied()
2997 }
2998
2999 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
3021fn 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
3169fn 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#[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 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 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 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 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 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)); }
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 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 assert!(matches!(node, RirNode::Scan { .. }));
3371 }
3372
3373 #[test]
3374 fn test_lower_join_rule() {
3375 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 if let RirNode::Project { input, columns } = node {
3412 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]); assert_eq!(right_keys, vec![0]); }
3427 } else {
3428 panic!("Expected Project node");
3429 }
3430 }
3431
3432 #[test]
3433 fn test_join_order_prefers_smaller_relation() {
3434 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 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 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 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 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 let node = result.unwrap();
3526 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 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 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 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 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 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 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 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 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)); 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 let mut env = VariableEnv::new();
3851 env.total_cols = 2; 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 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 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 fn count_projects(node: &RirNode) -> usize {
3935 match node {
3936 RirNode::Project { input, .. } => 1 + count_projects(input),
3937 _ => 0,
3938 }
3939 }
3940
3941 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 if let RirNode::Project { columns, .. } = &node {
3951 assert_eq!(columns.len(), 2, "Head has 2 variables");
3952 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 let mut program = Program::new();
3964
3965 program.predicates.push(pred_decl(
3967 "count_data",
3968 vec![ScalarType::Symbol, ScalarType::U64],
3969 ));
3970
3971 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 program.predicates.push(pred_decl(
3985 "big_count",
3986 vec![ScalarType::Symbol, ScalarType::U64],
3987 ));
3988
3989 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 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 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]; let result = lowerer.lower_rule(rule);
4042 assert!(
4043 result.is_ok(),
4044 "Lowering should succeed: {:?}",
4045 result.err()
4046 );
4047
4048 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 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 let mut program = Program::new();
4084
4085 program.predicates.push(pred_decl(
4087 "reports_to",
4088 vec![ScalarType::Symbol, ScalarType::Symbol],
4089 ));
4090
4091 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 program.predicates.push(pred_decl(
4115 "direct_count",
4116 vec![ScalarType::Symbol, ScalarType::U64],
4117 ));
4118
4119 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 program.predicates.push(pred_decl(
4142 "big_manager",
4143 vec![ScalarType::Symbol, ScalarType::U64],
4144 ));
4145
4146 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 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 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 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 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 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 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}