Skip to main content

xlog_prob/
provenance.rs

1//! Provenance extraction from XLOG programs into PIR.
2
3use 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/// Metadata for a single Bernoulli decision stage in an annotated disjunction.
69#[derive(Debug, Clone, PartialEq)]
70pub struct ChoiceSource {
71    /// Explicit heads of the annotated disjunction, paired with their declared
72    /// (marginal) probabilities. Does not include the synthetic implicit "none"
73    /// branch. Shared (`Arc`) across every Bernoulli chain variable of the same
74    /// disjunction so that a k-head disjunction pays for one k-length vector,
75    /// not O(k) independent clones of it.
76    pub choices: Arc<[(GroundAtom, f64)]>,
77    /// Position of this ChoiceVarId in the m-1 Bernoulli decision chain.
78    pub choice_index: usize,
79    /// Enclosing annotated-disjunction identity. `None` in v1.
80    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    /// Children of interned Or nodes, used to flatten nested ORs and apply
203    /// absorption during normalization. Nodes absent from the map are opaque.
204    or_children: HashMap<PirNodeId, Vec<PirNodeId>>,
205    /// Children of interned And nodes (same role as `or_children`).
206    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        // Flatten nested ANDs (associativity) so recursive-SCC provenance cannot
263        // grow syntactically forever while staying semantically fixed.
264        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        // Absorption: a ∧ (a ∨ b) = a — drop any Or-child containing another member.
285        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        // Flatten nested ORs (associativity) — see `and` for rationale.
312        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        // Absorption: a ∨ (a ∧ b) = a — drop any And-child containing another member.
333        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/// Provenance extraction result: PIR graph plus per-tuple formulas and weight metadata.
390#[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    /// Iterate over canonical semantic tuple keys and their provenance formulas.
426    ///
427    /// Unlike source-facing query, evidence, leaf, and choice metadata, these keys
428    /// describe execution identity. Schema-equivalent quoted and bare symbol
429    /// spellings therefore share one key, and derived tuples may have no unique
430    /// source spelling.
431    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    // Stratify first to fail fast on unsupported recursion patterns.
623    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    // Deterministic facts.
640    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    // Probabilistic facts.
649    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    // Annotated disjunctions: lower to a chain of Bernoulli decisions.
670    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    // Evaluate rules SCC-by-SCC (semi-naive for recursive SCCs).
702    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    // Use analyze_stratification to detect non-monotone SCCs
708    let strat_result = analyze_stratification(program);
709    let sccs = find_sccs_for_lowering(&graph);
710
711    // Build a set of SCC indices that are non-monotone
712    // We need to map the SCCs from find_sccs_for_lowering to analyze_stratification
713    // Both use the same SCC algorithm, so indices should match
714    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        // Note: Negation is now supported via stratified evaluation and negate_provenance()
725        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        // Check if any predicate in this SCC is in a non-monotone cycle
743        let is_non_monotone = scc.iter().any(|p| non_monotone_scc_preds.contains(p));
744
745        if is_non_monotone {
746            // Use WFS for non-monotone SCCs (cycles through negation)
747            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    // Snapshot tuple formulas.
772    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    // Built once per disjunction and shared (Arc) across all m-1 chain variables
894    // below, instead of being deep-cloned per variable (which would be O(k^2)
895    // GroundAtom clones for a k-head disjunction).
896    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        // None branch consumes the final remaining probability; it produces no fact.
972        // We still need the decision variables so probabilities normalize.
973    }
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    // Snapshot full relations for the SCC.
1037    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    // Seed: evaluate all rules once against the current full snapshot.
1044    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    // Write back SCC relations.
1146    for (pred, rel) in full {
1147        store.insert(pred, rel);
1148    }
1149
1150    Ok(())
1151}
1152
1153/// Evaluate a non-monotone SCC using Well-Founded Semantics.
1154///
1155/// This function handles SCCs that have cycles through negation. It:
1156/// 1. Grounds the rules by enumerating all variable bindings from existing tuples
1157/// 2. Converts ground rules to WFS rules
1158/// 3. Calls WFS to compute the well-founded model
1159/// 4. Stores the results (true atoms with provenance) back
1160///
1161/// Undefined atoms (those in a true cycle) get no provenance (probability 0).
1162fn 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    // Step 1: Ground all rules in the SCC
1172    // We enumerate all possible variable bindings by iterating over existing tuples
1173    let mut wfs_rules: Vec<WfsRule> = Vec::new();
1174
1175    for rule in rules {
1176        // Ground this rule against the current store
1177        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        // No ground rules, nothing to do
1183        return Ok(());
1184    }
1185
1186    // Step 2: Call WFS to compute the well-founded model
1187    let wfs_result = evaluate_wfs_rules(&wfs_rules, &mut builder.pir, &WfsConfig::default())?;
1188
1189    // Step 3: Store the results back
1190    // True atoms get their provenance, false/undefined atoms are not added
1191    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
1202/// Ground a rule for WFS evaluation.
1203///
1204/// This generates all ground instances of a rule by iterating over existing tuples
1205/// that match the body literals (excluding SCC predicates which are handled by WFS).
1206fn 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    // Start with empty binding
1214    let mut bindings: Vec<ProvenanceEvaluationState> =
1215        vec![(HashMap::new(), HashMap::new(), builder.const_true())];
1216
1217    // Collect body literals that are in the SCC (will become WFS body literals)
1218    // and non-SCC literals (will be grounded now)
1219    let mut wfs_body_template: Vec<(usize, bool)> = Vec::new(); // (body_index, is_positive)
1220
1221    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                    // This will become a WFS body literal
1226                    wfs_body_template.push((idx, true));
1227                } else {
1228                    // Ground now by iterating over existing tuples
1229                    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                        // If relation doesn't exist, no tuples match
1254                    }
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                    // This will become a WFS negative body literal
1264                    wfs_body_template.push((idx, false));
1265                } else {
1266                    // Ground now: negation of non-SCC predicate
1267                    let rel = store.get(&atom.predicate);
1268                    let mut next_bindings = Vec::new();
1269
1270                    for (binding, arithmetic_bindings, prov) in bindings {
1271                        // Check if all variables in the negated atom are bound
1272                        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                            // Skip unsafe negation
1279                            continue;
1280                        }
1281
1282                        if let Some(rel) = rel {
1283                            // Collect matching tuples
1284                            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                                // No matches - closed world: negation succeeds
1294                                next_bindings.push((binding, arithmetic_bindings, prov));
1295                            } else {
1296                                // Negate the combined provenance
1297                                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                            // Relation doesn't exist - closed world: negation succeeds
1304                            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    // Now create WFS rules for each binding
1364    let mut result: Vec<WfsRule> = Vec::new();
1365
1366    for (binding, _, external_prov) in bindings {
1367        // Build the WFS body from SCC literals
1368        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            // Ground the atom with the current binding
1377            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                            // Variable not bound - this shouldn't happen for well-formed rules
1385                            // Skip this ground instance
1386                            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        // Build the ground head
1404        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                        // Unbound head variable - skip this instance
1412                        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
1428/// Negate a provenance formula, pushing negation to leaves (NNF form).
1429///
1430/// This implements the logical negation of a provenance formula by applying De Morgan's laws
1431/// to push negations down to the leaves. At the leaf level:
1432/// - `Lit { leaf }` becomes `NegLit { leaf }` (negated probabilistic fact)
1433/// - `NegLit { leaf }` becomes `Lit { leaf }` (double negation elimination)
1434/// - `Const(true)` becomes `Const(false)` and vice versa
1435fn 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), // Double negation elimination
1447        Some(PirNode::And { children }) => {
1448            // De Morgan: not(A and B) = (not A) or (not B)
1449            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            // De Morgan: not(A or B) = (not A) and (not B)
1457            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            // Negate both branches
1469            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
1477/// Evaluate a single rule and produce a map from head tuples to proof formulas.
1478///
1479/// `full_scc` is the per-SCC snapshot for recursive predicates; `delta_scc` is optional and
1480/// provides a delta relation for a specific body literal index.
1481fn 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                // Stratified negation: for each binding, check if any matching tuple exists.
1543                // - If a matching tuple exists with provenance P, the negation has provenance "not P"
1544                // - If no matching tuple exists, the negation succeeds trivially (closed-world assumption)
1545                //
1546                // For negated literals, we only use the global store and full_scc snapshot,
1547                // never the delta (negation is evaluated against the complete relation).
1548                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                    // Predicate not found - closed world assumption: all negations succeed
1554                    for (binding, arithmetic_bindings, prov) in states {
1555                        // Ensure all variables in the negated atom are bound
1556                        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                    // First, check if all variables in the negated atom are bound.
1573                    // Negation requires all variables to be bound (safety condition).
1574                    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                        // Skip this binding - variables must be bound before negation
1580                        continue;
1581                    }
1582
1583                    // Collect matching tuples and their provenances
1584                    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                            // A match was found; we need its negated provenance
1589                            matching_provs.push(*tuple_prov);
1590                        }
1591                    }
1592
1593                    if matching_provs.is_empty() {
1594                        // No matching tuples - closed world assumption: negation succeeds trivially
1595                        next_states.push((binding, arithmetic_bindings, prov));
1596                    } else {
1597                        // For negation to succeed, ALL matching tuples must be "absent" (negated).
1598                        // If tuple can exist via multiple provenances (disjunction), we negate that.
1599                        // Negation of (proof_a or proof_b or ...) =
1600                        // (not proof_a) and (not proof_b) and ...
1601                        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                // No deterministic rows and no uncertain row selected: the group
1791                // is empty in this outcome, so no head tuple materializes.
1792                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/// Factorized aggregate-outcome folding for non-count exact aggregates.
1812///
1813/// Instead of enumerating all `2^k` present/absent masks over the `k` uncertain
1814/// rows (one conjunctive PIR formula per mask), fold the rows one at a time
1815/// through a dynamic program keyed by the aggregate state reached so far.
1816/// Outcomes that agree on the aggregate state share one PIR sub-DAG, so the
1817/// emitted PIR is `O(k * #distinct-states)` instead of `O(2^k)` formulas.
1818///
1819/// Rows are folded in the same order as the previous mask enumeration
1820/// (deterministic rows first, then uncertain rows in index order), so every
1821/// outcome value is bit-identical to the enumerated result and the union of
1822/// worlds reaching each outcome is unchanged (identical query probabilities).
1823///
1824/// Returns the folded outcomes as `(aggregate states, any-uncertain-row-selected,
1825/// proof formula)` triples plus the total number of DP states visited.
1826#[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(&quoted_alpha)
2753        );
2754        assert_eq!(
2755            provenance.evidence[0].0.args.as_slice(),
2756            std::slice::from_ref(&quoted_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(&quoted_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}