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