1use std::path::{Path, PathBuf};
17
18use xlog_core::{Result, XlogError};
19use xlog_ir::{ExecutionPlan, GeneratedQueryRuleProvenance};
20use xlog_stats::{StatsManager, StatsSnapshot};
21
22use crate::compiler_config::CompilerConfig;
23use crate::list_normalize::normalize_list_builtins_owned;
24use crate::lower::Lowerer;
25use crate::magic_sets::rewrite_magic_sets_owned;
26use crate::meta_normalize::normalize_meta_builtins_owned;
27use crate::module::ModuleError;
28use crate::optimizer::Optimizer;
29use crate::parser::parse_program;
30use crate::resolver::ModuleResolver;
31use crate::stratify::stratify;
32use crate::{BodyLiteral, Program, Query, Rule as AstRule, Term};
33
34pub struct Compiler {
54 lowerer: Lowerer,
55}
56
57use std::collections::{HashMap, HashSet};
58use std::sync::Arc;
59use xlog_core::{RelId, Schema};
60
61impl Default for Compiler {
62 fn default() -> Self {
63 Self::new()
64 }
65}
66
67impl Compiler {
68 pub fn new() -> Self {
70 Self {
71 lowerer: Lowerer::new(),
72 }
73 }
74
75 pub fn set_max_active_rules(&mut self, max: usize) {
77 self.lowerer.set_max_active_rules(max);
78 }
79
80 pub fn compile(&mut self, source: &str) -> Result<ExecutionPlan> {
119 self.compile_with_stats_snapshot(source, None)
120 }
121
122 pub fn compile_with_stats_snapshot(
130 &mut self,
131 source: &str,
132 stats_snapshot: Option<&StatsSnapshot>,
133 ) -> Result<ExecutionPlan> {
134 self.compile_with_config_and_stats_snapshot(
135 source,
136 &CompilerConfig::default(),
137 stats_snapshot,
138 )
139 }
140
141 pub fn compile_with_config_and_stats_snapshot(
148 &mut self,
149 source: &str,
150 config: &CompilerConfig,
151 stats_snapshot: Option<&StatsSnapshot>,
152 ) -> Result<ExecutionPlan> {
153 let program = parse_program(source)?;
154 self.compile_owned_program_with_config_and_stats_snapshot(program, config, stats_snapshot)
155 }
156
157 pub fn compile_program(&mut self, program: &Program) -> Result<ExecutionPlan> {
163 self.compile_program_with_stats_snapshot(program, None)
164 }
165
166 pub fn compile_prepared_program(&mut self, program: &Program) -> Result<ExecutionPlan> {
169 self.compile_prepared_program_with_stats_snapshot(program, None)
170 }
171
172 pub fn compile_program_with_stats_snapshot(
177 &mut self,
178 program: &Program,
179 stats_snapshot: Option<&StatsSnapshot>,
180 ) -> Result<ExecutionPlan> {
181 self.compile_program_with_config_and_stats_snapshot(
182 program,
183 &CompilerConfig::default(),
184 stats_snapshot,
185 )
186 }
187
188 pub fn compile_prepared_program_with_stats_snapshot(
190 &mut self,
191 program: &Program,
192 stats_snapshot: Option<&StatsSnapshot>,
193 ) -> Result<ExecutionPlan> {
194 self.compile_prepared_program_with_config_and_stats_snapshot(
195 program,
196 &CompilerConfig::default(),
197 stats_snapshot,
198 )
199 }
200
201 pub(crate) fn validate_program_without_stratification(
209 &mut self,
210 program: &Program,
211 ) -> Result<()> {
212 program.validate_prepared_authored_constraint_identity()?;
213 let program = run_frontend_passes(program.clone())?;
214 self.lowerer.validate_program_without_plan(&program)
215 }
216
217 pub fn compile_program_with_config_and_stats_snapshot(
223 &mut self,
224 program: &Program,
225 config: &CompilerConfig,
226 stats_snapshot: Option<&StatsSnapshot>,
227 ) -> Result<ExecutionPlan> {
228 self.compile_owned_program_with_config_and_stats_snapshot(
229 program.clone(),
230 config,
231 stats_snapshot,
232 )
233 }
234
235 pub fn compile_owned_program_with_config_and_stats_snapshot(
238 &mut self,
239 mut program: Program,
240 config: &CompilerConfig,
241 stats_snapshot: Option<&StatsSnapshot>,
242 ) -> Result<ExecutionPlan> {
243 if program.authored_constraint_source_bound.is_some() {
244 program.validate_prepared_authored_constraint_identity()?;
245 } else {
246 program.prepare_authored_constraint_identity_at_root()?;
247 }
248 self.compile_prepared_owned_program_with_config_and_stats_snapshot(
249 program,
250 config,
251 stats_snapshot,
252 )
253 }
254
255 pub fn compile_prepared_program_with_config_and_stats_snapshot(
257 &mut self,
258 program: &Program,
259 config: &CompilerConfig,
260 stats_snapshot: Option<&StatsSnapshot>,
261 ) -> Result<ExecutionPlan> {
262 self.compile_prepared_owned_program_with_config_and_stats_snapshot(
263 program.clone(),
264 config,
265 stats_snapshot,
266 )
267 }
268
269 pub fn compile_prepared_owned_program_with_config_and_stats_snapshot(
276 &mut self,
277 program: Program,
278 config: &CompilerConfig,
279 stats_snapshot: Option<&StatsSnapshot>,
280 ) -> Result<ExecutionPlan> {
281 program.validate_prepared_authored_constraint_identity()?;
282 let generated_query_heads = generated_query_heads_for_program(&program)?;
283 let program = run_frontend_passes(program)?;
284
285 let strata = stratify(&program).map_err(map_stratification_to_naf_error)?;
287
288 let strata_preds: Vec<Vec<String>> = strata.into_iter().map(|s| s.predicates).collect();
290
291 self.lowerer.set_strata(strata_preds);
293
294 let mut cardinality_hints: HashMap<String, u64> = HashMap::new();
297 if let Some(snapshot) = stats_snapshot {
298 if !snapshot.rel_names.is_empty() {
299 let rel_name_by_id: HashMap<RelId, &str> = snapshot
300 .rel_names
301 .iter()
302 .map(|(id, name)| (*id, name.as_str()))
303 .collect();
304 for rel in &snapshot.relations {
305 if let Some(name) = rel_name_by_id.get(&rel.rel_id) {
306 cardinality_hints.insert((*name).to_string(), rel.cardinality);
307 }
308 }
309 }
310 }
311 self.lowerer.set_cardinality_hints(cardinality_hints);
312
313 let mut plan = self.lowerer.lower_program(&program)?;
314
315 let mut mgr = StatsManager::new();
320 let mut fact_counts: HashMap<String, u64> = HashMap::new();
321 for fact in program.facts() {
322 *fact_counts.entry(fact.head.predicate.clone()).or_insert(0) += 1;
323 }
324
325 for (pred, rel_id) in self.lowerer.rel_ids() {
326 mgr.register_relation(*rel_id);
327 let rows = fact_counts.get(pred).copied().unwrap_or(0);
328 if rows > 0 {
329 mgr.update_cardinality(*rel_id, rows);
330 if let Some(schema) = self.lowerer.schemas().get(pred) {
331 mgr.update_byte_size(*rel_id, rows * schema.row_size_bytes() as u64);
332 }
333 }
334 }
335
336 if let Some(snapshot) = stats_snapshot {
337 if snapshot.rel_names.is_empty() {
338 mgr.merge_snapshot(snapshot);
339 } else {
340 let rel_name_by_id: HashMap<RelId, &str> = snapshot
341 .rel_names
342 .iter()
343 .map(|(id, name)| (*id, name.as_str()))
344 .collect();
345
346 for rel in &snapshot.relations {
347 let Some(pred) = rel_name_by_id.get(&rel.rel_id) else {
348 continue;
349 };
350 let Some(rel_id) = self.lowerer.rel_ids().get(*pred) else {
351 continue;
352 };
353
354 let mut remapped = rel.clone();
355 remapped.rel_id = *rel_id;
356
357 if let Some(schema) = self.lowerer.schemas().get(*pred) {
358 remapped.column_stats.retain(|col| {
359 col.col_idx < schema.arity()
360 && schema.column_type(col.col_idx) == Some(col.dtype)
361 });
362 } else {
363 remapped.column_stats.clear();
364 }
365
366 mgr.register_relation(*rel_id);
367 if let Some(stats) = mgr.get_relation_stats_mut(*rel_id) {
368 *stats = remapped;
369 }
370 }
371
372 for js in &snapshot.join_selectivities {
373 if js.left_keys.len() != js.right_keys.len() {
374 continue;
375 }
376
377 let Some(left_pred) = rel_name_by_id.get(&js.left_rel) else {
378 continue;
379 };
380 let Some(right_pred) = rel_name_by_id.get(&js.right_rel) else {
381 continue;
382 };
383 let Some(&left_id) = self.lowerer.rel_ids().get(*left_pred) else {
384 continue;
385 };
386 let Some(&right_id) = self.lowerer.rel_ids().get(*right_pred) else {
387 continue;
388 };
389
390 let Some(left_schema) = self.lowerer.schemas().get(*left_pred) else {
391 continue;
392 };
393 let Some(right_schema) = self.lowerer.schemas().get(*right_pred) else {
394 continue;
395 };
396 if js.left_keys.iter().any(|&k| k >= left_schema.arity())
397 || js.right_keys.iter().any(|&k| k >= right_schema.arity())
398 {
399 continue;
400 }
401
402 mgr.set_join_selectivity(
403 left_id,
404 right_id,
405 js.left_keys.clone(),
406 js.right_keys.clone(),
407 js.selectivity,
408 );
409 }
410 }
411 }
412
413 let schemas_by_rel_id: HashMap<RelId, Schema> = self
415 .lowerer
416 .rel_ids()
417 .iter()
418 .filter_map(|(pred, rel_id)| {
419 self.lowerer
420 .schemas()
421 .get(pred)
422 .map(|schema| (*rel_id, schema.clone()))
423 })
424 .collect();
425
426 let stats_arc = Arc::new(mgr);
427
428 crate::optimizer::helper_split_pass::run(
429 &mut plan,
430 &schemas_by_rel_id,
431 &stats_arc,
432 |schema| self.lowerer.create_helper_relation(schema),
433 );
434
435 let schemas_by_rel_id: HashMap<RelId, Schema> = self
436 .lowerer
437 .rel_ids()
438 .iter()
439 .filter_map(|(pred, rel_id)| {
440 self.lowerer
441 .schemas()
442 .get(pred)
443 .map(|schema| (*rel_id, schema.clone()))
444 })
445 .collect();
446
447 let mut optimizer = Optimizer::new(Arc::clone(&stats_arc));
448 optimizer.set_schemas(schemas_by_rel_id);
449 for rules in &mut plan.rules_by_scc {
450 for rule in rules {
451 let body = std::mem::replace(&mut rule.body, xlog_ir::RirNode::Unit);
452 rule.body = optimizer.optimize(body);
453 }
454 }
455
456 crate::optimizer::selectivity_pass::run(&mut plan, &stats_arc, self.lowerer.rel_ids());
465
466 crate::promote::promote_multiway(&mut plan, self.lowerer.rel_ids(), &stats_arc, config);
479
480 let schemas_by_rel_id: HashMap<RelId, Schema> = self
481 .lowerer
482 .rel_ids()
483 .iter()
484 .filter_map(|(pred, rel_id)| {
485 self.lowerer
486 .schemas()
487 .get(pred)
488 .map(|schema| (*rel_id, schema.clone()))
489 })
490 .collect();
491
492 crate::optimizer::helper_split_pass::run_kclique_specs(
493 &mut plan,
494 &schemas_by_rel_id,
495 |schema| self.lowerer.create_helper_relation(schema),
496 );
497
498 plan.generated_query_rules = generated_query_heads
499 .iter()
500 .enumerate()
501 .map(|(query_index, head)| {
502 let positions = plan
503 .rules_by_scc
504 .iter()
505 .enumerate()
506 .flat_map(|(scc_index, rules)| {
507 rules
508 .iter()
509 .enumerate()
510 .filter_map(move |(rule_index, rule)| {
511 (rule.head == *head).then_some((scc_index, rule_index))
512 })
513 })
514 .collect::<Vec<_>>();
515 let [(scc_index, rule_index)] = positions.as_slice() else {
516 return Err(XlogError::Compilation(format!(
517 "generated query head {head} must have exactly one compiled rule, found {}",
518 positions.len()
519 )));
520 };
521 Ok(GeneratedQueryRuleProvenance {
522 query_index,
523 scc_index: *scc_index,
524 rule_index: *rule_index,
525 })
526 })
527 .collect::<Result<Vec<_>>>()?;
528
529 Ok(plan)
530 }
531
532 pub fn reset(&mut self) {
537 self.lowerer = Lowerer::new();
538 }
539
540 pub fn rel_ids(&self) -> &HashMap<String, RelId> {
545 self.lowerer.rel_ids()
546 }
547
548 pub fn schemas(&self) -> &HashMap<String, Schema> {
552 self.lowerer.schemas()
553 }
554}
555
556fn generated_query_heads_for_program(program: &Program) -> Result<Vec<String>> {
557 (0..program.queries.len())
558 .map(|query_index| {
559 let generated_head = format!("__xlog_query_{query_index}");
560 let authored_collision = program
561 .predicates
562 .iter()
563 .any(|declaration| declaration.name == generated_head)
564 || program
565 .rules
566 .iter()
567 .any(|rule| rule.head.predicate == generated_head)
568 || program
569 .learnable_rules
570 .iter()
571 .any(|rule| rule.head.predicate == generated_head);
572 if authored_collision {
573 return Err(XlogError::Compilation(format!(
574 "authored relation {generated_head} collides with generated query head"
575 )));
576 }
577 Ok(generated_head)
578 })
579 .collect()
580}
581
582fn run_frontend_passes(program: Program) -> Result<Program> {
586 let program = desugar_queries_and_constraints(program)?;
587 let program = normalize_meta_builtins_owned(program)?;
588 let program = normalize_list_builtins_owned(program)?;
589 let program = rewrite_magic_sets_owned(program)?.program;
590 validate_negation_safety(&program)?;
591 Ok(program)
592}
593
594fn desugar_queries_and_constraints(program: Program) -> Result<Program> {
595 let mut out = program;
596
597 for constraint in &out.constraints {
599 let authored_index = constraint.require_authored_index()?;
600 let pred = format!("__xlog_constraint_{authored_index}");
601 out.rules.push(AstRule {
602 head: crate::ast::Atom {
603 predicate: pred,
604 terms: vec![Term::Integer(1)],
605 },
606 body: constraint.body.clone(),
607 });
608 }
609
610 for (i, Query { atom }) in out.queries.iter().enumerate() {
612 let pred = format!("__xlog_query_{}", i);
613
614 let mut head_terms: Vec<Term> = Vec::new();
615 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
616
617 for term in &atom.terms {
618 for name in term.variables() {
619 if seen.insert(name) {
620 head_terms.push(Term::Variable(name.to_string()));
621 }
622 }
623 }
624
625 if head_terms.is_empty() {
626 head_terms.push(Term::Integer(1));
627 }
628
629 out.rules.push(AstRule {
630 head: crate::ast::Atom {
631 predicate: pred,
632 terms: head_terms,
633 },
634 body: vec![BodyLiteral::Positive(atom.clone())],
635 });
636 }
637
638 Ok(out)
639}
640
641fn validate_negation_safety(program: &Program) -> Result<()> {
642 for rule in &program.rules {
643 validate_body_naf_safety(&rule.body, &format!("rule {}", rule.head.predicate))?;
644 }
645 for constraint in &program.constraints {
646 validate_body_naf_safety(
647 &constraint.body,
648 &format!("constraint {}", constraint.require_authored_index()?),
649 )?;
650 }
651 for (idx, learnable) in program.learnable_rules.iter().enumerate() {
652 validate_body_naf_safety(&learnable.body, &format!("learnable rule {}", idx))?;
653 }
654 Ok(())
655}
656
657fn validate_body_naf_safety(body: &[BodyLiteral], context: &str) -> Result<()> {
658 let mut bound: HashSet<String> = HashSet::new();
659 for lit in body {
660 match lit {
661 BodyLiteral::Positive(atom) => {
662 for name in atom.variables() {
663 bound.insert(name.to_string());
664 }
665 }
666 BodyLiteral::Negated(atom) => {
667 for name in atom.variables() {
668 if !bound.contains(name) {
669 return Err(naf_error(format!(
670 "unbound variable {} in negated atom {}/{} in {}; bind it before not with a positive atom or deterministic is expression, or use '_' for existential positions",
671 name,
672 atom.predicate,
673 atom.arity(),
674 context
675 )));
676 }
677 }
678 }
679 BodyLiteral::IsExpr(is_expr) => {
680 bound.insert(is_expr.target.clone());
681 }
682 BodyLiteral::Epistemic(_) => {}
683 BodyLiteral::Comparison(_) | BodyLiteral::Univ(_) => {}
684 }
685 }
686 Ok(())
687}
688
689fn map_stratification_to_naf_error(err: XlogError) -> XlogError {
690 match err {
691 XlogError::StratificationCycle(cycle) => naf_error(format!(
692 "deterministic not atom must be stratified; cycle through negation or aggregation: {}",
693 cycle.join(" -> ")
694 )),
695 other => other,
696 }
697}
698
699fn naf_error(message: impl Into<String>) -> XlogError {
700 XlogError::Compilation(format!("negation safety error: {}", message.into()))
701}
702
703pub fn compile(source: &str) -> Result<ExecutionPlan> {
718 let mut compiler = Compiler::new();
719 compiler.compile(source)
720}
721
722pub fn load_modules(
738 entry_file: &Path,
739 search_paths: Vec<PathBuf>,
740) -> std::result::Result<ModuleResolver, ModuleError> {
741 let mut resolver = ModuleResolver::new(search_paths);
742 resolver.load_entry_file(entry_file)?;
743
744 Ok(resolver)
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750 use xlog_core::ScalarType;
751 use xlog_ir::RirNode;
752 use xlog_stats::ColumnStats;
753 use xlog_stats::RelationStats;
754 use xlog_stats::StatsManager;
755
756 #[test]
757 fn test_compiler_new() {
758 let compiler = Compiler::new();
759 drop(compiler);
761 }
762
763 #[test]
764 fn test_compile_fact() {
765 let mut compiler = Compiler::new();
766 let result = compiler.compile("edge(1, 2).");
767 assert!(result.is_ok(), "Failed to compile fact: {:?}", result.err());
768 }
769
770 #[test]
771 fn test_compile_simple_rule() {
772 let mut compiler = Compiler::new();
773 let result = compiler.compile(
774 r#"
775 edge(1, 2).
776 reach(X, Y) :- edge(X, Y).
777 "#,
778 );
779 assert!(
780 result.is_ok(),
781 "Failed to compile simple rule: {:?}",
782 result.err()
783 );
784
785 let plan = result.unwrap();
786 assert!(!plan.sccs.is_empty(), "Expected at least one SCC");
787 }
788
789 #[test]
790 fn compiler_records_each_program_query_at_its_compiled_rule_position() {
791 let plan = Compiler::new()
792 .compile(
793 r#"
794 source(a).
795 ?- source(X).
796 ?- source(a).
797 "#,
798 )
799 .expect("queries should compile");
800
801 assert_eq!(plan.generated_query_rules.len(), 2);
802 for (query_index, provenance) in plan.generated_query_rules.iter().enumerate() {
803 assert_eq!(provenance.query_index, query_index);
804 let rule = &plan.rules_by_scc[provenance.scc_index][provenance.rule_index];
805 assert_eq!(rule.head, format!("__xlog_query_{query_index}"));
806 }
807 }
808
809 #[test]
810 fn compiler_does_not_infer_query_provenance_from_an_authored_prefix() {
811 let mut program = parse_program(
812 r#"
813 source(a).
814 authored(X) :- source(X).
815 "#,
816 )
817 .expect("authored program should parse");
818 program
819 .rules
820 .iter_mut()
821 .find(|rule| rule.head.predicate == "authored")
822 .expect("authored rule should exist")
823 .head
824 .predicate = "__xlog_query_authored".to_string();
825
826 let plan = Compiler::new()
827 .compile_program(&program)
828 .expect("authored prefix should remain an ordinary rule");
829 assert!(plan.generated_query_rules.is_empty());
830 }
831
832 #[test]
833 fn compiler_rejects_an_authored_exact_generated_query_head_collision() {
834 let mut program = parse_program(
835 r#"
836 source(a).
837 authored(X) :- source(X).
838 ?- source(X).
839 "#,
840 )
841 .expect("collision witness should parse");
842 program
843 .rules
844 .iter_mut()
845 .find(|rule| rule.head.predicate == "authored")
846 .expect("authored rule should exist")
847 .head
848 .predicate = "__xlog_query_0".to_string();
849
850 let error = Compiler::new()
851 .compile_program(&program)
852 .expect_err("authored exact query-head collisions must fail compilation");
853 assert!(
854 error
855 .to_string()
856 .contains("authored relation __xlog_query_0 collides with generated query head"),
857 "unexpected collision error: {error}"
858 );
859 }
860
861 #[test]
862 fn compiler_reports_bound_is_target_before_arithmetic_type_mismatch() {
863 let error = Compiler::new()
864 .compile(
865 r#"
866 val(10).
867 bad(Z) :- val(Z), Z is Z + 1.
868 ?- bad(Z).
869 "#,
870 )
871 .expect_err("an already-bound is-expression target must be rejected");
872
873 assert_eq!(
874 error.to_string(),
875 "Compilation error: Variable Z already bound; 'is' requires fresh variable"
876 );
877 }
878
879 #[test]
880 fn compiler_rejects_is_target_bound_by_an_earlier_is_expression() {
881 let error = Compiler::new()
882 .compile(
883 r#"
884 val(10).
885 bad(Z) :- val(X), Z is X + X, Z is X + X.
886 ?- bad(Z).
887 "#,
888 )
889 .expect_err("a prior is-expression target must stay bound");
890
891 assert_eq!(
892 error.to_string(),
893 "Compilation error: Variable Z already bound; 'is' requires fresh variable"
894 );
895 }
896
897 #[test]
898 fn compiler_accepts_fresh_chained_is_targets() {
899 Compiler::new()
900 .compile(
901 r#"
902 val(10).
903 good(Z) :- val(X), Y is X + X, Z is Y + Y.
904 ?- good(Z).
905 "#,
906 )
907 .expect("distinct source-ordered is-expression targets must remain valid");
908 }
909
910 #[test]
911 fn compiler_reports_unbound_is_input_before_a_later_duplicate_target() {
912 let error = Compiler::new()
913 .compile(
914 r#"
915 seed(1).
916 bad(Z) :- seed(X), Z is U + 1, Z is 1 + 1.
917 ?- bad(Z).
918 "#,
919 )
920 .expect_err("an unreachable later binding must not preempt the first is error");
921
922 assert_eq!(
923 error.to_string(),
924 "Compilation error: Variable U used in arithmetic but not bound"
925 );
926 }
927
928 #[test]
929 fn test_compile_transitive_closure() {
930 let mut compiler = Compiler::new();
931 let result = compiler.compile(
932 r#"
933 edge(1, 2).
934 edge(2, 3).
935 edge(3, 4).
936 reach(X, Y) :- edge(X, Y).
937 reach(X, Z) :- reach(X, Y), edge(Y, Z).
938 "#,
939 );
940 assert!(result.is_ok(), "Failed to compile TC: {:?}", result.err());
941
942 let plan = result.unwrap();
943 assert!(!plan.sccs.is_empty());
945 }
946
947 #[test]
948 fn test_compile_with_negation() {
949 let mut compiler = Compiler::new();
950 let result = compiler.compile(
951 r#"
952 node(1).
953 node(2).
954 node(3).
955 edge(1, 2).
956 isolated(X) :- node(X), not edge(X, _).
957 "#,
958 );
959 assert!(
960 result.is_ok(),
961 "Failed to compile with negation: {:?}",
962 result.err()
963 );
964 }
965
966 #[test]
967 fn test_compile_with_comparison() {
968 let mut compiler = Compiler::new();
969 let result = compiler.compile(
970 r#"
971 value(1).
972 value(5).
973 value(10).
974 value(15).
975 small(X) :- value(X), X < 10.
976 "#,
977 );
978 assert!(
979 result.is_ok(),
980 "Failed to compile with comparison: {:?}",
981 result.err()
982 );
983 }
984
985 #[test]
986 fn test_schema_infers_from_rule_body_types() {
987 let mut compiler = Compiler::new();
988 let result = compiler.compile(
989 r#"
990 path(X, Y) :- reach(X, Y).
991 edge(1, 2).
992 edge(2, 3).
993 reach(X, Y) :- edge(X, Y).
994 "#,
995 );
996 assert!(
997 result.is_ok(),
998 "Failed to compile rule for schema inference: {:?}",
999 result.err()
1000 );
1001
1002 let schema = compiler
1003 .schemas()
1004 .get("reach")
1005 .expect("missing reach schema");
1006 assert_eq!(
1007 schema.column_type(0),
1008 Some(ScalarType::U32),
1009 "reach column 0 should match edge column type"
1010 );
1011 assert_eq!(
1012 schema.column_type(1),
1013 Some(ScalarType::U32),
1014 "reach column 1 should match edge column type"
1015 );
1016
1017 let schema = compiler.schemas().get("path").expect("missing path schema");
1018 assert_eq!(
1019 schema.column_type(0),
1020 Some(ScalarType::U32),
1021 "path column 0 should inherit the transitive body type"
1022 );
1023 assert_eq!(
1024 schema.column_type(1),
1025 Some(ScalarType::U32),
1026 "path column 1 should inherit the transitive body type"
1027 );
1028 }
1029
1030 #[test]
1031 fn test_schema_propagates_known_columns_when_a_sibling_column_is_unresolved() {
1032 let mut compiler = Compiler::new();
1033 let result = compiler.compile(
1034 r#"
1035 path(X) :- intermediate(X, Z).
1036 intermediate(X, Z) :- source(X), unresolved(Z).
1037 unresolved(Z) :- unresolved(Z).
1038 source(value).
1039 "#,
1040 );
1041 assert!(
1042 result.is_ok(),
1043 "Failed to compile partial rule-head schema inference: {:?}",
1044 result.err()
1045 );
1046
1047 let schema = compiler.schemas().get("path").expect("missing path schema");
1048 assert_eq!(
1049 schema.column_type(0),
1050 Some(ScalarType::Symbol),
1051 "path should inherit the known intermediate column independently of its sibling"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_schema_rejects_conflicting_rule_head_evidence_in_either_order() {
1057 let symbol_first = r#"
1058 pred symbols(symbol).
1059 pred integers(i64).
1060 symbols(value).
1061 integers(5000000000).
1062 mixed(X) :- symbols(X).
1063 mixed(X) :- integers(X).
1064 "#;
1065 let integer_first = r#"
1066 pred symbols(symbol).
1067 pred integers(i64).
1068 symbols(value).
1069 integers(5000000000).
1070 mixed(X) :- integers(X).
1071 mixed(X) :- symbols(X).
1072 "#;
1073
1074 for source in [symbol_first, integer_first] {
1075 let error = Compiler::new()
1076 .compile(source)
1077 .expect_err("conflicting inferred rule-head types must be rejected");
1078 let message = error.to_string();
1079 assert!(message.contains("mixed"), "{message}");
1080 assert!(message.contains("column 1"), "{message}");
1081 }
1082 }
1083
1084 #[test]
1085 fn test_schema_rejects_conflicting_body_evidence_in_either_order() {
1086 let first_body_order = r#"
1087 source_number(1).
1088 source_symbol(value).
1089 mixed(X) :- source_number(X), source_symbol(X).
1090 mixed(1) :- source_number(1).
1091 "#;
1092 let reversed_body_order = r#"
1093 source_number(1).
1094 source_symbol(value).
1095 mixed(X) :- source_symbol(X), source_number(X).
1096 mixed(1) :- source_number(1).
1097 "#;
1098
1099 for source in [first_body_order, reversed_body_order] {
1100 let error = Compiler::new()
1101 .compile(source)
1102 .expect_err("conflicting body-column types must be rejected");
1103 let message = error.to_string();
1104 assert!(message.contains("mixed"), "{message}");
1105 assert!(message.contains("variable X"), "{message}");
1106 }
1107 }
1108
1109 #[test]
1110 fn test_schema_infers_arithmetic_binding_result_type() {
1111 let mut compiler = Compiler::new();
1112 compiler
1113 .compile(
1114 r#"
1115 computed(X) :- X is cast(1, u64).
1116 "#,
1117 )
1118 .expect("an arithmetic binding should determine its head-column type");
1119
1120 let schema = compiler
1121 .schemas()
1122 .get("computed")
1123 .expect("missing computed schema");
1124 assert_eq!(schema.column_type(0), Some(ScalarType::U64));
1125 }
1126
1127 #[test]
1128 fn test_schema_rejects_arithmetic_and_atom_evidence_in_either_rule_order() {
1129 let arithmetic_first = r#"
1130 pred integers(i64).
1131 integers(5000000000).
1132 mixed(X) :- X is cast(1, u64).
1133 mixed(X) :- integers(X).
1134 "#;
1135 let atom_first = r#"
1136 pred integers(i64).
1137 integers(5000000000).
1138 mixed(X) :- integers(X).
1139 mixed(X) :- X is cast(1, u64).
1140 "#;
1141
1142 for source in [arithmetic_first, atom_first] {
1143 let error = Compiler::new()
1144 .compile(source)
1145 .expect_err("conflicting arithmetic and atom types must be rejected");
1146 let message = error.to_string();
1147 assert!(message.contains("mixed"), "{message}");
1148 assert!(message.contains("column 1"), "{message}");
1149 }
1150 }
1151
1152 #[test]
1153 fn test_schema_rejects_incompatible_constant_heads_before_runtime() {
1154 let sources = [
1155 r#"
1156 pred p(u32).
1157 seed().
1158 p(value) :- seed().
1159 "#,
1160 r#"
1161 p(1).
1162 p(value).
1163 "#,
1164 r#"
1165 p(value).
1166 p(1).
1167 "#,
1168 r#"
1169 p(1).
1170 p(5000000000).
1171 "#,
1172 r#"
1173 p(5000000000).
1174 p(1).
1175 "#,
1176 ];
1177
1178 for source in sources {
1179 let error = Compiler::new()
1180 .compile(source)
1181 .expect_err("incompatible constant head types must fail during compilation");
1182 let message = error.to_string();
1183 assert!(message.contains("p"), "{message}");
1184 assert!(message.contains("head term at position 0"), "{message}");
1185 }
1186 }
1187
1188 #[test]
1189 fn test_declared_scalar_schemas_accept_supported_literal_conversions() {
1190 Compiler::new()
1191 .compile(
1192 r#"
1193 pred signed(i64).
1194 pred wide(u64).
1195 pred real(f64).
1196 pred flag(bool).
1197 seed().
1198 signed(1).
1199 wide(1).
1200 real(1).
1201 real(2) :- seed().
1202 flag(true).
1203 flag(false) :- seed().
1204 "#,
1205 )
1206 .expect("representable literals should use declared scalar storage types");
1207 }
1208
1209 #[test]
1210 fn test_compile_unstratifiable_fails() {
1211 let mut compiler = Compiler::new();
1212 let result = compiler.compile(
1213 r#"
1214 p :- not q.
1215 q :- not p.
1216 "#,
1217 );
1218 assert!(result.is_err(), "Should fail with stratification cycle");
1219 }
1220
1221 #[test]
1222 fn test_compile_syntax_error_fails() {
1223 let mut compiler = Compiler::new();
1224 let result = compiler.compile("edge(1, 2"); assert!(result.is_err(), "Should fail with syntax error");
1226 }
1227
1228 #[test]
1229 fn test_compile_convenience_function() {
1230 let result = compile("edge(1, 2).");
1231 assert!(
1232 result.is_ok(),
1233 "Convenience compile failed: {:?}",
1234 result.err()
1235 );
1236 }
1237
1238 #[test]
1239 fn test_compiler_reset() {
1240 let mut compiler = Compiler::new();
1241
1242 let result1 = compiler.compile("edge(1, 2).");
1244 assert!(result1.is_ok());
1245
1246 compiler.reset();
1248 let result2 = compiler.compile("node(1). node(2).");
1249 assert!(result2.is_ok());
1250 }
1251
1252 #[test]
1253 fn test_compile_with_pred_decl() {
1254 let mut compiler = Compiler::new();
1255 let result = compiler.compile(
1256 r#"
1257 pred edge(u32, u32).
1258 edge(1, 2).
1259 edge(2, 3).
1260 reach(X, Y) :- edge(X, Y).
1261 "#,
1262 );
1263 assert!(
1264 result.is_ok(),
1265 "Failed to compile with pred decl: {:?}",
1266 result.err()
1267 );
1268 }
1269
1270 #[test]
1271 fn test_compile_multi_stratum() {
1272 let mut compiler = Compiler::new();
1273 let result = compiler.compile(
1274 r#"
1275 // Base facts
1276 edge(1, 2).
1277 edge(2, 3).
1278 edge(3, 1).
1279
1280 // Stratum 0: edge (base)
1281 // Stratum 1: reach (depends on edge, recursive)
1282 reach(X, Y) :- edge(X, Y).
1283 reach(X, Z) :- reach(X, Y), edge(Y, Z).
1284
1285 // Stratum 2: non_reach (negates reach)
1286 all_pairs(X, Y) :- edge(X, Z), edge(Y, W).
1287 non_reach(X, Y) :- all_pairs(X, Y), not reach(X, Y).
1288 "#,
1289 );
1290 assert!(
1291 result.is_ok(),
1292 "Failed to compile multi-stratum: {:?}",
1293 result.err()
1294 );
1295
1296 let plan = result.unwrap();
1297 assert!(!plan.strata.is_empty(), "Expected multiple strata");
1299 }
1300
1301 #[test]
1302 fn test_compile_aggregation() {
1303 let mut compiler = Compiler::new();
1304 let result = compiler.compile(
1305 r#"
1306 edge(1, 2).
1307 edge(1, 3).
1308 edge(2, 3).
1309 out_degree(X, count(Y)) :- edge(X, Y).
1310 "#,
1311 );
1312 assert!(
1313 result.is_ok(),
1314 "Failed to compile with aggregation: {:?}",
1315 result.err()
1316 );
1317
1318 let plan = result.unwrap();
1319 let out_degree_rules: Vec<_> = plan
1320 .rules_by_scc
1321 .iter()
1322 .flatten()
1323 .filter(|r| r.head == "out_degree")
1324 .collect();
1325 assert_eq!(out_degree_rules.len(), 1, "Expected one out_degree rule");
1326
1327 let body = &out_degree_rules[0].body;
1329 match body {
1330 RirNode::Project { input, .. } => {
1331 assert!(
1332 matches!(input.as_ref(), RirNode::GroupBy { .. }),
1333 "Expected Project(GroupBy(..)), got {:?}",
1334 input
1335 );
1336 }
1337 other => panic!("Expected Project(GroupBy(..)), got {:?}", other),
1338 }
1339 }
1340
1341 #[test]
1342 fn test_aggregate_schemas_match_runtime_result_types() {
1343 let mut compiler = Compiler::new();
1344 compiler
1345 .compile(
1346 r#"
1347 pred edge(u32, u64).
1348 pred score(u32, f64).
1349 pred counted(u32, u64).
1350 pred summed(u32, u64).
1351 pred minimum(u32, u64).
1352 pred maximum(u32, u64).
1353 pred combined(u32, f64).
1354 edge(1, 5000000000).
1355 score(1, 2.5).
1356 counted(X, count(Y)) :- edge(X, Y).
1357 summed(X, sum(Y)) :- edge(X, Y).
1358 minimum(X, min(Y)) :- edge(X, Y).
1359 maximum(X, max(Y)) :- edge(X, Y).
1360 combined(X, logsumexp(Y)) :- score(X, Y).
1361 "#,
1362 )
1363 .expect("aggregate declarations should match provider result schemas");
1364
1365 for predicate in ["counted", "summed", "minimum", "maximum"] {
1366 assert_eq!(
1367 compiler
1368 .schemas()
1369 .get(predicate)
1370 .expect("missing aggregate schema")
1371 .column_type(1),
1372 Some(ScalarType::U64),
1373 "unexpected result type for {predicate}"
1374 );
1375 }
1376 assert_eq!(
1377 compiler
1378 .schemas()
1379 .get("combined")
1380 .expect("missing log-sum-exp schema")
1381 .column_type(1),
1382 Some(ScalarType::F64)
1383 );
1384 }
1385
1386 #[test]
1387 fn test_undeclared_count_schema_is_u64() {
1388 let mut compiler = Compiler::new();
1389 compiler
1390 .compile(
1391 r#"
1392 edge(1, 5000000000).
1393 degree(X, count(Y)) :- edge(X, Y).
1394 "#,
1395 )
1396 .expect("undeclared count result should compile with its runtime type");
1397 assert_eq!(
1398 compiler
1399 .schemas()
1400 .get("degree")
1401 .expect("missing inferred count schema")
1402 .column_type(1),
1403 Some(ScalarType::U64)
1404 );
1405 }
1406
1407 #[test]
1408 fn test_unsupported_aggregate_inputs_are_rejected_before_runtime() {
1409 let fixtures = [
1410 r#"
1411 pred source(u32, i32).
1412 pred result(u32, u64).
1413 source(1, 2).
1414 result(X, sum(Y)) :- source(X, Y).
1415 "#,
1416 r#"
1417 pred source(u32, u32).
1418 pred result(u32, f64).
1419 source(1, 2).
1420 result(X, logsumexp(Y)) :- source(X, Y).
1421 "#,
1422 ];
1423
1424 for source in fixtures {
1425 let error = Compiler::new()
1426 .compile(source)
1427 .expect_err("unsupported aggregate input must fail during compilation");
1428 let message = error.to_string();
1429 assert!(message.contains("Unsupported aggregate input"), "{message}");
1430 assert!(message.contains("execution provider requires"), "{message}");
1431 }
1432 }
1433
1434 #[test]
1435 fn test_compile_with_stats_snapshot() {
1436 let mut compiler = Compiler::new();
1437 let source = r#"
1438 edge(1, 2).
1439 edge(2, 3).
1440 reach(X, Y) :- edge(X, Y).
1441 "#;
1442
1443 let _ = compiler.compile(source).expect("Initial compile failed");
1444 let edge_id = *compiler.rel_ids().get("edge").expect("edge rel_id missing");
1445
1446 let mut mgr = StatsManager::new();
1447 mgr.register_relation(edge_id);
1448 mgr.update_cardinality(edge_id, 42);
1449 let snapshot = mgr.snapshot();
1450
1451 let plan = compiler
1452 .compile_with_stats_snapshot(source, Some(&snapshot))
1453 .expect("Compile with snapshot failed");
1454 assert!(!plan.sccs.is_empty());
1455 }
1456
1457 #[test]
1458 fn test_compile_with_named_stats_snapshot_reorders_joins() {
1459 let mut compiler = Compiler::new();
1460 let source = r#"
1461 foo(1).
1462 edge(1).
1463 out(X) :- edge(X), foo(X).
1464 "#;
1465
1466 let mut edge_stats = RelationStats::new(RelId(0));
1469 edge_stats.update_cardinality(10);
1470 let mut foo_stats = RelationStats::new(RelId(1));
1471 foo_stats.update_cardinality(10_000);
1472
1473 let snapshot = StatsSnapshot {
1474 relations: vec![edge_stats, foo_stats],
1475 join_selectivities: Vec::new(),
1476 rel_names: vec![
1477 (RelId(0), "edge".to_string()),
1478 (RelId(1), "foo".to_string()),
1479 ],
1480 };
1481
1482 let plan = compiler
1483 .compile_with_stats_snapshot(source, Some(&snapshot))
1484 .expect("Compile with named snapshot failed");
1485
1486 let foo_id = *compiler.rel_ids().get("foo").expect("foo rel_id missing");
1487 let edge_id = *compiler.rel_ids().get("edge").expect("edge rel_id missing");
1488
1489 let out_rule = plan
1490 .rules_by_scc
1491 .iter()
1492 .flatten()
1493 .find(|r| r.head == "out")
1494 .expect("out rule missing");
1495
1496 let mut node = &out_rule.body;
1498 while let RirNode::Project { input, .. } = node {
1499 node = input;
1500 }
1501
1502 match node {
1503 RirNode::ChainJoin {
1504 left,
1505 right,
1506 fallback,
1507 ..
1508 } => {
1509 assert!(matches!(**left, RirNode::Scan { rel } if rel == foo_id));
1513 assert!(matches!(**right, RirNode::Scan { rel } if rel == edge_id));
1514
1515 let mut fallback_node = fallback.as_ref();
1516 while let RirNode::Project { input, .. } = fallback_node {
1517 fallback_node = input;
1518 }
1519 match fallback_node {
1520 RirNode::Join { left, right, .. } => {
1521 assert!(matches!(**left, RirNode::Scan { rel } if rel == foo_id));
1522 assert!(matches!(**right, RirNode::Scan { rel } if rel == edge_id));
1523 }
1524 other => panic!("Expected ChainJoin fallback Join node, got {:?}", other),
1525 }
1526 }
1527 RirNode::Join { left, right, .. } => {
1528 assert!(matches!(**left, RirNode::Scan { rel } if rel == foo_id));
1530 assert!(matches!(**right, RirNode::Scan { rel } if rel == edge_id));
1531 }
1532 other => panic!("Expected Join node, got {:?}", other),
1533 }
1534 }
1535
1536 fn helper_split_source() -> &'static str {
1537 r#"
1538 ab(0, 0). bc(0, 0). cd(0, 0). de(0, 0). ef(0, 0). af(0, 0).
1539 out(A, B, C, D, F) :-
1540 ab(A, B),
1541 bc(B, C),
1542 cd(C, D),
1543 de(D, E),
1544 ef(E, F),
1545 af(A, F).
1546 "#
1547 }
1548
1549 fn helper_split_snapshot(distinct_d: u64) -> StatsSnapshot {
1550 let mut snapshot_relations = Vec::new();
1551 for (idx, name) in ["ab", "bc", "cd", "de", "ef", "af"].iter().enumerate() {
1552 let mut rel_stats = RelationStats::new(RelId(idx as u32));
1553 rel_stats.update_cardinality(8192);
1554 if *name == "de" {
1555 let mut d_col = ColumnStats::new(0, ScalarType::U32);
1556 d_col.update_distinct(distinct_d);
1557 rel_stats.add_column(d_col);
1558 }
1559 snapshot_relations.push(rel_stats);
1560 }
1561 StatsSnapshot {
1562 relations: snapshot_relations,
1563 join_selectivities: Vec::new(),
1564 rel_names: ["ab", "bc", "cd", "de", "ef", "af"]
1565 .iter()
1566 .enumerate()
1567 .map(|(idx, name)| (RelId(idx as u32), (*name).to_string()))
1568 .collect(),
1569 }
1570 }
1571
1572 #[test]
1573 fn test_compile_with_named_stats_snapshot_creates_helper_relation() {
1574 let mut compiler = Compiler::new();
1575 let snapshot = helper_split_snapshot(1);
1576 let plan = compiler
1577 .compile_with_stats_snapshot(helper_split_source(), Some(&snapshot))
1578 .expect("compile with helper stats");
1579 let helper = compiler
1580 .rel_ids()
1581 .iter()
1582 .find_map(|(name, rel)| {
1583 name.starts_with("__kclique_helper_")
1584 .then_some((name.clone(), *rel))
1585 })
1586 .expect("helper relation allocated");
1587
1588 let helper_rule_count = plan
1589 .rules_by_scc
1590 .iter()
1591 .flatten()
1592 .filter(|rule| rule.head == helper.0)
1593 .count();
1594 assert_eq!(helper_rule_count, 1);
1595
1596 let helper_rule = plan
1597 .rules_by_scc
1598 .iter()
1599 .flatten()
1600 .find(|rule| rule.head == helper.0)
1601 .expect("helper rule");
1602 assert!(
1603 matches!(helper_rule.body, RirNode::ChainJoin { .. }),
1604 "helper split output should be eligible for ChainJoin promotion"
1605 );
1606
1607 let out_rule = plan
1608 .rules_by_scc
1609 .iter()
1610 .flatten()
1611 .find(|rule| rule.head == "out")
1612 .expect("out rule");
1613 assert!(contains_scan(&out_rule.body, helper.1));
1614 }
1615
1616 #[test]
1617 fn test_compile_with_flat_named_stats_keeps_original_rule() {
1618 let mut compiler = Compiler::new();
1619 let snapshot = helper_split_snapshot(8192);
1620 let plan = compiler
1621 .compile_with_stats_snapshot(helper_split_source(), Some(&snapshot))
1622 .expect("compile with flat stats");
1623
1624 assert!(!compiler
1625 .rel_ids()
1626 .keys()
1627 .any(|name| name.starts_with("__kclique_helper_")));
1628 let out_rules = plan
1629 .rules_by_scc
1630 .iter()
1631 .flatten()
1632 .filter(|rule| rule.head == "out")
1633 .count();
1634 assert_eq!(out_rules, 1);
1635 }
1636
1637 fn contains_scan(node: &RirNode, rel: RelId) -> bool {
1638 match node {
1639 RirNode::Scan { rel: scan_rel } => *scan_rel == rel,
1640 RirNode::Join { left, right, .. } | RirNode::ChainJoin { left, right, .. } => {
1641 contains_scan(left, rel) || contains_scan(right, rel)
1642 }
1643 RirNode::Project { input, .. }
1644 | RirNode::Filter { input, .. }
1645 | RirNode::Distinct { input, .. }
1646 | RirNode::GroupBy { input, .. } => contains_scan(input, rel),
1647 RirNode::Union { inputs } => inputs.iter().any(|input| contains_scan(input, rel)),
1648 RirNode::Diff { left, right } => contains_scan(left, rel) || contains_scan(right, rel),
1649 RirNode::Fixpoint {
1650 base, recursive, ..
1651 } => contains_scan(base, rel) || contains_scan(recursive, rel),
1652 RirNode::MultiWayJoin { inputs, .. } => {
1653 inputs.iter().any(|input| contains_scan(input, rel))
1654 }
1655 RirNode::TensorMaskedJoin { rel_index, .. } => {
1656 rel_index.iter().any(|(input_rel, _)| *input_rel == rel)
1657 }
1658 RirNode::Unit => false,
1659 }
1660 }
1661}