1use std::collections::{BTreeMap, HashMap};
4use std::hash::{Hash, Hasher};
5use std::sync::Arc;
6
7use xlog_core::{symbol, Result, ScalarType, Schema, XlogError};
8use xlog_logic::ast::{
9 AggExpr, AggOp, ArithExpr, Atom, BodyLiteral, CompOp, Evidence, ProbQuery, Program, Rule, Term,
10};
11use xlog_logic::stratify::{
12 analyze_stratification, build_dependency_graph, find_sccs_for_lowering, stratify,
13};
14use xlog_logic::{
15 compare_arithmetic_values, evaluate_arithmetic_expression, ArithmeticValue, Lowerer,
16};
17
18use crate::wfs::{evaluate_wfs_rules, WfsAtom, WfsConfig, WfsLiteral, WfsRule};
19
20use crate::aggregates::{AggState, AggStateKey};
21use crate::pir::{ChoiceVarId, LeafId, PirGraph, PirNodeId};
22
23#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub enum Value {
26 I64(i64),
28 F64(u64),
30 Symbol(u32),
32 String(String),
34}
35
36type ProvenanceBinding = HashMap<String, Value>;
37type ArithmeticBinding = HashMap<String, ArithmeticValue>;
38type ProvenanceEvaluationState = (ProvenanceBinding, ArithmeticBinding, PirNodeId);
39
40impl From<i64> for Value {
41 fn from(v: i64) -> Self {
42 Self::I64(v)
43 }
44}
45
46impl From<u32> for Value {
47 fn from(v: u32) -> Self {
48 Self::Symbol(v)
49 }
50}
51
52impl From<String> for Value {
53 fn from(v: String) -> Self {
54 Self::String(v)
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
59pub struct GroundAtom {
61 pub predicate: String,
63 pub args: Vec<Value>,
65}
66
67impl GroundAtom {
68 pub fn new(predicate: impl Into<String>, args: Vec<Value>) -> Self {
70 Self {
71 predicate: predicate.into(),
72 args,
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq)]
79pub struct ChoiceSource {
80 pub choices: Arc<[(GroundAtom, f64)]>,
86 pub choice_index: usize,
88 pub source_id: Option<usize>,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum AggregateLiftStatus {
95 Fired,
97 FallbackExactEnumeration,
99 Declined,
101}
102
103impl AggregateLiftStatus {
104 pub fn as_str(self) -> &'static str {
106 match self {
107 AggregateLiftStatus::Fired => "fired",
108 AggregateLiftStatus::FallbackExactEnumeration => "fallback_exact_enumeration",
109 AggregateLiftStatus::Declined => "declined",
110 }
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct AggregateLiftReport {
117 pub predicate: String,
119 pub group_key: Vec<Value>,
121 pub operator: String,
123 pub finite_domain_source: String,
125 pub deterministic_rows: usize,
127 pub uncertain_rows: usize,
129 pub domain_size: usize,
131 pub cap: usize,
133 pub status: AggregateLiftStatus,
135 pub reason: String,
137 pub naive_outcomes: u128,
139 pub dynamic_programming_states: usize,
141}
142
143#[derive(Debug, Clone)]
144struct Relation {
145 tuples: BTreeMap<Vec<Value>, PirNodeId>,
146}
147
148impl Relation {
149 fn new() -> Self {
150 Self {
151 tuples: BTreeMap::new(),
152 }
153 }
154
155 fn get(&self, tuple: &[Value]) -> Option<PirNodeId> {
156 self.tuples.get(tuple).copied()
157 }
158
159 fn is_empty(&self) -> bool {
160 self.tuples.is_empty()
161 }
162
163 fn insert_or(&mut self, tuple: Vec<Value>, formula: PirNodeId, builder: &mut PirBuilder) {
164 let entry = self
165 .tuples
166 .entry(tuple)
167 .or_insert_with(|| builder.const_false());
168 *entry = builder.or(vec![*entry, formula]);
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173enum PirKey {
174 Const(bool),
175 Lit(LeafId),
176 NegLit(LeafId),
177 And(Vec<PirNodeId>),
178 Or(Vec<PirNodeId>),
179 Decision {
180 var: ChoiceVarId,
181 child_false: PirNodeId,
182 child_true: PirNodeId,
183 },
184}
185
186impl Hash for PirKey {
187 fn hash<H: Hasher>(&self, state: &mut H) {
188 match self {
189 PirKey::Const(b) => {
190 0u8.hash(state);
191 b.hash(state);
192 }
193 PirKey::Lit(l) => {
194 1u8.hash(state);
195 l.hash(state);
196 }
197 PirKey::NegLit(l) => {
198 5u8.hash(state);
199 l.hash(state);
200 }
201 PirKey::And(children) => {
202 2u8.hash(state);
203 children.hash(state);
204 }
205 PirKey::Or(children) => {
206 3u8.hash(state);
207 children.hash(state);
208 }
209 PirKey::Decision {
210 var,
211 child_false,
212 child_true,
213 } => {
214 4u8.hash(state);
215 var.hash(state);
216 child_false.hash(state);
217 child_true.hash(state);
218 }
219 }
220 }
221}
222
223#[derive(Debug)]
224struct PirBuilder {
225 pir: PirGraph,
226 intern: HashMap<PirKey, PirNodeId>,
227 const_true: PirNodeId,
228 const_false: PirNodeId,
229 or_children: HashMap<PirNodeId, Vec<PirNodeId>>,
232 and_children: HashMap<PirNodeId, Vec<PirNodeId>>,
234}
235
236impl PirBuilder {
237 fn new() -> Self {
238 let mut pir = PirGraph::new();
239 let const_true = pir.const_true();
240 let const_false = pir.const_false();
241
242 let mut intern = HashMap::new();
243 intern.insert(PirKey::Const(true), const_true);
244 intern.insert(PirKey::Const(false), const_false);
245
246 Self {
247 pir,
248 intern,
249 const_true,
250 const_false,
251 or_children: HashMap::new(),
252 and_children: HashMap::new(),
253 }
254 }
255
256 fn finish(self) -> PirGraph {
257 self.pir
258 }
259
260 fn const_true(&self) -> PirNodeId {
261 self.const_true
262 }
263
264 fn const_false(&self) -> PirNodeId {
265 self.const_false
266 }
267
268 fn lit(&mut self, leaf: LeafId) -> PirNodeId {
269 let key = PirKey::Lit(leaf);
270 if let Some(&id) = self.intern.get(&key) {
271 return id;
272 }
273 let id = self.pir.lit(leaf);
274 self.intern.insert(key, id);
275 id
276 }
277
278 fn neg_lit(&mut self, leaf: LeafId) -> PirNodeId {
279 let key = PirKey::NegLit(leaf);
280 if let Some(&id) = self.intern.get(&key) {
281 return id;
282 }
283 let id = self.pir.neg_lit(leaf);
284 self.intern.insert(key, id);
285 id
286 }
287
288 fn and(&mut self, children: Vec<PirNodeId>) -> PirNodeId {
289 let mut flat: Vec<PirNodeId> = Vec::with_capacity(children.len());
292 for c in children {
293 match self.and_children.get(&c) {
294 Some(sub) => flat.extend_from_slice(sub),
295 None => flat.push(c),
296 }
297 }
298 let mut children = flat;
299 children.retain(|&c| c != self.const_true);
300 if children.contains(&self.const_false) {
301 return self.const_false;
302 }
303 if children.is_empty() {
304 return self.const_true;
305 }
306 if children.len() == 1 {
307 return children[0];
308 }
309 children.sort_by_key(|id| id.as_u32());
310 children.dedup();
311 if children.len() > 1 {
313 let members = children.clone();
314 children.retain(|c| match self.or_children.get(c) {
315 Some(sub) => !sub.iter().any(|s| {
316 s != c
317 && members
318 .binary_search_by_key(&s.as_u32(), |m| m.as_u32())
319 .is_ok()
320 }),
321 None => true,
322 });
323 }
324 if children.len() == 1 {
325 return children[0];
326 }
327 let key = PirKey::And(children.clone());
328 if let Some(&id) = self.intern.get(&key) {
329 return id;
330 }
331 let id = self.pir.and(children.clone());
332 self.intern.insert(key, id);
333 self.and_children.insert(id, children);
334 id
335 }
336
337 fn or(&mut self, children: Vec<PirNodeId>) -> PirNodeId {
338 let mut flat: Vec<PirNodeId> = Vec::with_capacity(children.len());
340 for c in children {
341 match self.or_children.get(&c) {
342 Some(sub) => flat.extend_from_slice(sub),
343 None => flat.push(c),
344 }
345 }
346 let mut children = flat;
347 children.retain(|&c| c != self.const_false);
348 if children.contains(&self.const_true) {
349 return self.const_true;
350 }
351 if children.is_empty() {
352 return self.const_false;
353 }
354 if children.len() == 1 {
355 return children[0];
356 }
357 children.sort_by_key(|id| id.as_u32());
358 children.dedup();
359 if children.len() > 1 {
361 let members = children.clone();
362 children.retain(|c| match self.and_children.get(c) {
363 Some(sub) => !sub.iter().any(|s| {
364 s != c
365 && members
366 .binary_search_by_key(&s.as_u32(), |m| m.as_u32())
367 .is_ok()
368 }),
369 None => true,
370 });
371 }
372 if children.len() == 1 {
373 return children[0];
374 }
375 let key = PirKey::Or(children.clone());
376 if let Some(&id) = self.intern.get(&key) {
377 return id;
378 }
379 let id = self.pir.or(children.clone());
380 self.intern.insert(key, id);
381 self.or_children.insert(id, children);
382 id
383 }
384
385 fn decision(
386 &mut self,
387 var: ChoiceVarId,
388 child_false: PirNodeId,
389 child_true: PirNodeId,
390 ) -> PirNodeId {
391 if child_false == child_true {
392 return child_true;
393 }
394 let key = PirKey::Decision {
395 var,
396 child_false,
397 child_true,
398 };
399 if let Some(&id) = self.intern.get(&key) {
400 return id;
401 }
402 let id = self.pir.decision(var, child_false, child_true);
403 self.intern.insert(key, id);
404 id
405 }
406
407 fn choice_lit(&mut self, var: ChoiceVarId, is_true: bool) -> PirNodeId {
408 if is_true {
409 self.decision(var, self.const_false(), self.const_true())
410 } else {
411 self.decision(var, self.const_true(), self.const_false())
412 }
413 }
414}
415
416#[derive(Debug)]
418pub struct Provenance {
419 pub pir: PirGraph,
421 pub leaf_probs: BTreeMap<LeafId, f64>,
423 pub choice_probs: BTreeMap<ChoiceVarId, (f64, f64)>,
425 tuple_formulas: BTreeMap<GroundAtom, PirNodeId>,
426 pub queries: Vec<GroundAtom>,
428 pub evidence: Vec<(GroundAtom, bool)>,
430 pub leaf_atoms: BTreeMap<LeafId, GroundAtom>,
432 pub choice_sources: BTreeMap<ChoiceVarId, ChoiceSource>,
434 pub aggregate_lifting: Vec<AggregateLiftReport>,
436 schemas: HashMap<String, Schema>,
437}
438
439impl Provenance {
440 pub fn query_formula(&self, predicate: &str, args: &[Value]) -> Option<PirNodeId> {
442 let atom = self
443 .canonical_atom(&GroundAtom::new(predicate, args.to_vec()))
444 .ok()?;
445 self.tuple_formulas.get(&atom).copied()
446 }
447
448 pub(crate) fn canonical_atom(&self, atom: &GroundAtom) -> Result<GroundAtom> {
449 let args = canonicalize_public_values(&atom.predicate, &atom.args, &self.schemas)?;
450 Ok(GroundAtom::new(atom.predicate.clone(), args))
451 }
452
453 pub fn leaf_atom(&self, leaf: LeafId) -> Option<&GroundAtom> {
455 self.leaf_atoms.get(&leaf)
456 }
457
458 pub fn choice_source(&self, var: ChoiceVarId) -> Option<&ChoiceSource> {
460 self.choice_sources.get(&var)
461 }
462
463 pub fn atoms_with_formulas(&self) -> impl Iterator<Item = (&GroundAtom, PirNodeId)> + '_ {
470 self.tuple_formulas.iter().map(|(atom, &id)| (atom, id))
471 }
472}
473
474pub fn extract_from_source(source: &str) -> Result<Provenance> {
476 let program = xlog_logic::parse_program(source)?;
477 extract_from_program(&program)
478}
479
480pub(crate) fn arithmetic_schemas(program: &Program) -> Result<HashMap<String, Schema>> {
481 let mut lowerer = Lowerer::new();
482 lowerer.infer_and_validate_schemas(program)?;
483 let mut schemas = lowerer.schemas().clone();
484 for Evidence { atom, .. } in &program.evidence {
485 ensure_ground_atom_schema(atom, &mut schemas);
486 }
487 for ProbQuery { atom } in &program.prob_queries {
488 ensure_ground_atom_schema(atom, &mut schemas);
489 }
490 Ok(schemas)
491}
492
493fn ensure_ground_atom_schema(atom: &Atom, schemas: &mut HashMap<String, Schema>) {
494 schemas.entry(atom.predicate.clone()).or_insert_with(|| {
495 Schema::new(
496 atom.terms
497 .iter()
498 .enumerate()
499 .map(|(index, term)| (format!("c{index}"), term.inferred_scalar_type()))
500 .collect(),
501 )
502 });
503}
504
505pub(crate) fn canonicalize_probabilistic_program(
506 program: &Program,
507 schemas: &HashMap<String, Schema>,
508) -> Result<Program> {
509 let mut program = program.clone();
510
511 for rule in &mut program.rules {
512 canonicalize_atom_constants(&mut rule.head, schemas)?;
513 canonicalize_body_constants(&mut rule.body, schemas)?;
514 }
515 for fact in &mut program.prob_facts {
516 canonicalize_atom_constants(&mut fact.atom, schemas)?;
517 }
518 for disjunction in &mut program.annotated_disjunctions {
519 for choice in &mut disjunction.choices {
520 canonicalize_atom_constants(&mut choice.atom, schemas)?;
521 }
522 }
523 for evidence in &mut program.evidence {
524 canonicalize_atom_constants(&mut evidence.atom, schemas)?;
525 }
526 for query in &mut program.prob_queries {
527 canonicalize_atom_constants(&mut query.atom, schemas)?;
528 }
529
530 Ok(program)
531}
532
533fn canonicalize_body_constants(
534 body: &mut [BodyLiteral],
535 schemas: &HashMap<String, Schema>,
536) -> Result<()> {
537 for literal in body {
538 match literal {
539 BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
540 canonicalize_atom_constants(atom, schemas)?;
541 }
542 BodyLiteral::Epistemic(_)
543 | BodyLiteral::Comparison(_)
544 | BodyLiteral::IsExpr(_)
545 | BodyLiteral::Univ(_) => {}
546 }
547 }
548 Ok(())
549}
550
551fn canonicalize_atom_constants(atom: &mut Atom, schemas: &HashMap<String, Schema>) -> Result<()> {
552 let schema = schemas.get(&atom.predicate).ok_or_else(|| {
553 XlogError::Compilation(format!(
554 "Probabilistic value canonicalization requires a schema for predicate '{}'",
555 atom.predicate
556 ))
557 })?;
558 if schema.arity() != atom.terms.len() {
559 return Err(XlogError::Compilation(format!(
560 "Predicate '{}' has arity {}, but its inferred schema has arity {}",
561 atom.predicate,
562 atom.terms.len(),
563 schema.arity()
564 )));
565 }
566
567 for (index, term) in atom.terms.iter_mut().enumerate() {
568 if !matches!(
569 term,
570 Term::Integer(_) | Term::Float(_) | Term::String(_) | Term::Symbol(_)
571 ) {
572 continue;
573 }
574 let scalar_type = schema.column_type(index).ok_or_else(|| {
575 XlogError::Compilation(format!(
576 "Predicate '{}' has no type for column {}",
577 atom.predicate, index
578 ))
579 })?;
580 let value = ArithmeticValue::from_typed_term(term, scalar_type)?;
581 *term = term_from_public_value(provenance_value_from_arithmetic(value)?);
582 }
583 Ok(())
584}
585
586pub(crate) fn canonicalize_public_values(
587 predicate: &str,
588 args: &[Value],
589 schemas: &HashMap<String, Schema>,
590) -> Result<Vec<Value>> {
591 let schema = schemas.get(predicate).ok_or_else(|| {
592 XlogError::Compilation(format!(
593 "Probabilistic value canonicalization requires a schema for predicate '{predicate}'"
594 ))
595 })?;
596 if schema.arity() != args.len() {
597 return Err(XlogError::Compilation(format!(
598 "Predicate '{predicate}' has arity {}, but received {} values",
599 schema.arity(),
600 args.len()
601 )));
602 }
603
604 args.iter()
605 .enumerate()
606 .map(|(index, value)| {
607 let scalar_type = schema.column_type(index).ok_or_else(|| {
608 XlogError::Compilation(format!(
609 "Predicate '{predicate}' has no type for column {index}"
610 ))
611 })?;
612 let value = arithmetic_value_from_typed_provenance(value, scalar_type)?;
613 provenance_value_from_arithmetic(value)
614 })
615 .collect()
616}
617
618fn term_from_public_value(value: Value) -> Term {
619 match value {
620 Value::I64(value) => Term::Integer(value),
621 Value::F64(value) => Term::Float(f64::from_bits(value)),
622 Value::Symbol(value) => Term::Symbol(value),
623 Value::String(value) => Term::String(value),
624 }
625}
626
627pub(crate) fn presentation_atom_from_canonical(
628 source: &Atom,
629 canonical: &Atom,
630 schemas: &HashMap<String, Schema>,
631) -> Result<GroundAtom> {
632 if source.predicate != canonical.predicate || source.terms.len() != canonical.terms.len() {
633 return Err(XlogError::Compilation(
634 "Probabilistic source and canonical atoms do not correspond".to_string(),
635 ));
636 }
637
638 let schema = schemas.get(&canonical.predicate).ok_or_else(|| {
639 XlogError::Compilation(format!(
640 "Probabilistic presentation requires a schema for predicate '{}'",
641 canonical.predicate
642 ))
643 })?;
644 let mut atom = atom_key_from_ground_atom(canonical)?;
645 for (index, (value, source_term)) in atom.args.iter_mut().zip(&source.terms).enumerate() {
646 if schema.column_type(index) != Some(ScalarType::Symbol)
647 || !matches!(value, Value::Symbol(_))
648 {
649 continue;
650 }
651 match source_term {
652 Term::String(source) => *value = Value::String(source.clone()),
653 Term::Symbol(source) => *value = Value::Symbol(*source),
654 _ => {}
655 }
656 }
657 Ok(atom)
658}
659
660pub fn extract_from_program(program: &Program) -> Result<Provenance> {
662 let _ = stratify(program)?;
664 let source_program = program;
665 let schemas = arithmetic_schemas(program)?;
666 let canonical_program = canonicalize_probabilistic_program(program, &schemas)?;
667 let program = &canonical_program;
668
669 let mut builder = PirBuilder::new();
670
671 let mut leaf_probs: BTreeMap<LeafId, f64> = BTreeMap::new();
672 let mut choice_probs: BTreeMap<ChoiceVarId, (f64, f64)> = BTreeMap::new();
673 let mut leaf_atoms: BTreeMap<LeafId, GroundAtom> = BTreeMap::new();
674 let mut choice_sources: BTreeMap<ChoiceVarId, ChoiceSource> = BTreeMap::new();
675 let mut aggregate_lifting: Vec<AggregateLiftReport> = Vec::new();
676
677 let mut store: BTreeMap<String, Relation> = BTreeMap::new();
678
679 for fact in program.facts() {
681 let key = atom_key_from_ground_atom(&fact.head)?;
682 let rel = store
683 .entry(key.predicate.clone())
684 .or_insert_with(Relation::new);
685 rel.insert_or(key.args.clone(), builder.const_true(), &mut builder);
686 }
687
688 let mut next_leaf: u32 = 0;
690 for (pf, source_pf) in program.prob_facts.iter().zip(&source_program.prob_facts) {
691 validate_prob(pf.prob, "probabilistic fact")?;
692 let key = atom_key_from_ground_atom(&pf.atom)?;
693 let leaf = LeafId::new(next_leaf);
694 next_leaf = next_leaf.checked_add(1).ok_or_else(|| {
695 XlogError::Compilation("probabilistic fact leaf id overflow".to_string())
696 })?;
697 leaf_probs.insert(leaf, pf.prob);
698 leaf_atoms.insert(
699 leaf,
700 presentation_atom_from_canonical(&source_pf.atom, &pf.atom, &schemas)?,
701 );
702
703 let rel = store
704 .entry(key.predicate.clone())
705 .or_insert_with(Relation::new);
706 rel.insert_or(key.args.clone(), builder.lit(leaf), &mut builder);
707 }
708
709 let mut next_choice: u32 = 0;
711 for (ad, source_ad) in program
712 .annotated_disjunctions
713 .iter()
714 .zip(&source_program.annotated_disjunctions)
715 {
716 if ad.choices.is_empty() {
717 return Err(XlogError::Compilation(
718 "Annotated disjunction must contain at least one choice".to_string(),
719 ));
720 }
721 let (vars, outcome_formulas) = compile_annotated_disjunction(
722 ad,
723 source_ad,
724 &schemas,
725 &mut next_choice,
726 &mut choice_probs,
727 &mut choice_sources,
728 &mut builder,
729 )?;
730 let _ = vars;
731
732 for (pf, formula) in ad.choices.iter().zip(outcome_formulas) {
733 let key = atom_key_from_ground_atom(&pf.atom)?;
734 let rel = store
735 .entry(key.predicate.clone())
736 .or_insert_with(Relation::new);
737 rel.insert_or(key.args.clone(), formula, &mut builder);
738 }
739 }
740
741 let graph = build_dependency_graph(program);
743 for pred in &graph.predicates {
744 store.entry(pred.clone()).or_insert_with(Relation::new);
745 }
746
747 let strat_result = analyze_stratification(program);
749 let sccs = find_sccs_for_lowering(&graph);
750
751 let non_monotone_scc_preds: std::collections::HashSet<String> = strat_result
755 .sccs
756 .iter()
757 .enumerate()
758 .filter(|(i, _)| strat_result.non_monotone_sccs.contains(i))
759 .flat_map(|(_, scc)| scc.iter().cloned())
760 .collect();
761
762 let mut rules_by_head: BTreeMap<String, Vec<Rule>> = BTreeMap::new();
763 for rule in program.proper_rules() {
764 rules_by_head
766 .entry(rule.head.predicate.clone())
767 .or_default()
768 .push(rule.clone());
769 }
770
771 for scc in sccs {
772 let mut scc_rules: Vec<Rule> = Vec::new();
773 for pred in &scc {
774 if let Some(rules) = rules_by_head.get(pred) {
775 scc_rules.extend(rules.iter().cloned());
776 }
777 }
778 if scc_rules.is_empty() {
779 continue;
780 }
781
782 let is_non_monotone = scc.iter().any(|p| non_monotone_scc_preds.contains(p));
784
785 if is_non_monotone {
786 eval_non_monotone_scc_with_wfs(&scc, &scc_rules, &mut store, &mut builder, &schemas)?;
788 } else {
789 let recursive = is_recursive_scc(&scc, &scc_rules);
790 if recursive {
791 eval_recursive_scc(
792 &scc,
793 &scc_rules,
794 &mut store,
795 &mut builder,
796 &mut aggregate_lifting,
797 &schemas,
798 )?;
799 } else {
800 eval_non_recursive_scc(
801 &scc_rules,
802 &mut store,
803 &mut builder,
804 &mut aggregate_lifting,
805 &schemas,
806 )?;
807 }
808 }
809 }
810
811 let mut tuple_formulas: BTreeMap<GroundAtom, PirNodeId> = BTreeMap::new();
813 for (pred, rel) in &store {
814 for (tuple, formula) in &rel.tuples {
815 tuple_formulas.insert(GroundAtom::new(pred.clone(), tuple.clone()), *formula);
816 }
817 }
818
819 let mut queries: Vec<GroundAtom> = Vec::new();
820 for (ProbQuery { atom }, ProbQuery { atom: source_atom }) in program
821 .prob_queries
822 .iter()
823 .zip(&source_program.prob_queries)
824 {
825 queries.push(presentation_atom_from_canonical(
826 source_atom,
827 atom,
828 &schemas,
829 )?);
830 }
831
832 let mut evidence: Vec<(GroundAtom, bool)> = Vec::new();
833 for (
834 Evidence { atom, value },
835 Evidence {
836 atom: source_atom, ..
837 },
838 ) in program.evidence.iter().zip(&source_program.evidence)
839 {
840 evidence.push((
841 presentation_atom_from_canonical(source_atom, atom, &schemas)?,
842 *value,
843 ));
844 }
845
846 Ok(Provenance {
847 pir: builder.finish(),
848 leaf_probs,
849 choice_probs,
850 tuple_formulas,
851 queries,
852 evidence,
853 leaf_atoms,
854 choice_sources,
855 aggregate_lifting,
856 schemas,
857 })
858}
859
860pub(crate) fn validate_prob(p: f64, what: &str) -> Result<()> {
861 if !(0.0..=1.0).contains(&p) || p.is_nan() {
862 return Err(XlogError::Compilation(format!(
863 "Invalid probability {} for {} (expected 0<=p<=1)",
864 p, what
865 )));
866 }
867 Ok(())
868}
869
870pub(crate) fn atom_key_from_ground_atom(atom: &Atom) -> Result<GroundAtom> {
871 let mut args = Vec::with_capacity(atom.terms.len());
872 for term in &atom.terms {
873 if !term.is_constant() {
874 return Err(XlogError::Compilation(format!(
875 "Expected ground atom, found non-constant term in {}",
876 atom.predicate
877 )));
878 }
879 args.push(value_from_term(term)?);
880 }
881 Ok(GroundAtom::new(atom.predicate.clone(), args))
882}
883
884pub(crate) fn value_from_term(term: &Term) -> Result<Value> {
885 match term {
886 Term::Integer(i) => Ok(Value::I64(*i)),
887 Term::Float(f) => Ok(Value::F64(f.to_bits())),
888 Term::String(s) => Ok(Value::String(s.clone())),
889 Term::Symbol(id) => Ok(Value::Symbol(*id)),
890 Term::Variable(_) | Term::Anonymous | Term::Aggregate(_) => Err(XlogError::Compilation(
891 "Non-constant term cannot be converted to a value".to_string(),
892 )),
893 Term::List(_) => Err(unsupported_probabilistic_term_error(
894 "value conversion",
895 "list",
896 )),
897 Term::Cons { .. } => Err(unsupported_probabilistic_term_error(
898 "value conversion",
899 "cons",
900 )),
901 Term::Compound { .. } => Err(unsupported_probabilistic_term_error(
902 "value conversion",
903 "compound",
904 )),
905 Term::PredRef(_) => Err(unsupported_probabilistic_term_error(
906 "value conversion",
907 "predref",
908 )),
909 }
910}
911
912fn unsupported_probabilistic_term_error(context: &str, kind: &str) -> XlogError {
913 XlogError::Compilation(format!(
914 "high-level term form '{}' is parsed but not supported in probabilistic provenance {} until a lowering/materialization path exists",
915 kind, context
916 ))
917}
918
919fn compile_annotated_disjunction(
920 ad: &xlog_logic::ast::AnnotatedDisjunction,
921 source_ad: &xlog_logic::ast::AnnotatedDisjunction,
922 schemas: &HashMap<String, Schema>,
923 next_choice: &mut u32,
924 choice_probs: &mut BTreeMap<ChoiceVarId, (f64, f64)>,
925 choice_sources: &mut BTreeMap<ChoiceVarId, ChoiceSource>,
926 builder: &mut PirBuilder,
927) -> Result<(Vec<ChoiceVarId>, Vec<PirNodeId>)> {
928 for pf in &ad.choices {
929 validate_prob(pf.prob, "annotated disjunction choice")?;
930 let _ = atom_key_from_ground_atom(&pf.atom)?;
931 }
932
933 let explicit_choices: Arc<[(GroundAtom, f64)]> = ad
937 .choices
938 .iter()
939 .zip(&source_ad.choices)
940 .map(|(pf, source_pf)| {
941 presentation_atom_from_canonical(&source_pf.atom, &pf.atom, schemas)
942 .map(|atom| (atom, pf.prob))
943 })
944 .collect::<Result<Vec<_>>>()?
945 .into();
946
947 let mut probs: Vec<f64> = ad.choices.iter().map(|pf| pf.prob).collect();
948 let sum: f64 = probs.iter().copied().sum();
949 let eps = 1e-12;
950 if sum > 1.0 + eps {
951 return Err(XlogError::Compilation(format!(
952 "Annotated disjunction probabilities sum to {} (> 1.0)",
953 sum
954 )));
955 }
956
957 let mut has_none = false;
958 let none_prob = (1.0 - sum).max(0.0);
959 if none_prob > eps {
960 probs.push(none_prob);
961 has_none = true;
962 }
963
964 let m = probs.len();
965 if m == 1 {
966 return Ok((Vec::new(), vec![builder.const_true()]));
967 }
968
969 let mut vars: Vec<ChoiceVarId> = Vec::with_capacity(m.saturating_sub(1));
970 let mut remaining = 1.0f64;
971 for (i, &p_i) in probs.iter().enumerate().take(m - 1) {
972 let cond_true = if remaining <= 0.0 {
973 0.0
974 } else {
975 p_i / remaining
976 };
977 validate_prob(cond_true, "annotated disjunction conditional")?;
978 let cond_false = 1.0 - cond_true;
979 let var = ChoiceVarId::new(*next_choice);
980 *next_choice = (*next_choice).checked_add(1).ok_or_else(|| {
981 XlogError::Compilation("annotated disjunction choice id overflow".to_string())
982 })?;
983 vars.push(var);
984 choice_probs.insert(var, (cond_true, cond_false));
985 choice_sources.insert(
986 var,
987 ChoiceSource {
988 choices: explicit_choices.clone(),
989 choice_index: i,
990 source_id: None,
991 },
992 );
993 remaining -= p_i;
994 }
995
996 let mut outcome_formulas: Vec<PirNodeId> = Vec::new();
997 for i in 0..ad.choices.len() {
998 let mut conds: Vec<PirNodeId> = Vec::new();
999 for (j, &var) in vars.iter().enumerate() {
1000 if j < i {
1001 conds.push(builder.choice_lit(var, false));
1002 } else if j == i {
1003 conds.push(builder.choice_lit(var, true));
1004 break;
1005 }
1006 }
1007 outcome_formulas.push(builder.and(conds));
1008 }
1009
1010 if has_none {
1011 }
1014
1015 Ok((vars, outcome_formulas))
1016}
1017
1018fn is_recursive_scc(scc: &[String], rules: &[Rule]) -> bool {
1019 if scc.len() > 1 {
1020 return true;
1021 }
1022 let Some(only) = scc.first() else {
1023 return false;
1024 };
1025 for rule in rules {
1026 for lit in &rule.body {
1027 if let BodyLiteral::Positive(atom) = lit {
1028 if &atom.predicate == only {
1029 return true;
1030 }
1031 }
1032 }
1033 }
1034 false
1035}
1036
1037fn eval_non_recursive_scc(
1038 rules: &[Rule],
1039 store: &mut BTreeMap<String, Relation>,
1040 builder: &mut PirBuilder,
1041 aggregate_lifting: &mut Vec<AggregateLiftReport>,
1042 schemas: &HashMap<String, Schema>,
1043) -> Result<()> {
1044 for rule in rules {
1045 let derived = eval_rule(
1046 rule,
1047 store,
1048 &BTreeMap::new(),
1049 None,
1050 builder,
1051 aggregate_lifting,
1052 schemas,
1053 )?;
1054 let rel = store
1055 .entry(rule.head.predicate.clone())
1056 .or_insert_with(Relation::new);
1057 for (tuple, formula) in derived {
1058 rel.insert_or(tuple, formula, builder);
1059 }
1060 }
1061 Ok(())
1062}
1063
1064const MAX_PROVENANCE_ITERATIONS: usize = 1024;
1065
1066fn eval_recursive_scc(
1067 scc: &[String],
1068 rules: &[Rule],
1069 store: &mut BTreeMap<String, Relation>,
1070 builder: &mut PirBuilder,
1071 aggregate_lifting: &mut Vec<AggregateLiftReport>,
1072 schemas: &HashMap<String, Schema>,
1073) -> Result<()> {
1074 let scc_set: std::collections::HashSet<&str> = scc.iter().map(|s| s.as_str()).collect();
1075
1076 let mut full: BTreeMap<String, Relation> = BTreeMap::new();
1078 for pred in scc {
1079 let rel = store.get(pred).cloned().unwrap_or_else(Relation::new);
1080 full.insert(pred.clone(), rel);
1081 }
1082
1083 let mut delta: BTreeMap<String, Relation> = BTreeMap::new();
1085 for rule in rules {
1086 let derived = eval_rule(
1087 rule,
1088 store,
1089 &full,
1090 None,
1091 builder,
1092 aggregate_lifting,
1093 schemas,
1094 )?;
1095 if derived.is_empty() {
1096 continue;
1097 }
1098 let head = rule.head.predicate.clone();
1099 let delta_rel = delta.entry(head.clone()).or_insert_with(Relation::new);
1100 let full_rel = full.entry(head).or_insert_with(Relation::new);
1101 for (tuple, proof) in derived {
1102 let old = full_rel.get(&tuple).unwrap_or(builder.const_false());
1103 let combined = builder.or(vec![old, proof]);
1104 if combined != old {
1105 full_rel.tuples.insert(tuple.clone(), combined);
1106 delta_rel.insert_or(tuple, proof, builder);
1107 }
1108 }
1109 }
1110
1111 let mut reached_fixpoint = false;
1112 for _ in 0..MAX_PROVENANCE_ITERATIONS {
1113 let any_delta = delta.values().any(|r| !r.is_empty());
1114 if !any_delta {
1115 reached_fixpoint = true;
1116 break;
1117 }
1118
1119 let full_prev = full.clone();
1120 let delta_prev = delta.clone();
1121 delta.clear();
1122
1123 for rule in rules {
1124 let body_indices: Vec<usize> = rule
1125 .body
1126 .iter()
1127 .enumerate()
1128 .filter_map(|(i, lit)| match lit {
1129 BodyLiteral::Positive(atom) if scc_set.contains(atom.predicate.as_str()) => {
1130 let pred = &atom.predicate;
1131 let non_empty =
1132 delta_prev.get(pred).map(|r| !r.is_empty()).unwrap_or(false);
1133 non_empty.then_some(i)
1134 }
1135 _ => None,
1136 })
1137 .collect();
1138 if body_indices.is_empty() {
1139 continue;
1140 }
1141
1142 let mut derived_all: BTreeMap<Vec<Value>, PirNodeId> = BTreeMap::new();
1143 for idx in body_indices {
1144 let derived = eval_rule(
1145 rule,
1146 store,
1147 &full_prev,
1148 Some((idx, &delta_prev)),
1149 builder,
1150 aggregate_lifting,
1151 schemas,
1152 )?;
1153 for (tuple, proof) in derived {
1154 let entry = derived_all
1155 .entry(tuple)
1156 .or_insert_with(|| builder.const_false());
1157 *entry = builder.or(vec![*entry, proof]);
1158 }
1159 }
1160
1161 if derived_all.is_empty() {
1162 continue;
1163 }
1164
1165 let head = rule.head.predicate.clone();
1166 let delta_rel = delta.entry(head.clone()).or_insert_with(Relation::new);
1167 let full_rel = full.entry(head).or_insert_with(Relation::new);
1168 for (tuple, proof) in derived_all {
1169 let old = full_rel.get(&tuple).unwrap_or(builder.const_false());
1170 let combined = builder.or(vec![old, proof]);
1171 if combined != old {
1172 full_rel.tuples.insert(tuple.clone(), combined);
1173 delta_rel.insert_or(tuple, proof, builder);
1174 }
1175 }
1176 }
1177 }
1178 if !reached_fixpoint {
1179 return Err(XlogError::Compilation(format!(
1180 "Provenance iteration limit ({}) exceeded for SCC {:?}",
1181 MAX_PROVENANCE_ITERATIONS, scc
1182 )));
1183 }
1184
1185 for (pred, rel) in full {
1187 store.insert(pred, rel);
1188 }
1189
1190 Ok(())
1191}
1192
1193fn eval_non_monotone_scc_with_wfs(
1203 scc: &[String],
1204 rules: &[Rule],
1205 store: &mut BTreeMap<String, Relation>,
1206 builder: &mut PirBuilder,
1207 schemas: &HashMap<String, Schema>,
1208) -> Result<()> {
1209 let scc_set: std::collections::HashSet<&str> = scc.iter().map(|s| s.as_str()).collect();
1210
1211 let mut wfs_rules: Vec<WfsRule> = Vec::new();
1214
1215 for rule in rules {
1216 let grounded = ground_rule_for_wfs(rule, store, &scc_set, builder, schemas)?;
1218 wfs_rules.extend(grounded);
1219 }
1220
1221 if wfs_rules.is_empty() {
1222 return Ok(());
1224 }
1225
1226 let wfs_result = evaluate_wfs_rules(&wfs_rules, &mut builder.pir, &WfsConfig::default())?;
1228
1229 for (wfs_atom, prov) in wfs_result.true_set {
1232 let args = canonicalize_public_values(&wfs_atom.predicate, &wfs_atom.args, schemas)?;
1233 let rel = store
1234 .entry(wfs_atom.predicate.clone())
1235 .or_insert_with(Relation::new);
1236 rel.insert_or(args, prov, builder);
1237 }
1238
1239 Ok(())
1240}
1241
1242fn ground_rule_for_wfs(
1247 rule: &Rule,
1248 store: &BTreeMap<String, Relation>,
1249 scc_set: &std::collections::HashSet<&str>,
1250 builder: &mut PirBuilder,
1251 schemas: &HashMap<String, Schema>,
1252) -> Result<Vec<WfsRule>> {
1253 let mut bindings: Vec<ProvenanceEvaluationState> =
1255 vec![(HashMap::new(), HashMap::new(), builder.const_true())];
1256
1257 let mut wfs_body_template: Vec<(usize, bool)> = Vec::new(); for (idx, lit) in rule.body.iter().enumerate() {
1262 match lit {
1263 BodyLiteral::Positive(atom) => {
1264 if scc_set.contains(atom.predicate.as_str()) {
1265 wfs_body_template.push((idx, true));
1267 } else {
1268 let rel = store.get(&atom.predicate);
1270 let mut next_bindings = Vec::new();
1271
1272 for (binding, arithmetic_bindings, prov) in bindings {
1273 if let Some(rel) = rel {
1274 for (tuple, tuple_prov) in &rel.tuples {
1275 let mut new_binding = binding.clone();
1276 if unify_atom(atom, tuple, &mut new_binding)? {
1277 let mut new_arithmetic_bindings = arithmetic_bindings.clone();
1278 extend_arithmetic_bindings(
1279 atom,
1280 tuple,
1281 schemas,
1282 &mut new_arithmetic_bindings,
1283 )?;
1284 let new_prov = builder.and(vec![prov, *tuple_prov]);
1285 next_bindings.push((
1286 new_binding,
1287 new_arithmetic_bindings,
1288 new_prov,
1289 ));
1290 }
1291 }
1292 }
1293 }
1295 bindings = next_bindings;
1296 if bindings.is_empty() {
1297 return Ok(Vec::new());
1298 }
1299 }
1300 }
1301 BodyLiteral::Negated(atom) => {
1302 if scc_set.contains(atom.predicate.as_str()) {
1303 wfs_body_template.push((idx, false));
1305 } else {
1306 let rel = store.get(&atom.predicate);
1308 let mut next_bindings = Vec::new();
1309
1310 for (binding, arithmetic_bindings, prov) in bindings {
1311 let all_bound = atom.terms.iter().all(|t| match t {
1313 Term::Variable(v) => binding.contains_key(v),
1314 _ => true,
1315 });
1316
1317 if !all_bound {
1318 continue;
1320 }
1321
1322 if let Some(rel) = rel {
1323 let mut matching_provs: Vec<PirNodeId> = Vec::new();
1325 for (tuple, tuple_prov) in &rel.tuples {
1326 let mut test_binding = binding.clone();
1327 if unify_atom(atom, tuple, &mut test_binding)? {
1328 matching_provs.push(*tuple_prov);
1329 }
1330 }
1331
1332 if matching_provs.is_empty() {
1333 next_bindings.push((binding, arithmetic_bindings, prov));
1335 } else {
1336 let combined = builder.or(matching_provs);
1338 let neg_prov = negate_provenance(combined, builder);
1339 let new_prov = builder.and(vec![prov, neg_prov]);
1340 next_bindings.push((binding, arithmetic_bindings, new_prov));
1341 }
1342 } else {
1343 next_bindings.push((binding, arithmetic_bindings, prov));
1345 }
1346 }
1347 bindings = next_bindings;
1348 if bindings.is_empty() {
1349 return Ok(Vec::new());
1350 }
1351 }
1352 }
1353 BodyLiteral::Epistemic(lit) => {
1354 return Err(XlogError::UnsupportedEpistemicConstruct {
1355 construct: "probabilistic WFS grounding".to_string(),
1356 context: format!("{:?} {}({})", lit.op, lit.atom.predicate, lit.atom.arity()),
1357 });
1358 }
1359 BodyLiteral::Comparison(cmp) => {
1360 let mut next_bindings = Vec::new();
1361 for (binding, arithmetic_bindings, prov) in bindings {
1362 if eval_comparison_with_arithmetic_bindings(
1363 cmp.op,
1364 &cmp.left,
1365 &cmp.right,
1366 &binding,
1367 &arithmetic_bindings,
1368 )? {
1369 next_bindings.push((binding, arithmetic_bindings, prov));
1370 }
1371 }
1372 bindings = next_bindings;
1373 if bindings.is_empty() {
1374 return Ok(Vec::new());
1375 }
1376 }
1377 BodyLiteral::IsExpr(is_expr) => {
1378 let mut next_bindings = Vec::new();
1379 for (mut binding, mut arithmetic_bindings, prov) in bindings {
1380 let arithmetic_value =
1381 eval_arithmetic_value(&is_expr.expr, &binding, &arithmetic_bindings)?;
1382 bind_arithmetic_result(
1383 &is_expr.target,
1384 arithmetic_value,
1385 &mut binding,
1386 &mut arithmetic_bindings,
1387 )?;
1388 next_bindings.push((binding, arithmetic_bindings, prov));
1389 }
1390 bindings = next_bindings;
1391 if bindings.is_empty() {
1392 return Ok(Vec::new());
1393 }
1394 }
1395 BodyLiteral::Univ(_) => {
1396 return Err(XlogError::Compilation(
1397 "univ literal was not normalized before provenance extraction".to_string(),
1398 ));
1399 }
1400 }
1401 }
1402
1403 let mut result: Vec<WfsRule> = Vec::new();
1405
1406 for (binding, _, external_prov) in bindings {
1407 let mut wfs_body: Vec<WfsLiteral> = Vec::new();
1409
1410 for &(idx, is_positive) in &wfs_body_template {
1411 let atom = match &rule.body[idx] {
1412 BodyLiteral::Positive(a) | BodyLiteral::Negated(a) => a,
1413 _ => continue,
1414 };
1415
1416 let mut args: Vec<Value> = Vec::new();
1418 for term in &atom.terms {
1419 match term {
1420 Term::Variable(name) => {
1421 if let Some(v) = binding.get(name) {
1422 args.push(v.clone());
1423 } else {
1424 continue;
1427 }
1428 }
1429 _ => {
1430 args.push(value_from_term(term)?);
1431 }
1432 }
1433 }
1434
1435 let wfs_atom = WfsAtom::new(atom.predicate.clone(), args);
1436 if is_positive {
1437 wfs_body.push(WfsLiteral::Positive(wfs_atom));
1438 } else {
1439 wfs_body.push(WfsLiteral::Negative(wfs_atom));
1440 }
1441 }
1442
1443 let mut head_args: Vec<Value> = Vec::new();
1445 for term in &rule.head.terms {
1446 match term {
1447 Term::Variable(name) => {
1448 if let Some(v) = binding.get(name) {
1449 head_args.push(v.clone());
1450 } else {
1451 continue;
1453 }
1454 }
1455 _ => {
1456 head_args.push(value_from_term(term)?);
1457 }
1458 }
1459 }
1460
1461 let wfs_head = WfsAtom::new(rule.head.predicate.clone(), head_args);
1462 result.push(WfsRule::new(wfs_head, wfs_body, external_prov));
1463 }
1464
1465 Ok(result)
1466}
1467
1468fn negate_provenance(prov: PirNodeId, builder: &mut PirBuilder) -> PirNodeId {
1476 use crate::pir::PirNode;
1477 match builder.pir.node(prov).cloned() {
1478 Some(PirNode::Const(b)) => {
1479 if b {
1480 builder.const_false()
1481 } else {
1482 builder.const_true()
1483 }
1484 }
1485 Some(PirNode::Lit { leaf }) => builder.neg_lit(leaf),
1486 Some(PirNode::NegLit { leaf }) => builder.lit(leaf), Some(PirNode::And { children }) => {
1488 let neg_children: Vec<PirNodeId> = children
1490 .iter()
1491 .map(|&c| negate_provenance(c, builder))
1492 .collect();
1493 builder.or(neg_children)
1494 }
1495 Some(PirNode::Or { children }) => {
1496 let neg_children: Vec<PirNodeId> = children
1498 .iter()
1499 .map(|&c| negate_provenance(c, builder))
1500 .collect();
1501 builder.and(neg_children)
1502 }
1503 Some(PirNode::Decision {
1504 var,
1505 child_false,
1506 child_true,
1507 }) => {
1508 let neg_false = negate_provenance(child_false, builder);
1510 let neg_true = negate_provenance(child_true, builder);
1511 builder.decision(var, neg_false, neg_true)
1512 }
1513 None => prov,
1514 }
1515}
1516
1517fn eval_rule(
1522 rule: &Rule,
1523 global: &BTreeMap<String, Relation>,
1524 full_scc: &BTreeMap<String, Relation>,
1525 delta_scc: Option<(usize, &BTreeMap<String, Relation>)>,
1526 builder: &mut PirBuilder,
1527 aggregate_lifting: &mut Vec<AggregateLiftReport>,
1528 schemas: &HashMap<String, Schema>,
1529) -> Result<BTreeMap<Vec<Value>, PirNodeId>> {
1530 let mut states: Vec<ProvenanceEvaluationState> =
1531 vec![(HashMap::new(), HashMap::new(), builder.const_true())];
1532
1533 for (idx, lit) in rule.body.iter().enumerate() {
1534 let mut next_states = Vec::new();
1535 match lit {
1536 BodyLiteral::Positive(atom) => {
1537 let rel = select_relation(atom, idx, global, full_scc, delta_scc)?;
1538 for (binding, arithmetic_bindings, prov) in states {
1539 for (tuple, tuple_prov) in &rel.tuples {
1540 let mut binding2 = binding.clone();
1541 if unify_atom(atom, tuple, &mut binding2)? {
1542 let mut arithmetic_bindings2 = arithmetic_bindings.clone();
1543 extend_arithmetic_bindings(
1544 atom,
1545 tuple,
1546 schemas,
1547 &mut arithmetic_bindings2,
1548 )?;
1549 let prov2 = builder.and(vec![prov, *tuple_prov]);
1550 next_states.push((binding2, arithmetic_bindings2, prov2));
1551 }
1552 }
1553 }
1554 }
1555 BodyLiteral::Comparison(cmp) => {
1556 for (binding, arithmetic_bindings, prov) in states {
1557 if eval_comparison_with_arithmetic_bindings(
1558 cmp.op,
1559 &cmp.left,
1560 &cmp.right,
1561 &binding,
1562 &arithmetic_bindings,
1563 )? {
1564 next_states.push((binding, arithmetic_bindings, prov));
1565 }
1566 }
1567 }
1568 BodyLiteral::IsExpr(is_expr) => {
1569 for (mut binding, mut arithmetic_bindings, prov) in states {
1570 let arithmetic_value =
1571 eval_arithmetic_value(&is_expr.expr, &binding, &arithmetic_bindings)?;
1572 bind_arithmetic_result(
1573 &is_expr.target,
1574 arithmetic_value,
1575 &mut binding,
1576 &mut arithmetic_bindings,
1577 )?;
1578 next_states.push((binding, arithmetic_bindings, prov));
1579 }
1580 }
1581 BodyLiteral::Negated(atom) => {
1582 let rel = if let Some(r) = full_scc.get(&atom.predicate) {
1589 r
1590 } else if let Some(r) = global.get(&atom.predicate) {
1591 r
1592 } else {
1593 for (binding, arithmetic_bindings, prov) in states {
1595 let all_bound = atom.terms.iter().all(|t| match t {
1597 Term::Variable(v) => binding.contains_key(v),
1598 _ => true,
1599 });
1600 if all_bound {
1601 next_states.push((binding, arithmetic_bindings, prov));
1602 }
1603 }
1604 states = next_states;
1605 if states.is_empty() {
1606 break;
1607 }
1608 continue;
1609 };
1610
1611 for (binding, arithmetic_bindings, prov) in states {
1612 let all_bound = atom.terms.iter().all(|t| match t {
1615 Term::Variable(v) => binding.contains_key(v),
1616 _ => true,
1617 });
1618 if !all_bound {
1619 continue;
1621 }
1622
1623 let mut matching_provs: Vec<PirNodeId> = Vec::new();
1625 for (tuple, tuple_prov) in &rel.tuples {
1626 let mut binding2 = binding.clone();
1627 if unify_atom(atom, tuple, &mut binding2)? {
1628 matching_provs.push(*tuple_prov);
1630 }
1631 }
1632
1633 if matching_provs.is_empty() {
1634 next_states.push((binding, arithmetic_bindings, prov));
1636 } else {
1637 let combined_tuple_prov = builder.or(matching_provs);
1642 let neg_prov = negate_provenance(combined_tuple_prov, builder);
1643 let new_prov = builder.and(vec![prov, neg_prov]);
1644 next_states.push((binding, arithmetic_bindings, new_prov));
1645 }
1646 }
1647 }
1648 BodyLiteral::Epistemic(lit) => {
1649 return Err(XlogError::UnsupportedEpistemicConstruct {
1650 construct: "probabilistic provenance evaluation".to_string(),
1651 context: format!("{:?} {}({})", lit.op, lit.atom.predicate, lit.atom.arity()),
1652 });
1653 }
1654 BodyLiteral::Univ(_) => {
1655 return Err(XlogError::Compilation(
1656 "univ literal was not normalized before provenance extraction".to_string(),
1657 ));
1658 }
1659 }
1660 states = next_states;
1661 if states.is_empty() {
1662 break;
1663 }
1664 }
1665
1666 let states = states
1667 .into_iter()
1668 .map(|(binding, _, provenance)| (binding, provenance))
1669 .collect::<Vec<_>>();
1670 let derived = if rule.has_aggregation() {
1671 eval_aggregate_head_provenance(&rule.head, states, builder, aggregate_lifting)?
1672 } else {
1673 let mut out: BTreeMap<Vec<Value>, PirNodeId> = BTreeMap::new();
1674 for (binding, prov) in states {
1675 let head_tuple = materialize_head(&rule.head, &binding)?;
1676 let entry = out
1677 .entry(head_tuple)
1678 .or_insert_with(|| builder.const_false());
1679 *entry = builder.or(vec![*entry, prov]);
1680 }
1681 out
1682 };
1683
1684 let mut canonical = BTreeMap::new();
1685 for (tuple, provenance) in derived {
1686 let tuple = canonicalize_public_values(&rule.head.predicate, &tuple, schemas)?;
1687 let entry = canonical
1688 .entry(tuple)
1689 .or_insert_with(|| builder.const_false());
1690 *entry = builder.or(vec![*entry, provenance]);
1691 }
1692 Ok(canonical)
1693}
1694
1695const MAX_EXACT_PROB_AGG_UNCERTAIN_ROWS: usize = 16;
1696const MAX_EXACT_PROB_COUNT_LIFT_ROWS: usize = 64;
1697
1698#[derive(Debug, Clone)]
1699struct AggregateProvRow {
1700 binding: HashMap<String, Value>,
1701 prov: PirNodeId,
1702}
1703
1704fn eval_aggregate_head_provenance(
1705 head: &Atom,
1706 states: Vec<(HashMap<String, Value>, PirNodeId)>,
1707 builder: &mut PirBuilder,
1708 aggregate_lifting: &mut Vec<AggregateLiftReport>,
1709) -> Result<BTreeMap<Vec<Value>, PirNodeId>> {
1710 let (key_vars, key_var_to_pos, agg_specs, agg_to_pos) = aggregate_head_plan(head)?;
1711
1712 let mut deduped_states: BTreeMap<Vec<(String, Value)>, AggregateProvRow> = BTreeMap::new();
1713 for (binding, prov) in states {
1714 let key = canonical_binding_key(&binding);
1715 match deduped_states.get_mut(&key) {
1716 Some(row) => {
1717 row.prov = builder.or(vec![row.prov, prov]);
1718 }
1719 None => {
1720 deduped_states.insert(key, AggregateProvRow { binding, prov });
1721 }
1722 }
1723 }
1724
1725 #[derive(Debug)]
1726 struct GroupRows {
1727 key: Vec<Value>,
1728 rows: Vec<AggregateProvRow>,
1729 }
1730
1731 let mut groups: BTreeMap<Vec<Value>, GroupRows> = BTreeMap::new();
1732 for row in deduped_states.into_values() {
1733 let mut key: Vec<Value> = Vec::with_capacity(key_vars.len());
1734 for name in &key_vars {
1735 let v = row
1736 .binding
1737 .get(name)
1738 .ok_or_else(|| XlogError::UnsafeVariable(name.clone()))?;
1739 key.push(v.clone());
1740 }
1741 groups
1742 .entry(key.clone())
1743 .or_insert_with(|| GroupRows {
1744 key,
1745 rows: Vec::new(),
1746 })
1747 .rows
1748 .push(row);
1749 }
1750
1751 let mut out: BTreeMap<Vec<Value>, PirNodeId> = BTreeMap::new();
1752 let count_only = agg_specs.iter().all(|(op, _)| *op == AggOp::Count);
1753 for group in groups.into_values() {
1754 let mut always_rows: Vec<AggregateProvRow> = Vec::new();
1755 let mut uncertain_rows: Vec<AggregateProvRow> = Vec::new();
1756 for row in group.rows {
1757 match pir_const_value(builder, row.prov) {
1758 Some(true) => always_rows.push(row),
1759 Some(false) => {}
1760 None => uncertain_rows.push(row),
1761 }
1762 }
1763
1764 if always_rows.is_empty() && uncertain_rows.is_empty() {
1765 continue;
1766 }
1767 if count_only {
1768 if uncertain_rows.len() > MAX_EXACT_PROB_COUNT_LIFT_ROWS {
1769 return Err(XlogError::Compilation(format!(
1770 "count aggregate lifting finite domain cap exceeded for predicate {} group {:?}: {} uncertain rows > cap {}; use prob_engine = mc or reduce the finite aggregate domain",
1771 head.predicate,
1772 group.key,
1773 uncertain_rows.len(),
1774 MAX_EXACT_PROB_COUNT_LIFT_ROWS
1775 )));
1776 }
1777 validate_count_lift_rows(&agg_specs, &always_rows, &uncertain_rows)?;
1778 record_aggregate_lift_reports(
1779 aggregate_lifting,
1780 head,
1781 &group.key,
1782 &agg_specs,
1783 always_rows.len(),
1784 uncertain_rows.len(),
1785 AggregateLiftStatus::Fired,
1786 "finite count domain lifted with exact cardinality dynamic programming",
1787 MAX_EXACT_PROB_COUNT_LIFT_ROWS,
1788 count_lift_dp_states(uncertain_rows.len()),
1789 );
1790 let count_formulas = count_lift_formulas(&uncertain_rows, builder);
1791 for (selected_uncertain_rows, proof) in count_formulas.into_iter().enumerate() {
1792 if always_rows.is_empty() && selected_uncertain_rows == 0 {
1793 continue;
1794 }
1795 let count_value = always_rows.len() + selected_uncertain_rows;
1796 let tuple =
1797 materialize_count_lift_tuple(head, &group.key, &key_var_to_pos, count_value)?;
1798 let entry = out.entry(tuple).or_insert_with(|| builder.const_false());
1799 *entry = builder.or(vec![*entry, proof]);
1800 }
1801 continue;
1802 }
1803
1804 if uncertain_rows.len() > MAX_EXACT_PROB_AGG_UNCERTAIN_ROWS {
1805 return Err(XlogError::Compilation(format!(
1806 "exact probabilistic aggregate domain cap exceeded for predicate {} group {:?}: {} uncertain rows > cap {}; use prob_engine = mc or reduce the finite aggregate domain",
1807 head.predicate,
1808 group.key,
1809 uncertain_rows.len(),
1810 MAX_EXACT_PROB_AGG_UNCERTAIN_ROWS
1811 )));
1812 }
1813 let (outcomes, dp_states) =
1814 factorized_aggregate_outcomes(&agg_specs, &always_rows, &uncertain_rows, builder)?;
1815 record_aggregate_lift_reports(
1816 aggregate_lifting,
1817 head,
1818 &group.key,
1819 &agg_specs,
1820 always_rows.len(),
1821 uncertain_rows.len(),
1822 AggregateLiftStatus::Fired,
1823 "finite outcome domain folded with factorized aggregate-state dynamic programming",
1824 MAX_EXACT_PROB_AGG_UNCERTAIN_ROWS,
1825 dp_states,
1826 );
1827
1828 for (agg_states, selected_any, proof) in outcomes {
1829 if always_rows.is_empty() && !selected_any {
1830 continue;
1833 }
1834
1835 let tuple = materialize_aggregate_tuple(
1836 head,
1837 &group.key,
1838 &key_var_to_pos,
1839 &agg_specs,
1840 &agg_to_pos,
1841 &agg_states,
1842 )?;
1843 let entry = out.entry(tuple).or_insert_with(|| builder.const_false());
1844 *entry = builder.or(vec![*entry, proof]);
1845 }
1846 }
1847
1848 Ok(out)
1849}
1850
1851type FactorizedAggregateOutcome = (Vec<AggState>, bool, PirNodeId);
1867
1868fn factorized_aggregate_outcomes(
1869 agg_specs: &[(AggOp, String)],
1870 always_rows: &[AggregateProvRow],
1871 uncertain_rows: &[AggregateProvRow],
1872 builder: &mut PirBuilder,
1873) -> Result<(Vec<FactorizedAggregateOutcome>, usize)> {
1874 use std::collections::btree_map::Entry;
1875
1876 fn states_key(states: &[AggState]) -> Vec<AggStateKey> {
1877 states.iter().map(AggState::dp_key).collect()
1878 }
1879
1880 let mut base: Vec<AggState> = agg_specs.iter().map(|(op, _)| AggState::new(*op)).collect();
1881 for row in always_rows {
1882 update_aggregate_states(&mut base, agg_specs, row)?;
1883 }
1884
1885 let mut dp: BTreeMap<(Vec<AggStateKey>, bool), (Vec<AggState>, PirNodeId)> = BTreeMap::new();
1886 let true_proof = builder.const_true();
1887 dp.insert((states_key(&base), false), (base, true_proof));
1888 let mut dp_states = dp.len();
1889
1890 for row in uncertain_rows {
1891 let absent = negate_provenance(row.prov, builder);
1892 let mut next: BTreeMap<(Vec<AggStateKey>, bool), (Vec<AggState>, PirNodeId)> =
1893 BTreeMap::new();
1894 for ((key, selected_any), (states, proof)) in dp {
1895 let mut present_states = states.clone();
1896 update_aggregate_states(&mut present_states, agg_specs, row)?;
1897 let present_key = states_key(&present_states);
1898 let present_proof = builder.and(vec![proof, row.prov]);
1899 match next.entry((present_key, true)) {
1900 Entry::Occupied(mut entry) => {
1901 entry.get_mut().1 = builder.or(vec![entry.get().1, present_proof]);
1902 }
1903 Entry::Vacant(entry) => {
1904 entry.insert((present_states, present_proof));
1905 }
1906 }
1907
1908 let absent_proof = builder.and(vec![proof, absent]);
1909 match next.entry((key, selected_any)) {
1910 Entry::Occupied(mut entry) => {
1911 entry.get_mut().1 = builder.or(vec![entry.get().1, absent_proof]);
1912 }
1913 Entry::Vacant(entry) => {
1914 entry.insert((states, absent_proof));
1915 }
1916 }
1917 }
1918 dp = next;
1919 dp_states += dp.len();
1920 }
1921
1922 let outcomes = dp
1923 .into_iter()
1924 .map(|((_, selected_any), (states, proof))| (states, selected_any, proof))
1925 .collect();
1926 Ok((outcomes, dp_states))
1927}
1928
1929fn validate_count_lift_rows(
1930 agg_specs: &[(AggOp, String)],
1931 always_rows: &[AggregateProvRow],
1932 uncertain_rows: &[AggregateProvRow],
1933) -> Result<()> {
1934 for (_, var) in agg_specs {
1935 for row in always_rows.iter().chain(uncertain_rows.iter()) {
1936 if !row.binding.contains_key(var) {
1937 return Err(XlogError::UnsafeVariable(var.clone()));
1938 }
1939 }
1940 }
1941 Ok(())
1942}
1943
1944fn count_lift_formulas(
1945 uncertain_rows: &[AggregateProvRow],
1946 builder: &mut PirBuilder,
1947) -> Vec<PirNodeId> {
1948 let n = uncertain_rows.len();
1949 let mut dp = vec![builder.const_false(); n + 1];
1950 dp[0] = builder.const_true();
1951
1952 for (idx, row) in uncertain_rows.iter().enumerate() {
1953 let mut next = vec![builder.const_false(); n + 1];
1954 let present = row.prov;
1955 let absent = negate_provenance(row.prov, builder);
1956 for selected in 0..=idx {
1957 let absent_case = builder.and(vec![dp[selected], absent]);
1958 next[selected] = builder.or(vec![next[selected], absent_case]);
1959
1960 let present_case = builder.and(vec![dp[selected], present]);
1961 next[selected + 1] = builder.or(vec![next[selected + 1], present_case]);
1962 }
1963 dp = next;
1964 }
1965
1966 dp
1967}
1968
1969fn materialize_count_lift_tuple(
1970 head: &Atom,
1971 group_key: &[Value],
1972 key_var_to_pos: &HashMap<String, usize>,
1973 count_value: usize,
1974) -> Result<Vec<Value>> {
1975 let count_value: i64 = count_value
1976 .try_into()
1977 .map_err(|_| XlogError::Compilation("count() overflowed i64".to_string()))?;
1978 let mut tuple: Vec<Value> = Vec::with_capacity(head.terms.len());
1979 for term in &head.terms {
1980 match term {
1981 Term::Variable(name) => {
1982 let pos = *key_var_to_pos.get(name).ok_or_else(|| {
1983 XlogError::Compilation(format!(
1984 "Aggregate head variable {} is not a group key",
1985 name
1986 ))
1987 })?;
1988 tuple.push(group_key[pos].clone());
1989 }
1990 Term::Aggregate(AggExpr {
1991 op: AggOp::Count, ..
1992 }) => tuple.push(Value::I64(count_value)),
1993 Term::Aggregate(AggExpr { op, .. }) => {
1994 return Err(XlogError::Compilation(format!(
1995 "Internal aggregate lift state mismatch for {}",
1996 agg_op_label(*op)
1997 )));
1998 }
1999 Term::Integer(_) | Term::Float(_) | Term::String(_) | Term::Symbol(_) => {
2000 tuple.push(value_from_term(term)?);
2001 }
2002 Term::Anonymous => unreachable!("aggregate head plan rejects anonymous terms"),
2003 Term::List(_) => {
2004 return Err(unsupported_probabilistic_term_error(
2005 "aggregate head materialization",
2006 "list",
2007 ));
2008 }
2009 Term::Cons { .. } => {
2010 return Err(unsupported_probabilistic_term_error(
2011 "aggregate head materialization",
2012 "cons",
2013 ));
2014 }
2015 Term::Compound { .. } => {
2016 return Err(unsupported_probabilistic_term_error(
2017 "aggregate head materialization",
2018 "compound",
2019 ));
2020 }
2021 Term::PredRef(_) => {
2022 return Err(unsupported_probabilistic_term_error(
2023 "aggregate head materialization",
2024 "predref",
2025 ));
2026 }
2027 }
2028 }
2029 Ok(tuple)
2030}
2031
2032#[expect(
2033 clippy::too_many_arguments,
2034 reason = "aggregate lift reporting records the exact group, operator set, row counts, status, cap, and dynamic-programming state count"
2035)]
2036fn record_aggregate_lift_reports(
2037 aggregate_lifting: &mut Vec<AggregateLiftReport>,
2038 head: &Atom,
2039 group_key: &[Value],
2040 agg_specs: &[(AggOp, String)],
2041 deterministic_rows: usize,
2042 uncertain_rows: usize,
2043 status: AggregateLiftStatus,
2044 reason: &str,
2045 cap: usize,
2046 dynamic_programming_states: usize,
2047) {
2048 for (op, _) in agg_specs {
2049 aggregate_lifting.push(AggregateLiftReport {
2050 predicate: head.predicate.clone(),
2051 group_key: group_key.to_vec(),
2052 operator: agg_op_label(*op).to_string(),
2053 finite_domain_source: "grounded body rows".to_string(),
2054 deterministic_rows,
2055 uncertain_rows,
2056 domain_size: deterministic_rows + uncertain_rows,
2057 cap,
2058 status,
2059 reason: reason.to_string(),
2060 naive_outcomes: naive_outcome_count(uncertain_rows),
2061 dynamic_programming_states,
2062 });
2063 }
2064}
2065
2066fn agg_op_label(op: AggOp) -> &'static str {
2067 match op {
2068 AggOp::Count => "count",
2069 AggOp::Sum => "sum",
2070 AggOp::Min => "min",
2071 AggOp::Max => "max",
2072 AggOp::LogSumExp => "logsumexp",
2073 }
2074}
2075
2076fn naive_outcome_count(uncertain_rows: usize) -> u128 {
2077 if uncertain_rows >= u128::BITS as usize {
2078 u128::MAX
2079 } else {
2080 1u128 << uncertain_rows
2081 }
2082}
2083
2084fn count_lift_dp_states(uncertain_rows: usize) -> usize {
2085 (uncertain_rows + 1) * (uncertain_rows + 2) / 2
2086}
2087
2088type AggregatePlan = (
2089 Vec<String>,
2090 HashMap<String, usize>,
2091 Vec<(AggOp, String)>,
2092 HashMap<(AggOp, String), usize>,
2093);
2094
2095fn aggregate_head_plan(head: &Atom) -> Result<AggregatePlan> {
2096 let mut key_vars: Vec<String> = Vec::new();
2097 let mut key_var_to_pos: HashMap<String, usize> = HashMap::new();
2098 let mut agg_specs: Vec<(AggOp, String)> = Vec::new();
2099 let mut agg_to_pos: HashMap<(AggOp, String), usize> = HashMap::new();
2100
2101 for term in &head.terms {
2102 match term {
2103 Term::Variable(name) => {
2104 if !key_var_to_pos.contains_key(name) {
2105 let pos = key_vars.len();
2106 key_vars.push(name.clone());
2107 key_var_to_pos.insert(name.clone(), pos);
2108 }
2109 }
2110 Term::Aggregate(agg) => {
2111 let key = (agg.op, agg.variable.clone());
2112 if let std::collections::hash_map::Entry::Vacant(entry) =
2113 agg_to_pos.entry(key.clone())
2114 {
2115 let pos = agg_specs.len();
2116 agg_specs.push(key);
2117 entry.insert(pos);
2118 }
2119 }
2120 Term::Integer(_) | Term::Float(_) | Term::String(_) | Term::Symbol(_) => {}
2121 Term::Anonymous => {
2122 return Err(XlogError::Compilation(format!(
2123 "Anonymous variable in aggregate head of {} is not supported",
2124 head.predicate
2125 )));
2126 }
2127 Term::List(_) => {
2128 return Err(unsupported_probabilistic_term_error(
2129 "aggregate head planning",
2130 "list",
2131 ));
2132 }
2133 Term::Cons { .. } => {
2134 return Err(unsupported_probabilistic_term_error(
2135 "aggregate head planning",
2136 "cons",
2137 ));
2138 }
2139 Term::Compound { .. } => {
2140 return Err(unsupported_probabilistic_term_error(
2141 "aggregate head planning",
2142 "compound",
2143 ));
2144 }
2145 Term::PredRef(_) => {
2146 return Err(unsupported_probabilistic_term_error(
2147 "aggregate head planning",
2148 "predref",
2149 ));
2150 }
2151 }
2152 }
2153
2154 Ok((key_vars, key_var_to_pos, agg_specs, agg_to_pos))
2155}
2156
2157fn canonical_binding_key(binding: &HashMap<String, Value>) -> Vec<(String, Value)> {
2158 let mut key: Vec<(String, Value)> = binding
2159 .iter()
2160 .map(|(name, value)| (name.clone(), value.clone()))
2161 .collect();
2162 key.sort();
2163 key
2164}
2165
2166fn pir_const_value(builder: &PirBuilder, node: PirNodeId) -> Option<bool> {
2167 match builder.pir.node(node) {
2168 Some(crate::pir::PirNode::Const(value)) => Some(*value),
2169 _ => None,
2170 }
2171}
2172
2173fn update_aggregate_states(
2174 states: &mut [AggState],
2175 agg_specs: &[(AggOp, String)],
2176 row: &AggregateProvRow,
2177) -> Result<()> {
2178 for (idx, (op, var)) in agg_specs.iter().enumerate() {
2179 let v = row
2180 .binding
2181 .get(var)
2182 .ok_or_else(|| XlogError::UnsafeVariable(var.clone()))?;
2183 states[idx].update(*op, v)?;
2184 }
2185 Ok(())
2186}
2187
2188fn materialize_aggregate_tuple(
2189 head: &Atom,
2190 group_key: &[Value],
2191 key_var_to_pos: &HashMap<String, usize>,
2192 agg_specs: &[(AggOp, String)],
2193 agg_to_pos: &HashMap<(AggOp, String), usize>,
2194 agg_states: &[AggState],
2195) -> Result<Vec<Value>> {
2196 let mut tuple: Vec<Value> = Vec::with_capacity(head.terms.len());
2197 for term in &head.terms {
2198 match term {
2199 Term::Variable(name) => {
2200 let pos = *key_var_to_pos.get(name).ok_or_else(|| {
2201 XlogError::Compilation(format!(
2202 "Aggregate head variable {} is not a group key",
2203 name
2204 ))
2205 })?;
2206 tuple.push(group_key[pos].clone());
2207 }
2208 Term::Aggregate(AggExpr { op, variable }) => {
2209 let idx = *agg_to_pos
2210 .get(&(*op, variable.clone()))
2211 .expect("agg_to_pos missing");
2212 let spec = agg_specs
2213 .get(idx)
2214 .expect("aggregate state index should have a spec");
2215 tuple.push(agg_states[idx].finish(spec.0)?);
2216 }
2217 Term::Integer(_) | Term::Float(_) | Term::String(_) | Term::Symbol(_) => {
2218 tuple.push(value_from_term(term)?);
2219 }
2220 Term::Anonymous => unreachable!("aggregate head plan rejects anonymous terms"),
2221 Term::List(_) => {
2222 return Err(unsupported_probabilistic_term_error(
2223 "aggregate head materialization",
2224 "list",
2225 ));
2226 }
2227 Term::Cons { .. } => {
2228 return Err(unsupported_probabilistic_term_error(
2229 "aggregate head materialization",
2230 "cons",
2231 ));
2232 }
2233 Term::Compound { .. } => {
2234 return Err(unsupported_probabilistic_term_error(
2235 "aggregate head materialization",
2236 "compound",
2237 ));
2238 }
2239 Term::PredRef(_) => {
2240 return Err(unsupported_probabilistic_term_error(
2241 "aggregate head materialization",
2242 "predref",
2243 ));
2244 }
2245 }
2246 }
2247 Ok(tuple)
2248}
2249
2250fn select_relation<'a>(
2251 atom: &Atom,
2252 body_index: usize,
2253 global: &'a BTreeMap<String, Relation>,
2254 full_scc: &'a BTreeMap<String, Relation>,
2255 delta_scc: Option<(usize, &'a BTreeMap<String, Relation>)>,
2256) -> Result<&'a Relation> {
2257 if let Some((delta_index, delta_map)) = delta_scc {
2258 if delta_index == body_index {
2259 return delta_map.get(&atom.predicate).ok_or_else(|| {
2260 XlogError::Compilation(format!(
2261 "Missing delta relation for predicate {}",
2262 atom.predicate
2263 ))
2264 });
2265 }
2266 }
2267 if let Some(rel) = full_scc.get(&atom.predicate) {
2268 return Ok(rel);
2269 }
2270 global
2271 .get(&atom.predicate)
2272 .ok_or_else(|| XlogError::Compilation(format!("Unknown predicate {}", atom.predicate)))
2273}
2274
2275pub(crate) fn unify_atom(
2276 atom: &Atom,
2277 tuple: &[Value],
2278 binding: &mut HashMap<String, Value>,
2279) -> Result<bool> {
2280 if atom.terms.len() != tuple.len() {
2281 return Err(XlogError::Compilation(format!(
2282 "Arity mismatch for {}: atom has {}, tuple has {}",
2283 atom.predicate,
2284 atom.terms.len(),
2285 tuple.len()
2286 )));
2287 }
2288 for (term, value) in atom.terms.iter().zip(tuple.iter()) {
2289 match term {
2290 Term::Variable(name) => match binding.get(name) {
2291 Some(existing) => {
2292 if existing != value {
2293 return Ok(false);
2294 }
2295 }
2296 None => {
2297 binding.insert(name.clone(), value.clone());
2298 }
2299 },
2300 Term::Anonymous => {}
2301 Term::Integer(_) | Term::Float(_) | Term::String(_) | Term::Symbol(_) => {
2302 if &value_from_term(term)? != value {
2303 return Ok(false);
2304 }
2305 }
2306 Term::Aggregate(AggExpr { op: _, variable: _ }) => {
2307 return Err(XlogError::Compilation(
2308 "Aggregation not supported in provenance extraction".to_string(),
2309 ));
2310 }
2311 Term::List(_) => {
2312 return Err(unsupported_probabilistic_term_error("unification", "list"))
2313 }
2314 Term::Cons { .. } => {
2315 return Err(unsupported_probabilistic_term_error("unification", "cons"))
2316 }
2317 Term::Compound { .. } => {
2318 return Err(unsupported_probabilistic_term_error(
2319 "unification",
2320 "compound",
2321 ));
2322 }
2323 Term::PredRef(_) => {
2324 return Err(unsupported_probabilistic_term_error(
2325 "unification",
2326 "predref",
2327 ))
2328 }
2329 }
2330 }
2331 Ok(true)
2332}
2333
2334fn materialize_head(head: &Atom, binding: &HashMap<String, Value>) -> Result<Vec<Value>> {
2335 let mut out = Vec::with_capacity(head.terms.len());
2336 for term in &head.terms {
2337 match term {
2338 Term::Variable(name) => {
2339 let v = binding.get(name).ok_or_else(|| {
2340 XlogError::Compilation(format!(
2341 "Unbound head variable {} in {}",
2342 name, head.predicate
2343 ))
2344 })?;
2345 out.push(v.clone());
2346 }
2347 Term::Anonymous => {
2348 return Err(XlogError::Compilation(format!(
2349 "Anonymous variable in head of {} is not supported",
2350 head.predicate
2351 )));
2352 }
2353 Term::Integer(_) | Term::Float(_) | Term::String(_) | Term::Symbol(_) => {
2354 out.push(value_from_term(term)?);
2355 }
2356 Term::Aggregate(AggExpr {
2357 op: AggOp::Count,
2358 variable: _,
2359 })
2360 | Term::Aggregate(AggExpr {
2361 op: AggOp::Sum,
2362 variable: _,
2363 })
2364 | Term::Aggregate(AggExpr {
2365 op: AggOp::Min,
2366 variable: _,
2367 })
2368 | Term::Aggregate(AggExpr {
2369 op: AggOp::Max,
2370 variable: _,
2371 })
2372 | Term::Aggregate(AggExpr {
2373 op: AggOp::LogSumExp,
2374 variable: _,
2375 }) => {
2376 return Err(XlogError::Compilation(
2377 "Aggregation not supported in provenance extraction".to_string(),
2378 ));
2379 }
2380 Term::List(_) => {
2381 return Err(unsupported_probabilistic_term_error(
2382 "head materialization",
2383 "list",
2384 ));
2385 }
2386 Term::Cons { .. } => {
2387 return Err(unsupported_probabilistic_term_error(
2388 "head materialization",
2389 "cons",
2390 ));
2391 }
2392 Term::Compound { .. } => {
2393 return Err(unsupported_probabilistic_term_error(
2394 "head materialization",
2395 "compound",
2396 ));
2397 }
2398 Term::PredRef(_) => {
2399 return Err(unsupported_probabilistic_term_error(
2400 "head materialization",
2401 "predref",
2402 ));
2403 }
2404 }
2405 }
2406 Ok(out)
2407}
2408
2409#[cfg(test)]
2410pub(crate) fn eval_comparison(
2411 op: CompOp,
2412 left: &Term,
2413 right: &Term,
2414 binding: &HashMap<String, Value>,
2415) -> Result<bool> {
2416 eval_comparison_with_arithmetic_bindings(op, left, right, binding, &HashMap::new())
2417}
2418
2419pub(crate) fn eval_comparison_with_arithmetic_bindings(
2420 op: CompOp,
2421 left: &Term,
2422 right: &Term,
2423 binding: &HashMap<String, Value>,
2424 arithmetic_bindings: &HashMap<String, ArithmeticValue>,
2425) -> Result<bool> {
2426 let bound_left = bound_arithmetic_value(left, binding, arithmetic_bindings);
2427 let bound_right = bound_arithmetic_value(right, binding, arithmetic_bindings);
2428 let left_type = bound_left.as_ref().and_then(ArithmeticValue::scalar_type);
2429 let right_type = bound_right.as_ref().and_then(ArithmeticValue::scalar_type);
2430 let left = resolve_comparison_arithmetic_term(left, binding, bound_left, right_type)?;
2431 let right = resolve_comparison_arithmetic_term(right, binding, bound_right, left_type)?;
2432 compare_arithmetic_values(&left, op, &right)
2433}
2434
2435pub(crate) fn resolve_term(term: &Term, binding: &HashMap<String, Value>) -> Result<Value> {
2436 match term {
2437 Term::Variable(name) => binding.get(name).cloned().ok_or_else(|| {
2438 XlogError::Compilation(format!("Unbound variable {} in comparison", name))
2439 }),
2440 Term::Anonymous => Err(XlogError::Compilation(
2441 "Anonymous variable not allowed in comparison".to_string(),
2442 )),
2443 Term::Integer(_) | Term::Float(_) | Term::String(_) | Term::Symbol(_) => {
2444 value_from_term(term)
2445 }
2446 Term::Aggregate(_) => Err(XlogError::Compilation(
2447 "Aggregation not supported in provenance extraction".to_string(),
2448 )),
2449 Term::List(_) => Err(unsupported_probabilistic_term_error("comparison", "list")),
2450 Term::Cons { .. } => Err(unsupported_probabilistic_term_error("comparison", "cons")),
2451 Term::Compound { .. } => Err(unsupported_probabilistic_term_error(
2452 "comparison",
2453 "compound",
2454 )),
2455 Term::PredRef(_) => Err(unsupported_probabilistic_term_error(
2456 "comparison",
2457 "predref",
2458 )),
2459 }
2460}
2461
2462#[cfg(test)]
2463pub(crate) fn eval_arith_expr(expr: &ArithExpr, binding: &HashMap<String, Value>) -> Result<Value> {
2464 let value = eval_arithmetic_value(expr, binding, &HashMap::new())?;
2465 provenance_value_from_arithmetic(value)
2466}
2467
2468pub(crate) fn eval_arithmetic_value(
2469 expr: &ArithExpr,
2470 binding: &HashMap<String, Value>,
2471 arithmetic_bindings: &HashMap<String, ArithmeticValue>,
2472) -> Result<ArithmeticValue> {
2473 let bindings = binding
2474 .iter()
2475 .map(|(name, value)| (name.clone(), arithmetic_value_from_provenance(value)))
2476 .chain(
2477 arithmetic_bindings
2478 .iter()
2479 .map(|(name, value)| (name.clone(), value.clone())),
2480 )
2481 .collect::<HashMap<_, _>>();
2482 evaluate_arithmetic_expression(expr, &bindings)
2483}
2484
2485pub(crate) fn provenance_value_from_arithmetic(value: ArithmeticValue) -> Result<Value> {
2486 match value {
2487 ArithmeticValue::I32(value) => Ok(Value::I64(i64::from(value))),
2488 ArithmeticValue::I64(value) => Ok(Value::I64(value)),
2489 ArithmeticValue::U32(value) => Ok(Value::I64(i64::from(value))),
2490 ArithmeticValue::U64(value) => i64::try_from(value)
2491 .map(Value::I64)
2492 .map_err(|_| XlogError::Compilation("u64 arithmetic result exceeds i64".to_string())),
2493 ArithmeticValue::F32(value) => Ok(Value::F64(f64::from(value).to_bits())),
2494 ArithmeticValue::F64(value) => Ok(Value::F64(value.to_bits())),
2495 ArithmeticValue::Bool(value) => Ok(Value::I64(i64::from(value))),
2496 ArithmeticValue::Symbol(value) => Ok(Value::Symbol(value)),
2497 ArithmeticValue::String(value) => Ok(Value::String(value)),
2498 }
2499}
2500
2501pub(crate) fn bind_arithmetic_result(
2502 target: &str,
2503 value: ArithmeticValue,
2504 binding: &mut HashMap<String, Value>,
2505 arithmetic_bindings: &mut HashMap<String, ArithmeticValue>,
2506) -> Result<()> {
2507 if binding.contains_key(target) || arithmetic_bindings.contains_key(target) {
2508 return Err(XlogError::Compilation(format!(
2509 "Is-expression target {target} is already bound"
2510 )));
2511 }
2512 let provenance_value = provenance_value_from_arithmetic(value.clone())?;
2513 binding.insert(target.to_string(), provenance_value);
2514 arithmetic_bindings.insert(target.to_string(), value);
2515 Ok(())
2516}
2517
2518fn bound_arithmetic_value(
2519 term: &Term,
2520 binding: &HashMap<String, Value>,
2521 arithmetic_bindings: &HashMap<String, ArithmeticValue>,
2522) -> Option<ArithmeticValue> {
2523 if let Term::Variable(name) = term {
2524 if let Some(value) = arithmetic_bindings.get(name) {
2525 return Some(value.clone());
2526 }
2527 return binding.get(name).map(arithmetic_value_from_provenance);
2528 }
2529 None
2530}
2531
2532fn resolve_comparison_arithmetic_term(
2533 term: &Term,
2534 binding: &HashMap<String, Value>,
2535 bound: Option<ArithmeticValue>,
2536 peer_type: Option<ScalarType>,
2537) -> Result<ArithmeticValue> {
2538 if let Some(bound) = bound {
2539 return Ok(bound);
2540 }
2541 if let Some(peer_type) = peer_type {
2542 return ArithmeticValue::from_typed_term(term, peer_type);
2543 }
2544 resolve_term(term, binding).map(|value| arithmetic_value_from_provenance(&value))
2545}
2546
2547pub(crate) fn extend_arithmetic_bindings(
2548 atom: &Atom,
2549 tuple: &[Value],
2550 schemas: &HashMap<String, Schema>,
2551 arithmetic_bindings: &mut HashMap<String, ArithmeticValue>,
2552) -> Result<()> {
2553 let schema = schemas.get(&atom.predicate).ok_or_else(|| {
2554 XlogError::Compilation(format!(
2555 "Arithmetic evaluation requires a schema for predicate '{}'",
2556 atom.predicate
2557 ))
2558 })?;
2559 if atom.terms.len() != tuple.len() || schema.arity() != tuple.len() {
2560 return Err(XlogError::Compilation(format!(
2561 "Predicate '{}' row arity does not match its arithmetic schema",
2562 atom.predicate
2563 )));
2564 }
2565 for (index, (term, value)) in atom.terms.iter().zip(tuple).enumerate() {
2566 let Term::Variable(name) = term else {
2567 continue;
2568 };
2569 let scalar_type = schema.column_type(index).ok_or_else(|| {
2570 XlogError::Compilation(format!(
2571 "Arithmetic evaluation requires a type for '{}' column {}",
2572 atom.predicate,
2573 index + 1
2574 ))
2575 })?;
2576 let typed_value = arithmetic_value_from_typed_provenance(value, scalar_type)?;
2577 if let Some(existing) = arithmetic_bindings.get(name) {
2578 if existing.scalar_type() != typed_value.scalar_type()
2579 || !compare_arithmetic_values(existing, CompOp::Eq, &typed_value)?
2580 {
2581 return Err(XlogError::Compilation(format!(
2582 "Arithmetic binding for variable '{name}' has incompatible predicate types"
2583 )));
2584 }
2585 } else {
2586 arithmetic_bindings.insert(name.clone(), typed_value);
2587 }
2588 }
2589 Ok(())
2590}
2591
2592fn arithmetic_value_from_typed_provenance(
2593 value: &Value,
2594 scalar_type: ScalarType,
2595) -> Result<ArithmeticValue> {
2596 let mismatch = || {
2597 XlogError::Compilation(format!(
2598 "Provenance value is incompatible with declared {scalar_type:?} arithmetic type"
2599 ))
2600 };
2601 match (scalar_type, value) {
2602 (ScalarType::I32, Value::I64(value)) => i32::try_from(*value)
2603 .map(ArithmeticValue::I32)
2604 .map_err(|_| mismatch()),
2605 (ScalarType::I64, Value::I64(value)) => Ok(ArithmeticValue::I64(*value)),
2606 (ScalarType::U32, Value::I64(value)) => u32::try_from(*value)
2607 .map(ArithmeticValue::U32)
2608 .map_err(|_| mismatch()),
2609 (ScalarType::U64, Value::I64(value)) => u64::try_from(*value)
2610 .map(ArithmeticValue::U64)
2611 .map_err(|_| mismatch()),
2612 (ScalarType::F32, Value::F64(bits)) => {
2613 Ok(ArithmeticValue::F32(f64::from_bits(*bits) as f32))
2614 }
2615 (ScalarType::F64, Value::F64(bits)) => Ok(ArithmeticValue::F64(f64::from_bits(*bits))),
2616 (ScalarType::Bool, Value::I64(0)) => Ok(ArithmeticValue::Bool(false)),
2617 (ScalarType::Bool, Value::I64(1)) => Ok(ArithmeticValue::Bool(true)),
2618 (ScalarType::Symbol, Value::Symbol(value)) => Ok(ArithmeticValue::Symbol(*value)),
2619 (ScalarType::Symbol, Value::String(value)) => {
2620 Ok(ArithmeticValue::Symbol(symbol::intern(value)))
2621 }
2622 _ => Err(mismatch()),
2623 }
2624}
2625
2626fn arithmetic_value_from_provenance(value: &Value) -> ArithmeticValue {
2627 match value {
2628 Value::I64(value) => ArithmeticValue::I64(*value),
2629 Value::F64(bits) => ArithmeticValue::F64(f64::from_bits(*bits)),
2630 Value::Symbol(value) => ArithmeticValue::Symbol(*value),
2631 Value::String(value) => ArithmeticValue::String(value.clone()),
2632 }
2633}
2634
2635#[cfg(test)]
2636mod arithmetic_evaluation_tests {
2637 use super::*;
2638 use xlog_core::ScalarType;
2639
2640 #[test]
2641 fn provenance_uses_shared_cast_conditional_and_power_semantics() {
2642 let expression = ArithExpr::Conditional {
2643 cond_left: Box::new(ArithExpr::Integer(1)),
2644 cond_op: CompOp::Eq,
2645 cond_right: Box::new(ArithExpr::Integer(1)),
2646 then_expr: Box::new(ArithExpr::Cast(
2647 Box::new(ArithExpr::Pow(
2648 Box::new(ArithExpr::Integer(2)),
2649 Box::new(ArithExpr::Integer(3)),
2650 )),
2651 ScalarType::F32,
2652 )),
2653 else_expr: Box::new(ArithExpr::Cast(
2654 Box::new(ArithExpr::Integer(0)),
2655 ScalarType::F32,
2656 )),
2657 };
2658 assert_eq!(
2659 eval_arith_expr(&expression, &HashMap::new()).expect("provenance value"),
2660 Value::F64(8.0_f64.to_bits())
2661 );
2662 }
2663
2664 #[test]
2665 fn provenance_preserves_left_to_right_arithmetic_errors() {
2666 let expression = ArithExpr::Add(
2667 Box::new(ArithExpr::Variable("left_missing".to_string())),
2668 Box::new(ArithExpr::Variable("right_missing".to_string())),
2669 );
2670 let error = eval_arith_expr(&expression, &HashMap::new())
2671 .expect_err("unbound arithmetic must fail");
2672 assert!(error.to_string().contains("left_missing"), "{error}");
2673
2674 let error = eval_comparison(
2675 CompOp::Eq,
2676 &Term::Variable("left_missing".to_string()),
2677 &Term::Variable("right_missing".to_string()),
2678 &HashMap::new(),
2679 )
2680 .expect_err("unbound comparison must fail");
2681 assert!(error.to_string().contains("left_missing"), "{error}");
2682 assert!(!error.to_string().contains("right_missing"), "{error}");
2683 }
2684
2685 #[test]
2686 fn provenance_comparisons_share_runtime_nan_ordering_with_conditionals() {
2687 let nan_expression = ArithExpr::Div(
2688 Box::new(ArithExpr::Float(0.0)),
2689 Box::new(ArithExpr::Float(0.0)),
2690 );
2691 let nan = eval_arith_expr(&nan_expression, &HashMap::new()).expect("canonical NaN");
2692 let binding = HashMap::from([("X".to_string(), nan)]);
2693 assert!(eval_comparison(
2694 CompOp::Gt,
2695 &Term::Variable("X".to_string()),
2696 &Term::Float(f64::INFINITY),
2697 &binding,
2698 )
2699 .expect("body comparison"));
2700
2701 let conditional = ArithExpr::Conditional {
2702 cond_left: Box::new(ArithExpr::Variable("X".to_string())),
2703 cond_op: CompOp::Gt,
2704 cond_right: Box::new(ArithExpr::Float(f64::INFINITY)),
2705 then_expr: Box::new(ArithExpr::Integer(1)),
2706 else_expr: Box::new(ArithExpr::Integer(0)),
2707 };
2708 assert_eq!(
2709 eval_arith_expr(&conditional, &binding).expect("conditional comparison"),
2710 Value::I64(1)
2711 );
2712 assert!(eval_comparison(
2713 CompOp::Eq,
2714 &Term::Variable("X".to_string()),
2715 &Term::Variable("X".to_string()),
2716 &binding,
2717 )
2718 .expect("NaN equality"));
2719 }
2720
2721 #[test]
2722 fn exact_provenance_preserves_declared_and_sequential_arithmetic_widths() {
2723 let provenance = extract_from_source(
2724 "pred input(u32).\n\
2725 pred cast_input(i64).\n\
2726 pred input_float(f32).\n\
2727 pred wide_input(u32).\n\
2728 pred wide_copy(u32).\n\
2729 pred out_from_input(u32).\n\
2730 pred out_from_cast(u32).\n\
2731 pred input_at_least_one(u32).\n\
2732 pred float_at_least_one(f32).\n\
2733 0.5::input(1).\n\
2734 0.5::cast_input(1).\n\
2735 0.5::input_float(1.1).\n\
2736 0.5::wide_input(4294967295).\n\
2737 wide_copy(X) :- wide_input(X).\n\
2738 out_from_input(Y) :- input(X), Y is X + cast(1, u32).\n\
2739 out_from_cast(Z) :- cast_input(X), Y is cast(X, u32), Z is Y + cast(1, u32).\n\
2740 input_at_least_one(X) :- input(X), X >= 1.\n\
2741 float_at_least_one(X) :- input_float(X), X >= 1.0.\n\
2742 query(out_from_input(2)).\n\
2743 query(out_from_cast(2)).\n\
2744 query(input_at_least_one(1)).\n\
2745 query(float_at_least_one(1.1)).\n\
2746 query(wide_copy(4294967295)).\n",
2747 )
2748 .expect("extract typed arithmetic provenance");
2749
2750 for (predicate, expected) in [
2751 ("out_from_input", 2),
2752 ("out_from_cast", 2),
2753 ("input_at_least_one", 1),
2754 ] {
2755 assert!(
2756 provenance
2757 .query_formula(predicate, &[Value::I64(expected)])
2758 .is_some(),
2759 "missing derived query formula for {predicate}"
2760 );
2761 }
2762 assert!(
2763 provenance
2764 .query_formula("float_at_least_one", &[Value::F64(1.1_f64.to_bits())])
2765 .is_some(),
2766 "public lookup must canonicalize an f64 caller value to declared f32"
2767 );
2768 assert!(
2769 provenance
2770 .query_formula("wide_copy", &[Value::I64(i64::from(u32::MAX))])
2771 .is_some(),
2772 "public lookup must retain an in-range u32 boundary"
2773 );
2774 assert!(
2775 provenance
2776 .query_formula("wide_copy", &[Value::I64(-1)])
2777 .is_none(),
2778 "public lookup must reject a value outside the declared u32 range"
2779 );
2780 }
2781
2782 #[test]
2783 fn provenance_preserves_source_symbol_spelling_in_public_metadata() {
2784 let provenance = extract_from_source(
2785 "0.25::gate(\"alpha\").\n\
2786 0.1::gate(alpha).\n\
2787 0.4::route(\"beta\"); 0.6::route(gamma).\n\
2788 evidence(gate(\"alpha\"), true).\n\
2789 query(gate(\"alpha\")).\n",
2790 )
2791 .expect("extract quoted-symbol provenance");
2792
2793 let quoted_alpha = Value::String("alpha".to_string());
2794 assert_eq!(
2795 provenance.queries[0].args.as_slice(),
2796 std::slice::from_ref("ed_alpha)
2797 );
2798 assert_eq!(
2799 provenance.evidence[0].0.args.as_slice(),
2800 std::slice::from_ref("ed_alpha)
2801 );
2802 let leaf_values = provenance
2803 .leaf_atoms
2804 .values()
2805 .map(|atom| atom.args[0].clone())
2806 .collect::<Vec<_>>();
2807 assert_eq!(
2808 leaf_values,
2809 [quoted_alpha.clone(), Value::Symbol(symbol::intern("alpha"))]
2810 );
2811
2812 let choices = &provenance
2813 .choice_sources
2814 .values()
2815 .next()
2816 .expect("annotated-disjunction choice metadata")
2817 .choices;
2818 assert_eq!(choices[0].0.args, [Value::String("beta".to_string())]);
2819 assert_eq!(choices[1].0.args, [Value::Symbol(symbol::intern("gamma"))]);
2820
2821 let quoted_formula = provenance
2822 .query_formula("gate", std::slice::from_ref("ed_alpha))
2823 .expect("quoted symbol lookup");
2824 let bare_formula = provenance
2825 .query_formula("gate", &[Value::Symbol(symbol::intern("alpha"))])
2826 .expect("bare symbol lookup");
2827 assert_eq!(quoted_formula, bare_formula);
2828
2829 let gate_atoms = provenance
2830 .atoms_with_formulas()
2831 .filter(|(atom, _)| atom.predicate == "gate")
2832 .collect::<Vec<_>>();
2833 assert_eq!(gate_atoms.len(), 1);
2834 assert_eq!(
2835 gate_atoms[0].0.args,
2836 [Value::Symbol(symbol::intern("alpha"))]
2837 );
2838 }
2839
2840 #[test]
2841 fn exact_provenance_preserves_stratification_error_precedence() {
2842 let error = extract_from_source(
2843 "pred input(f32).\n\
2844 pred output(f64).\n\
2845 input(0.1).\n\
2846 output(X) :- input(X).\n\
2847 left() :- not right().\n\
2848 right() :- not left().\n",
2849 )
2850 .expect_err("ordinary negation cycle must fail before rule type validation");
2851
2852 assert!(
2853 matches!(error, XlogError::StratificationCycle(_)),
2854 "unexpected error: {error}"
2855 );
2856 }
2857
2858 #[test]
2859 fn exact_provenance_indexes_runtime_f32_nan_and_infinity() {
2860 let provenance = extract_from_source(
2861 "pred seed().\n\
2862 pred nan_value(f32).\n\
2863 pred infinite_value(f32).\n\
2864 0.5::seed().\n\
2865 nan_value(Y) :- seed(), Y is cast(0.0, f32) / cast(0.0, f32).\n\
2866 infinite_value(Y) :- seed(), Y is cast(1.0, f32) / cast(0.0, f32).\n",
2867 )
2868 .expect("extract non-finite f32 provenance");
2869
2870 assert!(provenance
2871 .query_formula("nan_value", &[Value::F64(f64::from(f32::NAN).to_bits())])
2872 .is_some());
2873 assert!(provenance
2874 .query_formula(
2875 "infinite_value",
2876 &[Value::F64(f64::from(f32::INFINITY).to_bits())]
2877 )
2878 .is_some());
2879 }
2880
2881 #[test]
2882 fn exact_provenance_rejects_arithmetic_results_outside_public_value_range() {
2883 let error = extract_from_source(
2884 "pred seed().\n\
2885 pred wrapped_u64(u64).\n\
2886 0.5::seed().\n\
2887 wrapped_u64(Z) :- seed(), Y is cast(0 - 1, u64), Z is Y + cast(1, u64).\n\
2888 query(wrapped_u64(0)).\n",
2889 )
2890 .expect_err("non-representable u64 intermediate must fail before relational use");
2891
2892 assert!(
2893 error
2894 .to_string()
2895 .contains("u64 arithmetic result exceeds i64"),
2896 "unexpected error: {error}"
2897 );
2898 }
2899
2900 #[test]
2901 fn wfs_grounding_preserves_declared_arithmetic_widths() {
2902 extract_from_source(
2903 "pred input(u32).\n\
2904 pred left(u32).\n\
2905 pred right(u32).\n\
2906 0.5::input(1).\n\
2907 left(Y) :- input(X), Y is X + cast(1, u32), not right(Y).\n\
2908 right(Y) :- input(X), Y is X + cast(1, u32), not left(Y).\n\
2909 query(left(2)).\n",
2910 )
2911 .expect("ground typed arithmetic before WFS evaluation");
2912 }
2913
2914 #[test]
2915 fn wfs_grounding_rejects_arithmetic_results_outside_public_value_range() {
2916 let error = extract_from_source(
2917 "pred seed().\n\
2918 pred left().\n\
2919 pred right(u64).\n\
2920 0.5::seed().\n\
2921 left() :- seed(), Y is cast(0 - 1, u64), not right(Y).\n\
2922 right(Y) :- seed(), Y is cast(0 - 1, u64), not left().\n\
2923 query(left()).\n",
2924 )
2925 .expect_err("WFS grounding must reject non-representable arithmetic bindings");
2926
2927 assert!(
2928 error
2929 .to_string()
2930 .contains("u64 arithmetic result exceeds i64"),
2931 "unexpected error: {error}"
2932 );
2933 }
2934}