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