Skip to main content

xlog_logic/
epistemic.rs

1//! Epistemic validation, reduction, and executable planning.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use xlog_core::{Result, XlogError};
6use xlog_ir::{
7    EirBodyLiteral, EirEpistemicLiteral, EirEpistemicMode, EirEpistemicOp, EirProgram, EirTerm,
8    EpistemicConstraintPlan, EpistemicExecutablePlan, EpistemicGpuPlan, EpistemicReductionPlan,
9    EpistemicSolverAssumptionBinding, EpistemicSolverServiceContract,
10    EpistemicTupleMembershipBinding, EpistemicWcojReductionStatus,
11};
12use xlog_stats::StatsSnapshot;
13
14use crate::ast::{
15    Atom, BodyLiteral, CompOp, Comparison, Constraint, EpistemicLiteral, EpistemicMode,
16    EpistemicOp, Program, Term,
17};
18use crate::build_eir;
19use crate::compile::Compiler;
20use crate::eir::convert_term;
21use crate::lower::{positive_body_bound_variables, Lowerer};
22
23/// Boolean truth value for bounded epistemic fixture evaluation.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TruthValue {
26    /// The literal is true.
27    True,
28    /// The literal is false.
29    False,
30}
31
32impl TruthValue {
33    fn from_bool(value: bool) -> Self {
34        if value {
35            TruthValue::True
36        } else {
37            TruthValue::False
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
43enum EpistemicTermKey {
44    Integer(i64),
45    FloatBits(u64),
46    String(String),
47    Symbol(u32),
48    List(Vec<EpistemicTermKey>),
49    Cons {
50        head: Box<EpistemicTermKey>,
51        tail: Box<EpistemicTermKey>,
52    },
53    Compound {
54        functor: String,
55        args: Vec<EpistemicTermKey>,
56    },
57    PredRef(String),
58}
59
60impl EpistemicTermKey {
61    fn from_term(term: &Term) -> Result<Self> {
62        Ok(match term {
63            Term::Integer(value) => Self::Integer(*value),
64            Term::Float(value) => Self::FloatBits(value.to_bits()),
65            Term::String(value) => Self::String(value.clone()),
66            Term::Symbol(value) => Self::Symbol(*value),
67            Term::List(items) => Self::List(
68                items
69                    .iter()
70                    .map(Self::from_term)
71                    .collect::<Result<Vec<_>>>()?,
72            ),
73            Term::Cons { head, tail } => Self::Cons {
74                head: Box::new(Self::from_term(head)?),
75                tail: Box::new(Self::from_term(tail)?),
76            },
77            Term::Compound { functor, args } => Self::Compound {
78                functor: functor.clone(),
79                args: args
80                    .iter()
81                    .map(Self::from_term)
82                    .collect::<Result<Vec<_>>>()?,
83            },
84            Term::PredRef(value) => Self::PredRef(value.clone()),
85            Term::Variable(_) | Term::Anonymous | Term::Aggregate(_) => {
86                return Err(XlogError::UnsupportedEpistemicConstruct {
87                    construct: "epistemic tuple key".to_string(),
88                    context: "tuple-key epistemic facts require ground terms".to_string(),
89                });
90            }
91        })
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
96enum EpistemicAtomKey {
97    Arity {
98        predicate: String,
99        arity: usize,
100    },
101    Ground {
102        predicate: String,
103        terms: Vec<EpistemicTermKey>,
104    },
105}
106
107impl EpistemicAtomKey {
108    fn from_arity(predicate: impl Into<String>, arity: usize) -> Self {
109        Self::Arity {
110            predicate: predicate.into(),
111            arity,
112        }
113    }
114
115    fn from_terms(predicate: impl Into<String>, terms: &[Term]) -> Result<Self> {
116        Ok(Self::Ground {
117            predicate: predicate.into(),
118            terms: terms
119                .iter()
120                .map(EpistemicTermKey::from_term)
121                .collect::<Result<Vec<_>>>()?,
122        })
123    }
124
125    fn predicate(&self) -> &str {
126        match self {
127            Self::Arity { predicate, .. } | Self::Ground { predicate, .. } => predicate,
128        }
129    }
130
131    fn arity(&self) -> usize {
132        match self {
133            Self::Arity { arity, .. } => *arity,
134            Self::Ground { terms, .. } => terms.len(),
135        }
136    }
137
138    fn matches_atom(&self, atom: &Atom) -> bool {
139        if self.predicate() != atom.predicate || self.arity() != atom.arity() {
140            return false;
141        }
142        match self {
143            Self::Arity { .. } => true,
144            Self::Ground { terms, .. } => atom
145                .terms
146                .iter()
147                .map(EpistemicTermKey::from_term)
148                .collect::<Result<Vec<_>>>()
149                .is_ok_and(|atom_terms| atom_terms == *terms),
150        }
151    }
152
153    fn overlaps(&self, other: &Self) -> bool {
154        if self.predicate() != other.predicate() || self.arity() != other.arity() {
155            return false;
156        }
157        matches!(self, Self::Arity { .. }) || matches!(other, Self::Arity { .. }) || self == other
158    }
159}
160
161/// Minimal interpretation used by G91/FAEEL distinction fixtures.
162#[derive(Debug, Clone, Default, PartialEq, Eq)]
163pub struct EpistemicInterpretation {
164    known: BTreeSet<EpistemicAtomKey>,
165    possible: BTreeSet<EpistemicAtomKey>,
166    rejected: BTreeSet<EpistemicAtomKey>,
167}
168
169impl EpistemicInterpretation {
170    /// Create an empty interpretation.
171    pub fn new() -> Self {
172        Self::default()
173    }
174
175    /// Mark a predicate/arity pair as known.
176    pub fn with_known(mut self, predicate: impl Into<String>, arity: usize) -> Self {
177        self.known
178            .insert(EpistemicAtomKey::from_arity(predicate, arity));
179        self
180    }
181
182    /// Mark a concrete tuple key as known.
183    pub fn with_known_terms(
184        mut self,
185        predicate: impl Into<String>,
186        terms: Vec<Term>,
187    ) -> Result<Self> {
188        self.known
189            .insert(EpistemicAtomKey::from_terms(predicate, &terms)?);
190        Ok(self)
191    }
192
193    /// Mark a predicate/arity pair as possible under G91 compatibility semantics.
194    pub fn with_possible(mut self, predicate: impl Into<String>, arity: usize) -> Self {
195        self.possible
196            .insert(EpistemicAtomKey::from_arity(predicate, arity));
197        self
198    }
199
200    /// Mark a concrete tuple key as possible under G91 compatibility semantics.
201    pub fn with_possible_terms(
202        mut self,
203        predicate: impl Into<String>,
204        terms: Vec<Term>,
205    ) -> Result<Self> {
206        self.possible
207            .insert(EpistemicAtomKey::from_terms(predicate, &terms)?);
208        Ok(self)
209    }
210
211    /// Mark a predicate/arity pair as rejected by the candidate.
212    pub fn with_rejected(mut self, predicate: impl Into<String>, arity: usize) -> Self {
213        self.rejected
214            .insert(EpistemicAtomKey::from_arity(predicate, arity));
215        self
216    }
217
218    /// Mark a concrete tuple key as rejected by the candidate.
219    pub fn with_rejected_terms(
220        mut self,
221        predicate: impl Into<String>,
222        terms: Vec<Term>,
223    ) -> Result<Self> {
224        self.rejected
225            .insert(EpistemicAtomKey::from_terms(predicate, &terms)?);
226        Ok(self)
227    }
228
229    fn first_contradiction(&self) -> Option<(String, usize)> {
230        self.known
231            .iter()
232            .find(|key| self.rejected.iter().any(|rejected| key.overlaps(rejected)))
233            .map(|key| (key.predicate().to_string(), key.arity()))
234    }
235
236    fn contains_known(&self, atom: &Atom) -> bool {
237        self.known.iter().any(|key| key.matches_atom(atom))
238    }
239
240    fn contains_possible(&self, atom: &Atom) -> bool {
241        self.possible.iter().any(|key| key.matches_atom(atom))
242    }
243
244    fn contains_rejected(&self, atom: &Atom) -> bool {
245        self.rejected.iter().any(|key| key.matches_atom(atom))
246    }
247
248    fn epistemic_guess_count(&self) -> usize {
249        self.known.len() + self.possible.len() + self.rejected.len()
250    }
251}
252
253/// One stable model in a bounded epistemic world-view fixture.
254#[derive(Debug, Clone, Default, PartialEq, Eq)]
255pub struct EpistemicWorld {
256    facts: BTreeSet<EpistemicAtomKey>,
257}
258
259impl EpistemicWorld {
260    /// Create an empty world.
261    pub fn new() -> Self {
262        Self::default()
263    }
264
265    /// Add a predicate/arity fact to this world.
266    pub fn with_fact(mut self, predicate: impl Into<String>, arity: usize) -> Self {
267        self.facts
268            .insert(EpistemicAtomKey::from_arity(predicate, arity));
269        self
270    }
271
272    /// Add a concrete tuple fact to this world.
273    pub fn with_fact_terms(
274        mut self,
275        predicate: impl Into<String>,
276        terms: Vec<Term>,
277    ) -> Result<Self> {
278        self.facts
279            .insert(EpistemicAtomKey::from_terms(predicate, &terms)?);
280        Ok(self)
281    }
282
283    fn contains(&self, atom: &Atom) -> bool {
284        self.facts.iter().any(|fact| fact.matches_atom(atom))
285    }
286}
287
288/// Non-empty set of accepted stable models used as the epistemic boundary.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct EpistemicWorldView {
291    worlds: Vec<EpistemicWorld>,
292}
293
294impl EpistemicWorldView {
295    /// Construct a non-empty world view.
296    pub fn from_worlds(worlds: Vec<EpistemicWorld>) -> Result<Self> {
297        if worlds.is_empty() {
298            return Err(xlog_core::XlogError::UnsupportedEpistemicConstruct {
299                construct: "world view boundary".to_string(),
300                context: "world view requires at least one stable model".to_string(),
301            });
302        }
303        Ok(Self { worlds })
304    }
305
306    /// Return the number of worlds in this view.
307    pub fn world_count(&self) -> usize {
308        self.worlds.len()
309    }
310
311    /// Evaluate an epistemic literal over this world view.
312    pub fn evaluate(&self, lit: &EpistemicLiteral) -> TruthValue {
313        let value = match lit.op {
314            EpistemicOp::Know => self.worlds.iter().all(|world| world.contains(&lit.atom)),
315            EpistemicOp::Possible => self.worlds.iter().any(|world| world.contains(&lit.atom)),
316        };
317
318        TruthValue::from_bool(if lit.negated { !value } else { value })
319    }
320}
321
322/// Build the production-facing GPU execution contract for an epistemic program.
323///
324/// This does not launch kernels. It proves that the semantic boundary can be
325/// represented as a GPU-native execution plan with explicit hot-path phases,
326/// required device buffers, WCOJ planning obligations, and a typed policy that
327/// rejects unsupported execution shapes instead of falling back.
328pub fn plan_epistemic_gpu_execution(program: &Program) -> Result<EpistemicGpuPlan> {
329    let mut prepared = program.clone();
330    if prepared.authored_constraint_source_bound.is_some() {
331        prepared.validate_prepared_authored_constraint_identity()?;
332    } else {
333        prepared.prepare_authored_constraint_identity_at_root()?;
334    }
335    plan_prepared_epistemic_gpu_execution(&prepared)
336}
337
338fn plan_prepared_epistemic_gpu_execution(program: &Program) -> Result<EpistemicGpuPlan> {
339    program.validate_prepared_authored_constraint_identity()?;
340    reject_recursive_epistemic_program(program)?;
341    validate_epistemic_relation_shapes(program, &BTreeSet::new())?;
342    let eir = build_eir(program)?;
343    // Modal dependency cycles are intercepted by the recursive reduction before this
344    // single-pass boundary. The remaining EIR has no co-evolving cycle, so one
345    // candidate enumeration and world-view validation is sufficient.
346    let mut epistemic_literals = Vec::new();
347    let mut reductions = Vec::new();
348    let mut tuple_membership_bindings = Vec::new();
349    let mut solver_assumption_bindings = Vec::new();
350
351    for (rule_index, rule) in eir.rules.iter().enumerate() {
352        let mut rule_epistemic_literals = Vec::new();
353        let mut positive_relational_atoms = Vec::new();
354        let mut has_negated_relational_atom = false;
355
356        for lit in &rule.body {
357            match lit {
358                EirBodyLiteral::Relational { negated, atom } => {
359                    if *negated {
360                        has_negated_relational_atom = true;
361                    } else {
362                        positive_relational_atoms.push(atom.clone());
363                    }
364                }
365                EirBodyLiteral::Epistemic(lit) => {
366                    rule_epistemic_literals.push(lit.clone());
367                }
368                EirBodyLiteral::Constraint | EirBodyLiteral::Binding => {}
369            }
370        }
371
372        if rule_epistemic_literals.is_empty() {
373            continue;
374        }
375
376        let reduction_index = reductions.len();
377        for lit in rule_epistemic_literals {
378            // Flatten any STRUCTURED finite+typed key term (`[a, b]`, `f(a, b)`)
379            // element-wise into scalar GPU key columns so the existing device
380            // tuple-key matcher binds/matches each element directly, and store the
381            // FLATTENED literal so its atom arity/terms equal the modal relation's
382            // (the plan validators and runtime read the same flattened shape).
383            // Scalar keys pass through unchanged; unbounded/untyped structured
384            // forms fail closed here with a precise finiteness diagnostic.
385            let lit = flatten_epistemic_literal(&lit)?;
386            let literal_index = epistemic_literals.len();
387            let augmented_head_terms = augmented_eir_head_terms(rule);
388            tuple_membership_bindings.push(EpistemicTupleMembershipBinding {
389                literal_index,
390                reduction_index,
391                predicate: lit.atom.predicate.clone(),
392                arity: lit.atom.arity,
393                key_columns: (0..lit.atom.arity).collect(),
394                bound_output_columns: bound_output_columns_for_terms(
395                    &lit.atom.terms,
396                    &augmented_head_terms,
397                ),
398                key_terms: lit.atom.terms.clone(),
399                op: lit.op,
400                negated: lit.negated,
401            });
402            solver_assumption_bindings.push(EpistemicSolverAssumptionBinding {
403                literal_index,
404                reduction_index,
405                predicate: lit.atom.predicate.clone(),
406                arity: lit.atom.arity,
407                terms: lit.atom.terms.clone(),
408                op: lit.op,
409                negated: lit.negated,
410            });
411            epistemic_literals.push(lit);
412        }
413        reductions.push(EpistemicReductionPlan {
414            rule_index,
415            head_predicate: rule.head.predicate.clone(),
416            public_head_arity: rule.head.terms.len(),
417            relational_body_atoms: positive_relational_atoms.len(),
418            wcoj_status: wcoj_status_for_reduction(
419                &positive_relational_atoms,
420                has_negated_relational_atom,
421            ),
422        });
423    }
424
425    if epistemic_literals.is_empty() {
426        return Err(xlog_core::XlogError::UnsupportedEpistemicConstruct {
427            construct: "epistemic GPU execution plan".to_string(),
428            context: "requires at least one epistemic literal".to_string(),
429        });
430    }
431
432    // World-view integrity constraints constrain accepted candidate world views.
433    // Each in-fragment constraint epistemic literal becomes a first-class
434    // epistemic literal sharing an existing reduction's active-model context, so
435    // its modal value is evaluated by the same GPU world-view validation path as
436    // rule-body modal literals. Out-of-fragment constraint shapes fail closed.
437    let constraints = lower_epistemic_constraints(
438        &eir,
439        &mut epistemic_literals,
440        &reductions,
441        &mut tuple_membership_bindings,
442        &mut solver_assumption_bindings,
443    )?;
444
445    let final_output_columns = final_output_columns_for_eir(&eir);
446    let gpu_plan = EpistemicGpuPlan::new(eir.mode, epistemic_literals, reductions)
447        .with_tuple_membership_bindings(tuple_membership_bindings)
448        .with_constraints(constraints)
449        .with_final_output_columns(final_output_columns)
450        .with_solver_contract(EpistemicSolverServiceContract::production_default(
451            solver_assumption_bindings,
452        ));
453    gpu_plan.validate_tuple_membership_bindings()?;
454    gpu_plan.validate_solver_contract()?;
455    gpu_plan.validate_constraints()?;
456    Ok(gpu_plan)
457}
458
459/// Lower in-fragment epistemic integrity constraints into first-class epistemic
460/// literals and return the per-constraint world-view constraint plans.
461///
462/// Each constraint epistemic literal is appended to `epistemic_literals` and
463/// given a tuple-membership binding plus solver assumption binding attached to
464/// the final rule reduction's active-model context. The constraint body's
465/// conjunction (over the appended literal indices) is what the device kernel
466/// rejects when it holds in an accepted world view.
467///
468/// Fail-closed (typed, with source context) when:
469/// - no rule reduction exists to host the constraint's modal evaluation;
470/// - a constraint body mixes relational/comparison/binding literals with the
471///   epistemic literals (only pure-modal constraint bodies are in fragment);
472/// - a constraint epistemic atom carries a non-ground tuple key (headless
473///   constraints have no reduced output column to bind variables against).
474fn lower_epistemic_constraints(
475    eir: &EirProgram,
476    epistemic_literals: &mut Vec<EirEpistemicLiteral>,
477    reductions: &[EpistemicReductionPlan],
478    tuple_membership_bindings: &mut Vec<EpistemicTupleMembershipBinding>,
479    solver_assumption_bindings: &mut Vec<EpistemicSolverAssumptionBinding>,
480) -> Result<Vec<EpistemicConstraintPlan>> {
481    let mut constraint_plans = Vec::new();
482    for constraint in &eir.constraints {
483        let constraint_index = constraint.authored_index.ok_or_else(|| {
484            XlogError::Compilation(
485                "prepared constraint compilation requires authored identities".to_string(),
486            )
487        })?;
488        let has_epistemic = constraint
489            .body
490            .iter()
491            .any(|lit| matches!(lit, EirBodyLiteral::Epistemic(_)));
492        if !has_epistemic {
493            // Purely relational constraints are handled by the reduced ordinary
494            // runtime plan; they are not world-view constraints.
495            continue;
496        }
497
498        if reductions.is_empty() {
499            return Err(XlogError::UnsupportedEpistemicConstruct {
500                construct: "epistemic GPU world-view constraint".to_string(),
501                context: format!(
502                    "constraint[{constraint_index}] is an epistemic integrity constraint but the \
503                     program has no epistemic rule to host its world-view evaluation; add an \
504                     epistemic rule whose reduced model provides the accepted world view, or \
505                     express the constraint over an existing epistemic rule"
506                ),
507            });
508        }
509        // Attach constraint modal evaluation to the final rule reduction's
510        // active-model context. The reduction's reduced output drives the
511        // `has_reduced_output` active-model gate used by world-view validation.
512        let reduction_index = reductions.len() - 1;
513
514        // First pass: flatten every epistemic literal (structured finite+typed
515        // keys reduce element-wise to scalar GPU key columns) and reject any
516        // non-epistemic body literal up front, so variable-multiplicity counting
517        // below sees the final flattened key shape. A non-epistemic literal makes
518        // the whole constraint out of fragment.
519        let mut flattened_literals = Vec::new();
520        for lit in &constraint.body {
521            match lit {
522                EirBodyLiteral::Epistemic(lit) => {
523                    flattened_literals.push(flatten_epistemic_literal(lit)?);
524                }
525                EirBodyLiteral::Relational { .. }
526                | EirBodyLiteral::Constraint
527                | EirBodyLiteral::Binding => {
528                    return Err(XlogError::UnsupportedEpistemicConstruct {
529                        construct: "epistemic GPU world-view constraint".to_string(),
530                        context: format!(
531                            "constraint[{constraint_index}] mixes non-epistemic body literals with \
532                             modal literals; world-view integrity constraints currently support \
533                             pure know/possible conjunctions so the constraint can be evaluated \
534                             against accepted world views without an ordinary-RIR rewrite"
535                        ),
536                    });
537                }
538            }
539        }
540
541        // Variable-keyed world-view constraints (`:- know p(X).`) range the key
542        // variable EXISTENTIALLY over the modal relation's tuple-key domain: the
543        // world view is pruned iff there EXISTS a binding for which the body
544        // holds. A constraint-local variable that occurs EXACTLY ONCE across the
545        // whole constraint body carries no join obligation, so it lowers to an
546        // ANONYMOUS wildcard key column — the existing GPU wildcard tuple-key
547        // matcher then ranges it over every accepted tuple, giving exact
548        // existential semantics with no host scan and no reduced head column.
549        //
550        // A variable that occurs MORE THAN ONCE (shared across literals as a join
551        // key `:- know p(X), possible q(X).`, or repeated within one literal as a
552        // diagonal `:- know p(X, X).`) cannot collapse to independent wildcards
553        // without weakening the constraint, so it fails closed here as unimplemented
554        // scope. This is finite+typed, NOT a finiteness/resource bound: the
555        // diagnostic stays a plain UnsupportedEpistemicConstruct, never a
556        // ResourceExhausted, so it is not mistaken for an unbounded-domain wall.
557        let mut variable_occurrences: std::collections::BTreeMap<String, usize> =
558            std::collections::BTreeMap::new();
559        for lit in &flattened_literals {
560            for term in &lit.atom.terms {
561                if let EirTerm::Variable(name) = term {
562                    *variable_occurrences.entry(name.clone()).or_insert(0) += 1;
563                }
564            }
565        }
566
567        let mut literal_indices = Vec::new();
568        for lit in flattened_literals {
569            // Anonymize single-occurrence constraint-local variables into wildcard
570            // key columns; reject shared/repeated variables (multiplicity > 1).
571            let mut anonymized_terms = Vec::with_capacity(lit.atom.terms.len());
572            for term in &lit.atom.terms {
573                match term {
574                    EirTerm::Integer(_) | EirTerm::Symbol(_) | EirTerm::Anonymous => {
575                        anonymized_terms.push(term.clone());
576                    }
577                    EirTerm::Variable(name) => {
578                        if variable_occurrences.get(name).copied().unwrap_or(0) > 1 {
579                            return Err(XlogError::UnsupportedEpistemicConstruct {
580                                construct: "epistemic GPU world-view constraint".to_string(),
581                                context: format!(
582                                    "constraint[{constraint_index}] reuses tuple-key variable \
583                                     {name} across literals/positions; shared-variable epistemic \
584                                     constraint joins (`:- know p(X), q(X).` / diagonal \
585                                     `:- know p(X, X).`) are not yet implemented for GPU world-view \
586                                     pruning. Single-occurrence variable keys (`:- know p(X).`) are \
587                                     supported and range existentially over the modal relation"
588                                ),
589                            });
590                        }
591                        // A NEGATED variable-keyed literal cannot collapse to a
592                        // wildcard: the wildcard computes `not (EXISTS X: know p(X))`
593                        // = `forall X: not know p(X)`, but a constraint variable is
594                        // EXISTENTIAL, so the body should fire on `EXISTS X: not
595                        // know p(X)`. forall-not != exists-not, so the wildcard would
596                        // mis-prune (it would prune iff p is EMPTY). Fail closed —
597                        // finite+typed UNIMPLEMENTED scope, NOT a finiteness bound, so
598                        // a plain UnsupportedEpistemicConstruct (never ResourceExhausted).
599                        // Negated ALL-GROUND constraint literals are unaffected (they
600                        // bind no variable, no quantifier flip — the path).
601                        //
602                        // Reaching here, `name` is SINGLE-occurrence (the multiplicity > 1
603                        // arm above already returned) AND appears under negation — so it has
604                        // NO positive binder and is NOT range-restricted. This is exactly the
605                        // unsafe shape ordinary Datalog rejects (`:- not r(X).`), so emit the
606                        // analogous NAF safety error rather than implying a missing feature.
607                        // The meaningful negated form `:- q(X), not know p(X).` binds X with a
608                        // positive literal (multiplicity > 1) and exits via the shared-variable
609                        // path above, so it never reaches this branch.
610                        if lit.negated {
611                            return Err(XlogError::Compilation(format!(
612                                "v0.8.5 naf error: unbound variable {name} in negated modal atom \
613                                 {}/{} in constraint[{constraint_index}]; bind it before not with \
614                                 a positive atom, or use '_' for existential positions",
615                                lit.atom.predicate, lit.atom.arity
616                            )));
617                        }
618                        // Single occurrence, POSITIVE: existential over the relation
619                        // domain == wildcard. Drop the variable identity (no join, no
620                        // head column to bind), routing this column through the GPU
621                        // wildcard tuple-key matcher.
622                        anonymized_terms.push(EirTerm::Anonymous);
623                    }
624                    other => {
625                        return Err(XlogError::UnsupportedEpistemicConstruct {
626                            construct: "epistemic GPU world-view constraint".to_string(),
627                            context: format!(
628                                "constraint[{constraint_index}] uses {} {}/{} with an unsupported \
629                                 tuple-key term {other:?}; headless world-view constraints support \
630                                 ground (integer/symbol) and single-occurrence variable/anonymous \
631                                 modal atoms",
632                                eir_epistemic_literal_label(&lit),
633                                lit.atom.predicate,
634                                lit.atom.arity
635                            ),
636                        });
637                    }
638                }
639            }
640            // Rebuild the literal with anonymized terms so the stored literal, its
641            // tuple-membership binding key_terms, and its solver assumption binding
642            // terms all carry the SAME shape (the plan validator requires
643            // binding.key_terms == literal.atom.terms).
644            let mut lit = lit;
645            lit.atom.terms = anonymized_terms;
646
647            let literal_index = epistemic_literals.len();
648            let bound_output_columns = vec![None; lit.atom.arity];
649            tuple_membership_bindings.push(EpistemicTupleMembershipBinding {
650                literal_index,
651                reduction_index,
652                predicate: lit.atom.predicate.clone(),
653                arity: lit.atom.arity,
654                key_columns: (0..lit.atom.arity).collect(),
655                key_terms: lit.atom.terms.clone(),
656                bound_output_columns,
657                op: lit.op,
658                negated: lit.negated,
659            });
660            solver_assumption_bindings.push(EpistemicSolverAssumptionBinding {
661                literal_index,
662                reduction_index,
663                predicate: lit.atom.predicate.clone(),
664                arity: lit.atom.arity,
665                terms: lit.atom.terms.clone(),
666                op: lit.op,
667                negated: lit.negated,
668            });
669            epistemic_literals.push(lit);
670            literal_indices.push(literal_index);
671        }
672
673        constraint_plans.push(EpistemicConstraintPlan {
674            constraint_index,
675            literal_indices,
676        });
677    }
678    Ok(constraint_plans)
679}
680
681/// Structural classification of an epistemic program with respect to ordinary
682/// (non-modal) recursion.
683///
684/// Recursion through positive/negated body literals normally fails closed in an
685/// epistemic program because the single-pass world-view executor cannot iterate a
686/// fixpoint. The well-defined sub-fragment "Case A" — recursion lives in the
687/// ordinary predicate while every modal atom in a recursion-participating rule is a
688/// positive `know`/`possible` over an *invariant* relation (an EDB or a lower
689/// non-recursive, non-epistemic stratum) — is admitted instead: the modal atom's
690/// extension is fixed independent of the recursion, so it can be resolved to its
691/// gated relation and the reduced ordinary program iterated by the existing
692/// recursive/semi-naive engine.
693#[derive(Debug, Clone, PartialEq, Eq)]
694pub enum RecursiveEpistemicClass {
695    /// The program has no ordinary or modal dependency cycle; the single-pass
696    /// epistemic world-view executor handles it.
697    NonRecursive,
698    /// Case A: ordinary recursion with every recursion-participating modal atom over
699    /// an invariant relation. Routed to the ordinary recursive engine after a
700    /// Case-A reduction (see [`reduce_case_a_epistemic_program_to_ordinary`]).
701    CaseA,
702    /// Case B: ordinary recursion where at least one POSITIVE `know`/`possible` modal
703    /// ranges over a NON-invariant relation that CO-EVOLVES with the recursion (the
704    /// modal target sits in the recursive SCC, or transitively depends on it). The
705    /// modal truth and the ordinary derivation are a single co-evolving founded least
706    /// fixpoint: resolving each positive modal to its (now recursive) ordinary atom and
707    /// iterating the existing semi-naive engine computes the FAEEL founded least
708    /// fixpoint directly — unfounded self-support is excluded by construction (the
709    /// least model of a positive program IS its founded model), so no separate
710    /// foundedness drop is needed. Routed exactly like Case A through
711    /// [`reduce_case_a_epistemic_program_to_ordinary`] and the ordinary recursive
712    /// engine.
713    ///
714    /// ADMISSION IS POLARITY/MODE-SCOPED (proved in
715    /// [`classify_recursive_epistemic_program`]): a NEGATED modal over a non-invariant
716    /// target is admitted when the reduced ordinary program is stratified; a genuine
717    /// negation cycle is delegated to the high-level GPU-backed WFS alternating-fixpoint
718    /// executor. A `possible` modal over a co-evolving target is admitted under FAEEL
719    /// as the founded least fixpoint. Under G91, exact head-tuple cycles are
720    /// intercepted by [`try_prepare_g91_compatibility_reduction`] and evaluated by an
721    /// explicit descending compatibility fixpoint.
722    CaseB,
723    /// Recursion arises entirely through modal dependencies rather than an ordinary
724    /// body cycle. FAEEL resolves the modal edges into an ordinary founded least
725    /// fixpoint. G91 exact head-tuple `possible` cycles use the explicit descending
726    /// compatibility plan; other admitted modal edges resolve to ordinary atoms. This
727    /// class cannot use the single-pass planner, which cannot distinguish a founded
728    /// predecessor chain from an unfounded tuple cycle.
729    ModalCycle,
730}
731
732/// Reject epistemic programs that contain an ordinary or modal dependency cycle before
733/// the single-pass GPU world-view planner.
734///
735/// [`plan_epistemic_gpu_execution`] builds a single-pass plan that evaluates each
736/// candidate world view exactly once; it cannot iterate a fixpoint. Admissible cycles
737/// are intercepted by recursive source preparation and delegated to either the
738/// ordinary recursive engine, GPU-backed WFS, or the explicit G91 compatibility
739/// fixpoint. This guard remains defense-in-depth for direct callers of the single-pass
740/// planner.
741fn reject_recursive_epistemic_program(program: &Program) -> Result<()> {
742    match classify_recursive_epistemic_program(program) {
743        Ok(RecursiveEpistemicClass::NonRecursive) => Ok(()),
744        Ok(
745            RecursiveEpistemicClass::CaseA
746            | RecursiveEpistemicClass::CaseB
747            | RecursiveEpistemicClass::ModalCycle,
748        ) => Err(recursive_epistemic_rejection(
749            "an epistemic program contains an ordinary or modal dependency cycle; the \
750                 single-pass epistemic GPU planner cannot iterate a fixpoint. Admissible \
751                 recursive epistemic programs require recursive source preparation and an \
752                 iterative execution plan, not this planner.",
753        )),
754        // Recursive shapes outside the admissible fragment already carry a specific
755        // typed diagnostic.
756        Err(err) => Err(err),
757    }
758}
759
760/// Classify ordinary and modal dependency cycles in an epistemic program.
761///
762/// Returns a typed [`XlogError::UnsupportedEpistemicConstruct`] for a recursive shape
763/// outside the supported ordinary, co-evolving, or modal-cycle fragments.
764pub fn classify_recursive_epistemic_program(program: &Program) -> Result<RecursiveEpistemicClass> {
765    let has_epistemic = program.rules.iter().any(|rule| {
766        rule.body
767            .iter()
768            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
769    });
770    if !has_epistemic {
771        // No epistemic literals: the ordinary recursive engine handles this program.
772        return Ok(RecursiveEpistemicClass::NonRecursive);
773    }
774
775    // Keep the ordinary graph separately from the full co-evolution graph. Positive
776    // and negated ordinary atoms participate in both. Modal atoms participate in the
777    // co-evolution graph because a modal dependency cycle must be solved as a
778    // fixpoint: treating it as single-pass either fabricates an unfounded tuple cycle
779    // or drops a valid transition from a founded predecessor.
780    let (ordinary_deps, deps) = epistemic_dependency_graphs(program);
781
782    let ordinary_recursive_predicates: BTreeSet<&str> = ordinary_deps
783        .keys()
784        .copied()
785        .filter(|pred| {
786            predicate_dependency_reaches(pred, pred, &ordinary_deps, &mut BTreeSet::new())
787        })
788        .collect();
789
790    // Collect every predicate in an ordinary-or-modal dependency cycle.
791    let recursive_predicates: BTreeSet<&str> = deps
792        .keys()
793        .copied()
794        .filter(|pred| predicate_dependency_reaches(pred, pred, &deps, &mut BTreeSet::new()))
795        .collect();
796
797    if recursive_predicates.is_empty() {
798        return Ok(RecursiveEpistemicClass::NonRecursive);
799    }
800    let modal_only_recursion = ordinary_recursive_predicates.is_empty();
801
802    // Recursion is present. Two admissible classes (anything else fails closed):
803    //
804    //   Case A — every modal atom is a POSITIVE `know`/`possible` over an INVARIANT
805    //   relation (extension fixed independent of the recursion). The recursion joins
806    //   against a fixed gated relation.
807    //
808    //   Case B — at least one POSITIVE `know`/`possible` modal ranges over a
809    //   NON-invariant relation that CO-EVOLVES with the recursion (the modal target is
810    //   itself recursive / epistemic / transitively depends on the recursion). Modal
811    //   truth and the ordinary derivation are a single founded least fixpoint: resolving
812    //   the positive modal to its (now recursive) ordinary atom and iterating the
813    //   semi-naive engine computes the FAEEL founded least fixpoint directly. The least
814    //   model of the resulting POSITIVE program IS its founded model, so unfounded
815    //   self-support is excluded by construction (no separate foundedness drop needed),
816    //   and a program with no founding simply yields the exact empty extension.
817    //
818    // FAEEL and non-compatibility G91 edges use the same positive-modal-to-positive-
819    // atom reduction, so the structural difference between Case A and Case B is whether
820    // the resolved relation is fixed or part of the SCC. Exact G91 head-tuple
821    // `possible` cycles are intercepted first and use the upper-bound/frozen-snapshot
822    // reduction. The whole program is scanned because either reduction rewrites every
823    // remaining modal literal.
824    //
825    // SOUNDNESS FLOOR:
826    //   * a NEGATED modal over a non-invariant target is admitted as Case B. If the
827    //     reduced program is stratified, ordinary stratified negation is enough; if it
828    //     contains a reduced cycle through negation, the high-level executor routes it
829    //     to GPU-backed WFS rather than host WFS.
830    //   * an exact head-tuple `possible` modal over a co-evolving target under G91 is
831    //     admitted only through the explicit descending compatibility reduction. FAEEL
832    //     `possible` remains the founded least fixpoint. A cycle carried only by modal
833    //     dependencies is classified as `ModalCycle`; execution then selects the
834    //     semantic reduction before it can reach the single-pass path.
835    let invariant = InvariantRelations::analyze(program);
836    let mut saw_case_b = false;
837    // A NEGATED modal over a NON-invariant target is admissible after reduction. The
838    // high-level executor chooses ordinary stratified execution or GPU-backed WFS based
839    // on the reduced program's monotonicity.
840    let mut saw_negated_non_invariant_modal = false;
841    for rule in &program.rules {
842        for lit in &rule.body {
843            let BodyLiteral::Epistemic(modal) = lit else {
844                continue;
845            };
846            if invariant.is_invariant(&modal.atom.predicate) {
847                // Modal over an INVARIANT relation: admissible Case-A. A positive
848                // `know`/`possible` resolves to a positive ordinary join over the gated
849                // relation; a NEGATED `not know`/`not possible` over an invariant
850                // relation equals ordinary `not R` (the world view agrees with R on an
851                // invariant relation), an anti-join with NO modal gating.
852                continue;
853            }
854
855            // NON-invariant modal target: the gated relation co-evolves with the
856            // recursion.
857            if modal.negated {
858                // A NEGATED modal over a NON-invariant relation is the deferred case.
859                // SOUNDNESS ARGUMENT (why stratification decides it): when the reduced
860                // ordinary program (`not know R` -> `not R`, `know R` -> `R`) is
861                // STRATIFIED, its perfect model is TOTAL and 2-valued. A total
862                // 2-valued model makes every modal target R 2-valued, so under FAEEL
863                // `know R == possible R == R` and `not know R == not possible R == not
864                // R` (the modal op stops mattering once R is determined -- the same
865                // equivalence established for DETERMINED targets, generalized
866                // here to STRATIFIED targets). Replacing each modal by its ordinary
867                // atom therefore preserves truth values, so the stratified perfect
868                // model of the reduced program IS the FAEEL model. The 2-valued
869                // (stratified) property is the linchpin.
870                //
871                // When the reduced program is NOT stratified (a cycle through the
872                // negation), the sound semantics is the 3-valued WELL-FOUNDED model
873                // (R partly UNDEFINED). Host-side WFS / stable-model solving remains
874                // precluded by the no-host-solver lock, so the high-level executor
875                // delegates that reduced program to the GPU-backed WFS path.
876                saw_negated_non_invariant_modal = true;
877                saw_case_b = true;
878                continue;
879            }
880
881            // POSITIVE `know` (any mode), FAEEL `possible`, or G91 `possible` over a
882            // co-evolving target: admissible Case B. FAEEL/know resolve to the
883            // ordinary atom. Exact G91 head-tuple `possible` cycles are intercepted by
884            // the compatibility reduction; remaining admitted modal edges resolve to
885            // ordinary atoms.
886            saw_case_b = true;
887        }
888    }
889
890    // NEGATED-MODAL DISCRIMINATOR: a deferred negated-modal-over-non-invariant is accepted
891    // as Case B. The high-level executor inspects the reduced ordinary program: no
892    // negation cycle routes to ordinary stratified execution; a negation cycle routes
893    // to the GPU-backed WFS alternating-fixpoint plan.
894    if saw_negated_non_invariant_modal {
895        // Stratified reduced programs continue through the ordinary semi-naive path.
896        // Non-monotone reduced programs are handled by the high-level GPU compiler's
897        // WFS plan; host WFS is not an accepted execution fallback.
898        let _reduced = reduce_case_a_epistemic_program_to_ordinary(program);
899    }
900
901    // SOUNDNESS GUARD: a recursive epistemic program (Case A/B) routes through the PURE
902    // ordinary semi-naive engine (`LogicExecutionPlan::Ordinary`), which never runs the
903    // world-view integrity-constraint kernel; the Case-A/B reduction DROPS every
904    // constraint that contains a modal literal. For a NON-recursive program the
905    // single-pass world-view path evaluates those constraints, but on the recursive
906    // route a co-occurring epistemic constraint (`:- know X` / `:- not know X`) would be
907    // SILENTLY IGNORED, yielding a result that includes rows a valid world view forbids.
908    // That is an UNSOUND admission (worse than a rejection), so fail closed when an
909    // epistemic constraint co-occurs with recursion. (Non-recursive epistemic-constraint
910    // Non-recursive epistemic-constraint programs never reach here; they run the
911    // constraint kernel on the single-pass path.)
912    let has_epistemic_constraint = program.constraints.iter().any(|constraint| {
913        constraint
914            .body
915            .iter()
916            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
917    });
918    if has_epistemic_constraint {
919        return Err(recursive_epistemic_rejection(
920            "a recursive epistemic program carries an epistemic integrity constraint \
921             (`:- know ...` / `:- not know ...`). Recursive reductions do not run the \
922             single-pass world-view constraint kernel and would otherwise drop the \
923             modal constraint, yielding a result that ignores it. To keep results sound \
924             this fails closed rather than silently dropping the constraint. \
925             Remove the recursion or express the integrity constraint over a \
926             non-recursive (single-pass) epistemic relation.",
927        ));
928    }
929
930    if modal_only_recursion {
931        debug_assert!(
932            saw_case_b,
933            "a modal-only cycle must have a co-evolving target"
934        );
935        Ok(RecursiveEpistemicClass::ModalCycle)
936    } else if saw_case_b {
937        Ok(RecursiveEpistemicClass::CaseB)
938    } else {
939        Ok(RecursiveEpistemicClass::CaseA)
940    }
941}
942
943type PredicateDependencyMap<'a> = BTreeMap<&'a str, BTreeSet<&'a str>>;
944
945fn epistemic_dependency_graphs(
946    program: &Program,
947) -> (PredicateDependencyMap<'_>, PredicateDependencyMap<'_>) {
948    let mut ordinary_dependencies = BTreeMap::new();
949    let mut all_dependencies = BTreeMap::new();
950    for rule in &program.rules {
951        let head = rule.head.predicate.as_str();
952        let all = all_dependencies.entry(head).or_insert_with(BTreeSet::new);
953        let ordinary = ordinary_dependencies
954            .entry(head)
955            .or_insert_with(BTreeSet::new);
956        for literal in &rule.body {
957            match literal {
958                BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
959                    all.insert(atom.predicate.as_str());
960                    ordinary.insert(atom.predicate.as_str());
961                }
962                BodyLiteral::Epistemic(modal) => {
963                    all.insert(modal.atom.predicate.as_str());
964                }
965                BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {}
966            }
967        }
968    }
969    (ordinary_dependencies, all_dependencies)
970}
971
972fn predicate_dependency_reaches<'a>(
973    start: &'a str,
974    target: &str,
975    dependencies: &BTreeMap<&'a str, BTreeSet<&'a str>>,
976    seen: &mut BTreeSet<&'a str>,
977) -> bool {
978    let Some(next) = dependencies.get(start) else {
979        return false;
980    };
981    for &predicate in next {
982        if predicate == target {
983            return true;
984        }
985        if seen.insert(predicate)
986            && predicate_dependency_reaches(predicate, target, dependencies, seen)
987        {
988            return true;
989        }
990    }
991    false
992}
993
994/// Modal dependency edges whose head and target belong to the same recursive
995/// component. The modal literal itself supplies the head-to-target edge; a return
996/// path from target to head proves SCC membership.
997fn recursive_modal_dependency_edges(program: &Program) -> BTreeSet<(String, String)> {
998    let (_, dependencies) = epistemic_dependency_graphs(program);
999    let mut edges = BTreeSet::new();
1000    for rule in &program.rules {
1001        for literal in &rule.body {
1002            let BodyLiteral::Epistemic(modal) = literal else {
1003                continue;
1004            };
1005            if modal.atom.predicate == rule.head.predicate
1006                || predicate_dependency_reaches(
1007                    modal.atom.predicate.as_str(),
1008                    rule.head.predicate.as_str(),
1009                    &dependencies,
1010                    &mut BTreeSet::new(),
1011                )
1012            {
1013                edges.insert((rule.head.predicate.clone(), modal.atom.predicate.clone()));
1014            }
1015        }
1016    }
1017    edges
1018}
1019
1020fn recursive_epistemic_rejection(context: &str) -> XlogError {
1021    XlogError::UnsupportedEpistemicConstruct {
1022        construct: "recursive epistemic program".to_string(),
1023        context: context.to_string(),
1024    }
1025}
1026
1027/// Predicates whose extension is fixed independent of any ordinary recursion or
1028/// epistemic literal in the program.
1029///
1030/// A predicate is invariant when it is EDB (defined only by ground facts) or its
1031/// entire transitive ordinary-definition closure is free of epistemic literals and of
1032/// ordinary recursion. Such a relation is computed once in a lower stratum, so a
1033/// positive `know`/`possible` over it has a fixed gated extension that a recursive
1034/// fixpoint can join against.
1035struct InvariantRelations<'a> {
1036    /// Ordinary (positive/negated) body-predicate edges per head predicate.
1037    ordinary_deps: BTreeMap<&'a str, BTreeSet<&'a str>>,
1038    /// Predicates whose definition (any defining non-fact rule) contains an epistemic
1039    /// body literal.
1040    epistemic_heads: BTreeSet<&'a str>,
1041    /// Predicates defined by at least one non-fact rule (i.e. not pure EDB).
1042    derived_heads: BTreeSet<&'a str>,
1043}
1044
1045impl<'a> InvariantRelations<'a> {
1046    fn analyze(program: &'a Program) -> Self {
1047        let mut ordinary_deps: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
1048        let mut epistemic_heads: BTreeSet<&str> = BTreeSet::new();
1049        let mut derived_heads: BTreeSet<&str> = BTreeSet::new();
1050        for rule in &program.rules {
1051            if rule.body.is_empty() {
1052                continue;
1053            }
1054            let head = rule.head.predicate.as_str();
1055            derived_heads.insert(head);
1056            let entry = ordinary_deps.entry(head).or_default();
1057            for lit in &rule.body {
1058                match lit {
1059                    BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
1060                        entry.insert(atom.predicate.as_str());
1061                    }
1062                    BodyLiteral::Epistemic(_) => {
1063                        epistemic_heads.insert(head);
1064                    }
1065                    BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {}
1066                }
1067            }
1068        }
1069        Self {
1070            ordinary_deps,
1071            epistemic_heads,
1072            derived_heads,
1073        }
1074    }
1075
1076    /// Whether `predicate`'s extension is fixed independent of the recursion.
1077    fn is_invariant(&self, predicate: &str) -> bool {
1078        let mut seen = BTreeSet::new();
1079        self.is_invariant_inner(predicate, &mut seen)
1080    }
1081
1082    fn is_invariant_inner<'b>(&'b self, predicate: &'b str, seen: &mut BTreeSet<&'b str>) -> bool {
1083        if !seen.insert(predicate) {
1084            // A cycle reaching `predicate` means recursion: not invariant.
1085            return false;
1086        }
1087        let invariant = if !self.derived_heads.contains(predicate) {
1088            // Pure EDB relation: invariant by construction.
1089            true
1090        } else if self.epistemic_heads.contains(predicate) {
1091            // Definition itself uses a modal literal: not a fixed lower stratum.
1092            false
1093        } else {
1094            match self.ordinary_deps.get(predicate) {
1095                None => true,
1096                Some(deps) => deps.iter().all(|dep| self.is_invariant_inner(dep, seen)),
1097            }
1098        };
1099        // `seen` is the active recursion stack, not a global visited set. Leaving a
1100        // completed dependency in it would mistake a shared acyclic dependency in a
1101        // diamond for a back edge when a sibling branch reaches the same predicate.
1102        seen.remove(predicate);
1103        invariant
1104    }
1105}
1106
1107fn eir_epistemic_literal_label(lit: &xlog_ir::EirEpistemicLiteral) -> &'static str {
1108    match (lit.negated, lit.op) {
1109        (false, EirEpistemicOp::Know) => "know",
1110        (false, EirEpistemicOp::Possible) => "possible",
1111        (true, EirEpistemicOp::Know) => "not know",
1112        (true, EirEpistemicOp::Possible) => "not possible",
1113    }
1114}
1115
1116fn has_independent_founded_support(eir: &EirProgram, atom: &xlog_ir::EirAtom) -> bool {
1117    if atom.arity > 0 && !atom.terms.iter().all(eir_term_is_ground) {
1118        return false;
1119    }
1120
1121    let mut support_stack = Vec::new();
1122    has_independent_founded_support_inner(eir, atom, &mut support_stack)
1123}
1124
1125/// Whether a ground atom is unconditionally derived by the authored ordinary rules.
1126///
1127/// Unlike `has_independent_founded_support`, this proof does not treat an undeclared
1128/// runtime EDB tuple as present merely because the predicate has no defining rule.
1129/// It is therefore suitable for proving that a global modal output gate is a no-op:
1130/// every positive dependency must itself be derivable from an explicit fact or an
1131/// ordinary rule, and constraints/bindings are rejected because EIR intentionally
1132/// erases the expression needed to prove them at this boundary.
1133fn has_unconditional_ground_founded_support(eir: &EirProgram, atom: &xlog_ir::EirAtom) -> bool {
1134    if !atom.terms.iter().all(eir_term_is_ground) {
1135        return false;
1136    }
1137
1138    let mut support_stack = Vec::new();
1139    has_unconditional_ground_founded_support_inner(eir, atom, &mut support_stack)
1140}
1141
1142fn has_unconditional_ground_founded_support_inner(
1143    eir: &EirProgram,
1144    atom: &xlog_ir::EirAtom,
1145    support_stack: &mut Vec<(String, Vec<EirTerm>)>,
1146) -> bool {
1147    let key = (atom.predicate.clone(), atom.terms.clone());
1148    if support_stack.iter().any(|ancestor| ancestor == &key) {
1149        return false;
1150    }
1151    support_stack.push(key);
1152
1153    let supported = eir.rules.iter().any(|rule| {
1154        let Some(substitution) = head_substitution_to_atom(&rule.head, atom) else {
1155            return false;
1156        };
1157        rule.body.iter().all(|literal| match literal {
1158            EirBodyLiteral::Relational {
1159                negated: false,
1160                atom,
1161            } => substitute_eir_atom(atom, &substitution).is_some_and(|atom| {
1162                atom.terms.iter().all(eir_term_is_ground)
1163                    && has_unconditional_ground_founded_support_inner(eir, &atom, support_stack)
1164            }),
1165            EirBodyLiteral::Epistemic(_)
1166            | EirBodyLiteral::Relational { negated: true, .. }
1167            | EirBodyLiteral::Constraint
1168            | EirBodyLiteral::Binding => false,
1169        })
1170    });
1171
1172    support_stack.pop();
1173    supported
1174}
1175
1176fn has_tuple_level_independent_founded_support(
1177    eir: &EirProgram,
1178    modal_rule: &xlog_ir::EirRule,
1179    atom: &xlog_ir::EirAtom,
1180) -> bool {
1181    if atom.arity == 0 {
1182        return false;
1183    }
1184
1185    let modal_domain = positive_relational_body_atoms(modal_rule);
1186    eir.rules.iter().any(|support_rule| {
1187        if !support_rule_head_matches_modal_atom(support_rule, atom) {
1188            return false;
1189        }
1190        let mut support_stack = vec![(atom.predicate.clone(), atom.arity)];
1191        if !eir_rule_has_independent_founded_body(eir, support_rule, &mut support_stack) {
1192            return false;
1193        }
1194        let Some(substitution) = head_substitution_to_atom(&support_rule.head, atom) else {
1195            return false;
1196        };
1197        let support_domain = positive_relational_body_atoms(support_rule);
1198        if support_domain.is_empty() {
1199            return false;
1200        }
1201        let Some(substituted_support_domain) = support_domain
1202            .iter()
1203            .map(|atom| substitute_eir_atom(atom, &substitution))
1204            .collect::<Option<Vec<_>>>()
1205        else {
1206            return false;
1207        };
1208        substituted_support_domain.iter().all(|support_atom| {
1209            modal_domain
1210                .iter()
1211                .any(|modal_atom| modal_atom == support_atom)
1212        })
1213    })
1214}
1215
1216fn positive_relational_body_atoms(rule: &xlog_ir::EirRule) -> Vec<xlog_ir::EirAtom> {
1217    rule.body
1218        .iter()
1219        .filter_map(|lit| match lit {
1220            EirBodyLiteral::Relational {
1221                negated: false,
1222                atom,
1223            } => Some(atom.clone()),
1224            _ => None,
1225        })
1226        .collect()
1227}
1228
1229fn support_rule_head_matches_modal_atom(rule: &xlog_ir::EirRule, atom: &xlog_ir::EirAtom) -> bool {
1230    rule.head.predicate == atom.predicate
1231        && rule.head.arity == atom.arity
1232        && head_substitution_to_atom(&rule.head, atom).is_some()
1233}
1234
1235fn head_substitution_to_atom(
1236    head: &xlog_ir::EirAtom,
1237    atom: &xlog_ir::EirAtom,
1238) -> Option<BTreeMap<String, EirTerm>> {
1239    if head.predicate != atom.predicate || head.arity != atom.arity {
1240        return None;
1241    }
1242    let mut substitution = BTreeMap::new();
1243    for (head_term, atom_term) in head.terms.iter().zip(&atom.terms) {
1244        match head_term {
1245            EirTerm::Variable(name) => match substitution.get(name) {
1246                Some(existing) if existing != atom_term => return None,
1247                Some(_) => {}
1248                None => {
1249                    substitution.insert(name.clone(), atom_term.clone());
1250                }
1251            },
1252            EirTerm::Anonymous => return None,
1253            other if other == atom_term => {}
1254            _ => return None,
1255        }
1256    }
1257    Some(substitution)
1258}
1259
1260fn substitute_eir_atom(
1261    atom: &xlog_ir::EirAtom,
1262    substitution: &BTreeMap<String, EirTerm>,
1263) -> Option<xlog_ir::EirAtom> {
1264    let terms = atom
1265        .terms
1266        .iter()
1267        .map(|term| substitute_eir_term(term, substitution))
1268        .collect::<Option<Vec<_>>>()?;
1269    Some(xlog_ir::EirAtom {
1270        predicate: atom.predicate.clone(),
1271        arity: atom.arity,
1272        terms,
1273    })
1274}
1275
1276fn substitute_eir_term(
1277    term: &EirTerm,
1278    substitution: &BTreeMap<String, EirTerm>,
1279) -> Option<EirTerm> {
1280    match term {
1281        EirTerm::Variable(name) => Some(
1282            substitution
1283                .get(name)
1284                .cloned()
1285                .unwrap_or_else(|| term.clone()),
1286        ),
1287        EirTerm::Anonymous => None,
1288        EirTerm::List(items) => items
1289            .iter()
1290            .map(|item| substitute_eir_term(item, substitution))
1291            .collect::<Option<Vec<_>>>()
1292            .map(EirTerm::List),
1293        EirTerm::Cons { head, tail } => Some(EirTerm::Cons {
1294            head: Box::new(substitute_eir_term(head, substitution)?),
1295            tail: Box::new(substitute_eir_term(tail, substitution)?),
1296        }),
1297        EirTerm::Compound { functor, args } => Some(EirTerm::Compound {
1298            functor: functor.clone(),
1299            args: args
1300                .iter()
1301                .map(|arg| substitute_eir_term(arg, substitution))
1302                .collect::<Option<Vec<_>>>()?,
1303        }),
1304        EirTerm::Aggregate { .. } => None,
1305        EirTerm::Integer(_)
1306        | EirTerm::FloatBits(_)
1307        | EirTerm::String(_)
1308        | EirTerm::Symbol(_)
1309        | EirTerm::PredRef(_) => Some(term.clone()),
1310    }
1311}
1312
1313fn has_independent_founded_support_inner(
1314    eir: &EirProgram,
1315    atom: &xlog_ir::EirAtom,
1316    support_stack: &mut Vec<(String, usize)>,
1317) -> bool {
1318    if atom.arity > 0 && !atom.terms.iter().all(eir_term_is_ground) {
1319        return false;
1320    }
1321
1322    let key = (atom.predicate.clone(), atom.arity);
1323    if support_stack.iter().any(|ancestor| ancestor == &key) {
1324        return false;
1325    }
1326    support_stack.push(key);
1327
1328    let supported = eir.rules.iter().any(|rule| {
1329        let Some(substitution) = head_substitution_to_atom(&rule.head, atom) else {
1330            return false;
1331        };
1332        eir_rule_has_independent_founded_body_with_substitution(
1333            eir,
1334            rule,
1335            &substitution,
1336            support_stack,
1337        )
1338    });
1339
1340    support_stack.pop();
1341    supported
1342}
1343
1344fn eir_rule_has_independent_founded_body(
1345    eir: &EirProgram,
1346    rule: &xlog_ir::EirRule,
1347    support_stack: &mut Vec<(String, usize)>,
1348) -> bool {
1349    eir_rule_has_independent_founded_body_with_substitution(
1350        eir,
1351        rule,
1352        &BTreeMap::new(),
1353        support_stack,
1354    )
1355}
1356
1357fn eir_rule_has_independent_founded_body_with_substitution(
1358    eir: &EirProgram,
1359    rule: &xlog_ir::EirRule,
1360    substitution: &BTreeMap<String, EirTerm>,
1361    support_stack: &mut Vec<(String, usize)>,
1362) -> bool {
1363    rule.body.iter().all(|lit| match lit {
1364        EirBodyLiteral::Epistemic(_) => false,
1365        EirBodyLiteral::Relational { negated: true, .. } => false,
1366        EirBodyLiteral::Relational {
1367            negated: false,
1368            atom,
1369        } => {
1370            let Some(atom) = substitute_eir_atom(atom, substitution) else {
1371                return false;
1372            };
1373            let dependency_key = (atom.predicate.clone(), atom.arity);
1374            if support_stack
1375                .iter()
1376                .any(|ancestor| ancestor == &dependency_key)
1377            {
1378                return false;
1379            }
1380            if !eir
1381                .rules
1382                .iter()
1383                .any(|rule| head_substitution_to_atom(&rule.head, &atom).is_some())
1384            {
1385                return true;
1386            }
1387            has_independent_founded_support_inner(eir, &atom, support_stack)
1388        }
1389        // EIR preserves only the presence of comparisons and bindings, not the
1390        // expression needed to prove that they hold for every tuple in the modal
1391        // rule's domain. Treating them as unconditional would let a restricted
1392        // support rule (for example `X = 1`) found unrelated tuples. A richer proof
1393        // may admit such rules later; this structural foundedness check must remain
1394        // conservative until then.
1395        EirBodyLiteral::Constraint | EirBodyLiteral::Binding => false,
1396    })
1397}
1398
1399fn eir_term_is_ground(term: &EirTerm) -> bool {
1400    match term {
1401        EirTerm::Variable(_) | EirTerm::Anonymous | EirTerm::Aggregate { .. } => false,
1402        EirTerm::Integer(_) | EirTerm::FloatBits(_) | EirTerm::String(_) | EirTerm::Symbol(_) => {
1403            true
1404        }
1405        EirTerm::List(items) => items.iter().all(eir_term_is_ground),
1406        EirTerm::Cons { head, tail } => eir_term_is_ground(head) && eir_term_is_ground(tail),
1407        EirTerm::Compound { args, .. } => args.iter().all(eir_term_is_ground),
1408        EirTerm::PredRef(_) => true,
1409    }
1410}
1411
1412/// Compile an epistemic program into its GPU contract and reduced runtime plan.
1413///
1414/// This is the first production-lowering boundary for epistemic execution. It
1415/// removes epistemic literals only after `plan_epistemic_gpu_execution` proves
1416/// the explicit EIR/GPU semantic contract, then sends the ordinary reduced
1417/// program through the same compiler, optimizer, helper-splitting, and WCOJ
1418/// promotion pipeline used by non-epistemic programs.
1419pub fn compile_epistemic_gpu_execution(program: &Program) -> Result<EpistemicExecutablePlan> {
1420    compile_epistemic_gpu_execution_with_stats_snapshot(program, None)
1421}
1422
1423/// Compile an epistemic program with an optional production statistics snapshot.
1424///
1425/// This preserves the reduced ordinary-body planner contract: cardinality,
1426/// selectivity, access heat, prefix-degree, sorted-layout, and helper-splitting
1427/// decisions are owned by the existing production compiler pipeline rather than
1428/// by an epistemic side planner.
1429pub fn compile_epistemic_gpu_execution_with_stats_snapshot(
1430    program: &Program,
1431    stats_snapshot: Option<&StatsSnapshot>,
1432) -> Result<EpistemicExecutablePlan> {
1433    let mut prepared = program.clone();
1434    if prepared.authored_constraint_source_bound.is_some() {
1435        prepared.validate_prepared_authored_constraint_identity()?;
1436    } else {
1437        prepared.prepare_authored_constraint_identity_at_root()?;
1438    }
1439    compile_epistemic_gpu_execution_inner(&prepared, stats_snapshot, false)
1440}
1441
1442/// Lower an epistemic program to its GPU contract and reduced runtime plan.
1443///
1444/// When `allow_multiple_output_heads` is false (the default monolithic and
1445/// single-head split path) the single-output-buffer contract
1446/// ([`require_single_epistemic_output_relation`]) is enforced. When true, the
1447/// caller has proven the component is a JOINT-SOLVABLE coalesced multi-head
1448/// component (see [`classify_cross_component_modal_coupling`]): one candidate
1449/// enumeration + world-view validation over the combined modal literals, with
1450/// each head materialized against the shared accepted world view at runtime.
1451fn compile_epistemic_gpu_execution_inner(
1452    program: &Program,
1453    stats_snapshot: Option<&StatsSnapshot>,
1454    allow_multiple_output_heads: bool,
1455) -> Result<EpistemicExecutablePlan> {
1456    program.validate_prepared_authored_constraint_identity()?;
1457    let gpu_plan = plan_prepared_epistemic_gpu_execution(program)?;
1458    if !allow_multiple_output_heads {
1459        require_single_epistemic_output_relation(&gpu_plan)?;
1460    }
1461    // JOINT-SOLVING multi-head materialization now projects each coupled head by ITS
1462    // OWN `public_head_arity` (see `final_output_columns_for_materialization`): each
1463    // head is materialized from its own reduced relation buffer with its own
1464    // reduction row-filter, reading only the store/world-view boundary. An augmented
1465    // multi-head component (a modal-literal variable absent from a head) therefore
1466    // projects every head's public tuple shape soundly, including coupled heads of
1467    // DIFFERING arity. The former blanket fail-closed guard on
1468    // `final_output_columns.is_some()` over multiple heads is no longer needed.
1469    let reduced_program = reduce_epistemic_program_to_ordinary(program)?;
1470    let mut compiler = Compiler::new();
1471    let reduced_runtime_plan =
1472        compiler.compile_prepared_program_with_stats_snapshot(&reduced_program, stats_snapshot)?;
1473    let relation_ids = compiler
1474        .rel_ids()
1475        .iter()
1476        .map(|(name, rel)| (name.clone(), *rel))
1477        .collect();
1478
1479    Ok(EpistemicExecutablePlan {
1480        gpu_plan,
1481        relation_ids,
1482        reduced_runtime_plan,
1483    })
1484}
1485
1486/// Authored epistemic source after static validation and exact FAEEL foundedness
1487/// filtering, ready for dependency classification and executable planning.
1488#[derive(Debug, Clone)]
1489pub struct PreparedEpistemicProgram {
1490    active_program: Program,
1491    removed_unfounded_rule_count: usize,
1492}
1493
1494/// Modal-free programs and frozen-relation bindings for a Gelfond-1991
1495/// compatibility greatest fixpoint.
1496///
1497/// The upper-bound program removes only selected positive `possible` gates in a
1498/// recursive component. The refinement program replaces those same gates with
1499/// reads from frozen snapshots of the preceding iteration. Re-evaluating the
1500/// refinement from the original extensional inputs until the selected relations
1501/// stop changing computes compatibility per concrete tuple instead of assuming
1502/// that predicate-level strongly connected component membership is sufficient.
1503#[derive(Debug, Clone)]
1504pub struct G91CompatibilityReduction {
1505    upper_bound_program: Program,
1506    refinement_program: Program,
1507    snapshot_relations: BTreeMap<String, String>,
1508    convergence_predicates: Vec<String>,
1509}
1510
1511impl G91CompatibilityReduction {
1512    /// Program whose selected compatibility gates are removed to establish the
1513    /// finite initial upper bound.
1514    pub fn upper_bound_program(&self) -> &Program {
1515        &self.upper_bound_program
1516    }
1517
1518    /// Program whose selected compatibility gates read the preceding iteration's
1519    /// frozen relation snapshots.
1520    pub fn refinement_program(&self) -> &Program {
1521        &self.refinement_program
1522    }
1523
1524    /// Source relation to collision-free frozen snapshot relation name.
1525    pub fn snapshot_relations(&self) -> &BTreeMap<String, String> {
1526        &self.snapshot_relations
1527    }
1528
1529    /// Intensional relations compared for convergence after each refinement.
1530    pub fn convergence_predicates(&self) -> &[String] {
1531        &self.convergence_predicates
1532    }
1533}
1534
1535impl PreparedEpistemicProgram {
1536    /// Program remaining after exact foundedness filtering.
1537    pub fn active_program(&self) -> &Program {
1538        &self.active_program
1539    }
1540
1541    /// Whether preparation removed at least one unfounded rule.
1542    pub fn removed_unfounded_rules(&self) -> bool {
1543        self.removed_unfounded_rule_count != 0
1544    }
1545}
1546
1547/// Validate authored contracts before semantics can remove a rule, then exclude only
1548/// positive exact-tuple FAEEL self-support with no independent founded support.
1549pub fn prepare_epistemic_program(program: &Program) -> Result<PreparedEpistemicProgram> {
1550    let prepared = prepare_root_authored_constraint_identity(program)?;
1551    validate_prepared_epistemic_source_program(&prepared)?;
1552    let removed_rule_indices = faeel_unfounded_exact_tuple_self_support_rule_indices(&prepared);
1553    Ok(PreparedEpistemicProgram {
1554        active_program: program_without_rule_indices(&prepared, &removed_rule_indices),
1555        removed_unfounded_rule_count: removed_rule_indices.len(),
1556    })
1557}
1558
1559#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1560struct G91CompatibilityLiteralLocation {
1561    rule_index: usize,
1562    literal_index: usize,
1563}
1564
1565/// Build the explicit tuple-level Gelfond-1991 compatibility reduction, when the
1566/// prepared program contains a supported positive `possible` dependency cycle.
1567pub fn try_prepare_g91_compatibility_reduction(
1568    prepared: &PreparedEpistemicProgram,
1569) -> Result<Option<G91CompatibilityReduction>> {
1570    let program = prepared.active_program();
1571    if program.directives.epistemic_mode_or_default() != EpistemicMode::G91 {
1572        return Ok(None);
1573    }
1574    if classify_recursive_epistemic_program(program)? == RecursiveEpistemicClass::NonRecursive {
1575        return Ok(None);
1576    }
1577
1578    validate_epistemic_derived_relation_identity(program, &BTreeSet::new())?;
1579    let recursive_modal_edges = recursive_modal_dependency_edges(program);
1580    let invariant = InvariantRelations::analyze(program);
1581    let mut locations = BTreeSet::new();
1582    let mut target_arities = BTreeMap::new();
1583    for (rule_index, rule) in program.rules.iter().enumerate() {
1584        for (literal_index, literal) in rule.body.iter().enumerate() {
1585            let BodyLiteral::Epistemic(modal) = literal else {
1586                continue;
1587            };
1588            if !is_g91_compatibility_literal(rule, modal, &invariant, &recursive_modal_edges) {
1589                continue;
1590            }
1591            locations.insert(G91CompatibilityLiteralLocation {
1592                rule_index,
1593                literal_index,
1594            });
1595            target_arities
1596                .entry(modal.atom.predicate.clone())
1597                .and_modify(|arity| {
1598                    debug_assert_eq!(*arity, modal.atom.arity());
1599                })
1600                .or_insert(modal.atom.arity());
1601        }
1602    }
1603    if locations.is_empty() {
1604        return Ok(None);
1605    }
1606
1607    reject_nonmonotone_g91_compatibility_components(program, &locations)?;
1608    let snapshot_relations = g91_snapshot_relation_names(program, target_arities.keys());
1609    let upper_bound_program = transform_g91_compatibility_program(
1610        program,
1611        &locations,
1612        G91CompatibilityTransform::UpperBound,
1613    );
1614    let mut refinement_program = transform_g91_compatibility_program(
1615        program,
1616        &locations,
1617        G91CompatibilityTransform::Snapshot(&snapshot_relations),
1618    );
1619    add_declared_g91_snapshot_relations(
1620        &mut refinement_program,
1621        program,
1622        &snapshot_relations,
1623        &target_arities,
1624    );
1625
1626    let convergence_predicates = program
1627        .proper_rules()
1628        .map(|rule| rule.head.predicate.clone())
1629        .collect::<BTreeSet<_>>()
1630        .into_iter()
1631        .collect();
1632    Ok(Some(G91CompatibilityReduction {
1633        upper_bound_program,
1634        refinement_program,
1635        snapshot_relations,
1636        convergence_predicates,
1637    }))
1638}
1639
1640fn is_g91_compatibility_literal(
1641    rule: &crate::ast::Rule,
1642    modal: &EpistemicLiteral,
1643    invariant: &InvariantRelations<'_>,
1644    recursive_modal_edges: &BTreeSet<(String, String)>,
1645) -> bool {
1646    modal.op == EpistemicOp::Possible
1647        && !modal.negated
1648        && !invariant.is_invariant(&modal.atom.predicate)
1649        && recursive_modal_edges
1650            .contains(&(rule.head.predicate.clone(), modal.atom.predicate.clone()))
1651        && modal.atom.terms == rule.head.terms
1652}
1653
1654enum G91CompatibilityTransform<'a> {
1655    UpperBound,
1656    Snapshot(&'a BTreeMap<String, String>),
1657}
1658
1659fn transform_g91_compatibility_program(
1660    program: &Program,
1661    locations: &BTreeSet<G91CompatibilityLiteralLocation>,
1662    transform: G91CompatibilityTransform<'_>,
1663) -> Program {
1664    let mut reduced = program.clone();
1665    for (rule_index, rule) in reduced.rules.iter_mut().enumerate() {
1666        for (literal_index, literal) in rule.body.iter_mut().enumerate() {
1667            let BodyLiteral::Epistemic(modal) = literal else {
1668                continue;
1669            };
1670            if locations.contains(&G91CompatibilityLiteralLocation {
1671                rule_index,
1672                literal_index,
1673            }) {
1674                *literal = match &transform {
1675                    G91CompatibilityTransform::UpperBound => BodyLiteral::Comparison(Comparison {
1676                        left: Term::Integer(1),
1677                        op: CompOp::Eq,
1678                        right: Term::Integer(1),
1679                    }),
1680                    G91CompatibilityTransform::Snapshot(snapshot_relations) => {
1681                        let mut atom = modal.atom.clone();
1682                        atom.predicate = snapshot_relations
1683                            .get(&atom.predicate)
1684                            .expect("selected compatibility target has a snapshot name")
1685                            .clone();
1686                        BodyLiteral::Positive(atom)
1687                    }
1688                };
1689            } else {
1690                *literal = if modal.negated {
1691                    BodyLiteral::Negated(modal.atom.clone())
1692                } else {
1693                    BodyLiteral::Positive(modal.atom.clone())
1694                };
1695            }
1696        }
1697    }
1698    reduced.constraints.retain(|constraint| {
1699        !constraint
1700            .body
1701            .iter()
1702            .any(|literal| matches!(literal, BodyLiteral::Epistemic(_)))
1703    });
1704    qualify_extensional_multi_arity_predicates(&mut reduced, program, &BTreeSet::new());
1705    reduced
1706}
1707
1708fn g91_snapshot_relation_names<'a>(
1709    program: &Program,
1710    targets: impl Iterator<Item = &'a String>,
1711) -> BTreeMap<String, String> {
1712    let mut reserved = collect_epistemic_relation_identities(program, &BTreeSet::new())
1713        .0
1714        .into_keys()
1715        .collect::<BTreeSet<_>>();
1716    let mut names = BTreeMap::new();
1717    for target in targets {
1718        let stem = target
1719            .chars()
1720            .map(|character| {
1721                if character.is_ascii_alphanumeric() || character == '_' {
1722                    character
1723                } else {
1724                    '_'
1725                }
1726            })
1727            .collect::<String>();
1728        let base = format!("__xlog_g91_snapshot_{stem}");
1729        let mut candidate = base.clone();
1730        let mut suffix = 0usize;
1731        while reserved.contains(&candidate) {
1732            candidate = format!("{base}_{suffix}");
1733            suffix += 1;
1734        }
1735        reserved.insert(candidate.clone());
1736        names.insert(target.clone(), candidate);
1737    }
1738    names
1739}
1740
1741fn add_declared_g91_snapshot_relations(
1742    refinement: &mut Program,
1743    source: &Program,
1744    snapshots: &BTreeMap<String, String>,
1745    target_arities: &BTreeMap<String, usize>,
1746) {
1747    for (target, snapshot) in snapshots {
1748        let expected_arity = target_arities
1749            .get(target)
1750            .expect("snapshot target has an authored arity");
1751        if let Some(declaration) = source.predicates.iter().find(|declaration| {
1752            declaration.name == *target && declaration.arity() == *expected_arity
1753        }) {
1754            let mut declaration = declaration.clone();
1755            declaration.name = snapshot.clone();
1756            declaration.is_private = false;
1757            refinement.predicates.push(declaration);
1758        }
1759    }
1760}
1761
1762fn reject_nonmonotone_g91_compatibility_components(
1763    program: &Program,
1764    locations: &BTreeSet<G91CompatibilityLiteralLocation>,
1765) -> Result<()> {
1766    let (_, dependencies) = epistemic_dependency_graphs(program);
1767    let selected_heads = locations
1768        .iter()
1769        .map(|location| program.rules[location.rule_index].head.predicate.as_str())
1770        .collect::<BTreeSet<_>>();
1771    for rule in &program.rules {
1772        let in_selected_component = selected_heads.iter().any(|selected| {
1773            rule.head.predicate == **selected
1774                || (predicate_dependency_reaches(
1775                    selected,
1776                    &rule.head.predicate,
1777                    &dependencies,
1778                    &mut BTreeSet::new(),
1779                ) && predicate_dependency_reaches(
1780                    &rule.head.predicate,
1781                    selected,
1782                    &dependencies,
1783                    &mut BTreeSet::new(),
1784                ))
1785        });
1786        if !in_selected_component {
1787            continue;
1788        }
1789        if rule.has_aggregation() {
1790            return Err(XlogError::UnsupportedEpistemicConstruct {
1791                construct: "Gelfond-1991 compatibility cycle through aggregation".to_string(),
1792                context: format!(
1793                    "aggregate predicate `{}` belongs to a positive `possible` compatibility \
1794                     component; the tuple-level greatest fixpoint requires every dependency in \
1795                     that component to be monotone",
1796                    rule.head.predicate
1797                ),
1798            });
1799        }
1800        if rule
1801            .body
1802            .iter()
1803            .filter_map(|literal| match literal {
1804                BodyLiteral::Negated(atom) => Some(atom),
1805                BodyLiteral::Epistemic(modal) if modal.negated => Some(&modal.atom),
1806                BodyLiteral::Positive(_)
1807                | BodyLiteral::Epistemic(_)
1808                | BodyLiteral::Comparison(_)
1809                | BodyLiteral::IsExpr(_)
1810                | BodyLiteral::Univ(_) => None,
1811            })
1812            .any(|atom| {
1813                predicate_dependency_reaches(
1814                    &atom.predicate,
1815                    &rule.head.predicate,
1816                    &dependencies,
1817                    &mut BTreeSet::new(),
1818                )
1819            })
1820        {
1821            return Err(XlogError::UnsupportedEpistemicConstruct {
1822                construct: "Gelfond-1991 compatibility cycle through negation".to_string(),
1823                context: format!(
1824                    "predicate `{}` belongs to a positive `possible` compatibility component \
1825                     that also has a recursive negated dependency; the tuple-level greatest \
1826                     fixpoint requires a monotone component",
1827                    rule.head.predicate
1828                ),
1829            });
1830        }
1831    }
1832    Ok(())
1833}
1834
1835/// Return the ordinary fixpoint reduction selected for a prepared epistemic program.
1836pub fn try_reduce_prepared_recursive_epistemic_program(
1837    prepared: &PreparedEpistemicProgram,
1838) -> Result<Option<Program>> {
1839    if try_prepare_g91_compatibility_reduction(prepared)?.is_some() {
1840        return Err(XlogError::UnsupportedEpistemicConstruct {
1841            construct: "Gelfond-1991 tuple compatibility ordinary reduction".to_string(),
1842            context: "positive `possible` compatibility cycles require the explicit upper-bound \
1843                      and frozen-snapshot greatest-fixpoint plan returned by \
1844                      `try_prepare_g91_compatibility_reduction`; they cannot be represented by \
1845                      one ordinary least-fixpoint program"
1846                .to_string(),
1847        });
1848    }
1849    let active_program = prepared.active_program();
1850    let recursive_class = classify_recursive_epistemic_program(active_program)?;
1851    if recursive_class == RecursiveEpistemicClass::NonRecursive
1852        && !prepared.removed_unfounded_rules()
1853    {
1854        return Ok(None);
1855    }
1856
1857    validate_epistemic_derived_relation_identity(active_program, &BTreeSet::new())?;
1858    match recursive_class {
1859        RecursiveEpistemicClass::NonRecursive => Ok(Some(
1860            reduce_founded_epistemic_program_to_ordinary(active_program),
1861        )),
1862        // After explicit G91 compatibility cycles have been intercepted above, every
1863        // remaining admitted class shares the same reduction: each positive
1864        // `know`/`possible` modal resolves to its ordinary atom. That atom is either
1865        // invariant or co-evolves inside an ordinary-or-modal dependency cycle. The
1866        // semi-naive least fixpoint computes the founded co-evolving result.
1867        RecursiveEpistemicClass::CaseA
1868        | RecursiveEpistemicClass::CaseB
1869        | RecursiveEpistemicClass::ModalCycle => Ok(Some(
1870            reduce_case_a_epistemic_program_to_ordinary(active_program),
1871        )),
1872    }
1873}
1874
1875/// Validate an admissible recursive epistemic program and return its ordinary
1876/// fixpoint reduction.
1877///
1878/// This is the recursive counterpart to [`compile_epistemic_gpu_execution`]. It first
1879/// validates the complete authored source, removes only exact tuple-level circular
1880/// FAEEL support, and classifies the remaining dependency graph. Predecessor and tuple-
1881/// permutation edges therefore remain part of recursive-path selection. Surviving
1882/// positive modal literals resolve to ordinary joins and execute through the existing
1883/// least-fixpoint engine. Exact G91 compatibility cycles return a typed error because
1884/// callers must execute the upper-bound/frozen-snapshot reduction returned by
1885/// [`try_prepare_g91_compatibility_reduction`].
1886///
1887/// Returns `Ok(Some(reduced))` for an admitted recursive class, `Ok(None)` when the
1888/// program has no dependency cycle (the caller should use the single-pass epistemic
1889/// path), and a typed error for a recursive shape outside the supported fragment.
1890pub fn try_reduce_case_a_recursive_epistemic_program(program: &Program) -> Result<Option<Program>> {
1891    let prepared = prepare_epistemic_program(program)?;
1892    try_reduce_prepared_recursive_epistemic_program(&prepared)
1893}
1894
1895fn require_single_epistemic_output_relation(gpu_plan: &EpistemicGpuPlan) -> Result<()> {
1896    let output_relations: BTreeSet<&str> = gpu_plan
1897        .reductions
1898        .iter()
1899        .map(|reduction| reduction.head_predicate.as_str())
1900        .collect();
1901    if output_relations.len() > 1 {
1902        return Err(XlogError::UnsupportedEpistemicConstruct {
1903            construct: "epistemic GPU final output relation".to_string(),
1904            context: format!(
1905                "single-plan GPU execution materializes one final output buffer, but reductions \
1906                 target multiple head predicates {:?}; use split GPU execution for independent \
1907                 epistemic outputs",
1908                output_relations
1909            ),
1910        });
1911    }
1912    Ok(())
1913}
1914
1915fn reject_epistemic_constraints(program: &Program) -> Result<()> {
1916    reject_epistemic_constraints_for_boundary(program, "epistemic GPU constraint", "GPU lowering")
1917}
1918
1919fn reject_gpt_epistemic_constraints(program: &Program) -> Result<()> {
1920    reject_epistemic_constraints_for_boundary(
1921        program,
1922        "epistemic GPT constraint",
1923        "GPT candidate testing",
1924    )
1925}
1926
1927fn reject_epistemic_constraints_for_boundary(
1928    program: &Program,
1929    construct: &str,
1930    boundary: &str,
1931) -> Result<()> {
1932    for constraint in &program.constraints {
1933        let constraint_index = constraint.require_authored_index()?;
1934        for lit in &constraint.body {
1935            let BodyLiteral::Epistemic(lit) = lit else {
1936                continue;
1937            };
1938            return Err(XlogError::UnsupportedEpistemicConstruct {
1939                construct: construct.to_string(),
1940                context: format!(
1941                    "constraint[{constraint_index}] contains unsupported {} {}/{}; epistemic integrity constraints must be represented explicitly before {boundary}",
1942                    epistemic_literal_label(lit),
1943                    lit.atom.predicate,
1944                    lit.atom.arity()
1945                ),
1946            });
1947        }
1948    }
1949    Ok(())
1950}
1951
1952fn epistemic_literal_label(lit: &EpistemicLiteral) -> &'static str {
1953    match (lit.negated, lit.op) {
1954        (false, EpistemicOp::Know) => "know",
1955        (false, EpistemicOp::Possible) => "possible",
1956        (true, EpistemicOp::Know) => "not know",
1957        (true, EpistemicOp::Possible) => "not possible",
1958    }
1959}
1960
1961/// Flatten a modal literal's structured key terms, returning a literal whose
1962/// atom carries the FLATTENED arity/terms.
1963///
1964/// This is the single normalization point for structured modal keys: the stored
1965/// epistemic literal, its tuple-membership binding, and its solver assumption
1966/// binding are all derived from the same flattened atom, so the plan validators
1967/// (which require `binding.arity == literal.atom.arity` and `binding.key_terms ==
1968/// literal.atom.terms`) stay consistent and the runtime matches the modal
1969/// relation's real column tuple. Scalar-only keys are returned unchanged.
1970fn flatten_epistemic_literal(lit: &EirEpistemicLiteral) -> Result<EirEpistemicLiteral> {
1971    let (arity, terms, _key_columns) =
1972        flatten_structured_key_terms(&lit.atom.predicate, &lit.atom.terms)?;
1973    Ok(EirEpistemicLiteral {
1974        op: lit.op,
1975        negated: lit.negated,
1976        atom: xlog_ir::EirAtom {
1977            predicate: lit.atom.predicate.clone(),
1978            arity,
1979            terms,
1980        },
1981    })
1982}
1983
1984/// Whether a term encodes directly into one scalar/Symbol GPU key column.
1985///
1986/// These are the leaf forms the device tuple-key matcher already handles per
1987/// column: bound variables (BOUND_OUTPUT), anonymous wildcards (WILDCARD), and
1988/// ground integer/float/string/symbol literals (GROUND).
1989fn eir_term_is_scalar_key_element(term: &EirTerm) -> bool {
1990    matches!(
1991        term,
1992        EirTerm::Variable(_)
1993            | EirTerm::Anonymous
1994            | EirTerm::Integer(_)
1995            | EirTerm::FloatBits(_)
1996            | EirTerm::String(_)
1997            | EirTerm::Symbol(_)
1998    )
1999}
2000
2001/// Flatten a modal atom's key terms ELEMENT-WISE into a flat list of scalar key
2002/// terms plus the matching `0..n` key-column indices.
2003///
2004/// A STRUCTURED finite+typed key term -- a fixed-arity list `[a, b]` or compound
2005/// `f(a, b)` whose elements are each scalar/Symbol-typed -- is expanded into its
2006/// elements, each of which becomes one GPU key column. The flattened arity must
2007/// equal the modal relation's arity (the runtime arity check enforces that
2008/// downstream). Scalar terms pass through unchanged.
2009///
2010/// Genuinely unbounded or untyped structured forms (a `cons` with a non-list
2011/// tail, a nested structure, a `predref`, or an `aggregate`) carry no fixed,
2012/// typed column set and are rejected as unsupported tuple-key constructs.
2013fn flatten_structured_key_terms(
2014    predicate: &str,
2015    terms: &[EirTerm],
2016) -> Result<(usize, Vec<EirTerm>, Vec<usize>)> {
2017    let mut flattened: Vec<EirTerm> = Vec::with_capacity(terms.len());
2018    for term in terms {
2019        match term {
2020            EirTerm::List(items) => {
2021                flatten_structured_elements(predicate, "list", items, &mut flattened)?;
2022            }
2023            EirTerm::Compound { functor, args } => {
2024                flatten_structured_elements(
2025                    predicate,
2026                    &format!("compound {functor}/{}", args.len()),
2027                    args,
2028                    &mut flattened,
2029                )?;
2030            }
2031            EirTerm::Cons { .. } => {
2032                return Err(XlogError::UnsupportedEpistemicConstruct {
2033                    construct: "modal tuple-key cons pattern".to_string(),
2034                    context: format!(
2035                        "modal tuple-key for {predicate} uses a `cons` pattern whose tail length \
2036                         is not statically fixed, so it has no finite, typed GPU key-column set; \
2037                         bind it to a fixed-arity list literal `[a, b, ...]` instead"
2038                    ),
2039                });
2040            }
2041            EirTerm::PredRef(name) => {
2042                return Err(XlogError::UnsupportedEpistemicConstruct {
2043                    construct: "modal tuple-key predicate reference".to_string(),
2044                    context: format!(
2045                        "modal tuple-key for {predicate} uses predref `{name}`, which has no \
2046                         finite, typed GPU key-column encoding"
2047                    ),
2048                });
2049            }
2050            EirTerm::Aggregate { op, variable } => {
2051                return Err(XlogError::UnsupportedEpistemicConstruct {
2052                    construct: "modal tuple-key aggregate".to_string(),
2053                    context: format!(
2054                        "modal tuple-key for {predicate} uses aggregate `{op}({variable})`, whose \
2055                         value is not a finite, typed GPU key-column tuple"
2056                    ),
2057                });
2058            }
2059            scalar => flattened.push(scalar.clone()),
2060        }
2061    }
2062
2063    let arity = flattened.len();
2064    let key_columns = (0..arity).collect();
2065    Ok((arity, flattened, key_columns))
2066}
2067
2068/// Splice the elements of a fixed-arity structured key term into `flattened`.
2069///
2070/// Each element must itself be a scalar/Symbol key element; a nested structure
2071/// would need a column to hold its own sub-tuple, which a flat relation schema
2072/// cannot express, so it is rejected with a precise unsupported-shape diagnostic.
2073fn flatten_structured_elements(
2074    predicate: &str,
2075    shape: &str,
2076    elements: &[EirTerm],
2077    flattened: &mut Vec<EirTerm>,
2078) -> Result<()> {
2079    for element in elements {
2080        if eir_term_is_scalar_key_element(element) {
2081            flattened.push(element.clone());
2082        } else {
2083            return Err(XlogError::UnsupportedEpistemicConstruct {
2084                construct: "nested modal tuple-key structure".to_string(),
2085                context: format!(
2086                    "modal tuple-key for {predicate} nests a non-scalar element {element:?} inside \
2087                     a {shape}; only fixed-arity structures of scalar/Symbol-typed elements have a \
2088                     finite, typed GPU key-column encoding"
2089                ),
2090            });
2091        }
2092    }
2093    Ok(())
2094}
2095
2096fn bound_output_columns_for_terms(
2097    key_terms: &[EirTerm],
2098    output_terms: &[EirTerm],
2099) -> Vec<Option<usize>> {
2100    key_terms
2101        .iter()
2102        .map(|term| match term {
2103            EirTerm::Variable(variable) => output_terms.iter().position(
2104                |head_term| matches!(head_term, EirTerm::Variable(name) if name == variable),
2105            ),
2106            _ => None,
2107        })
2108        .collect()
2109}
2110
2111fn augmented_eir_head_terms(rule: &xlog_ir::EirRule) -> Vec<EirTerm> {
2112    let mut output_terms = rule.head.terms.clone();
2113    for lit in &rule.body {
2114        let EirBodyLiteral::Epistemic(lit) = lit else {
2115            continue;
2116        };
2117        // A modal key variable may be NESTED inside a structured key term
2118        // (`know p([X, Y])`), so flatten before collecting variables that need a
2119        // reduced output column to bind against. Flattening failures are surfaced
2120        // by the binding-construction path; here we fall back to the raw terms so
2121        // diagnostics remain anchored at that site.
2122        let key_terms = flatten_structured_key_terms(&lit.atom.predicate, &lit.atom.terms)
2123            .map(|(_, terms, _)| terms)
2124            .unwrap_or_else(|_| lit.atom.terms.clone());
2125        for term in &key_terms {
2126            let EirTerm::Variable(variable) = term else {
2127                continue;
2128            };
2129            if !output_terms
2130                .iter()
2131                .any(|head_term| matches!(head_term, EirTerm::Variable(name) if name == variable))
2132            {
2133                output_terms.push(EirTerm::Variable(variable.clone()));
2134            }
2135        }
2136    }
2137    output_terms
2138}
2139
2140fn final_output_columns_for_eir(eir: &EirProgram) -> Option<Vec<usize>> {
2141    let mut final_columns = Vec::new();
2142    let mut needs_projection = false;
2143    for rule in &eir.rules {
2144        if !rule
2145            .body
2146            .iter()
2147            .any(|lit| matches!(lit, EirBodyLiteral::Epistemic(_)))
2148        {
2149            continue;
2150        }
2151        let augmented_len = augmented_eir_head_terms(rule).len();
2152        if augmented_len > rule.head.terms.len() {
2153            needs_projection = true;
2154        }
2155        if final_columns.is_empty() {
2156            final_columns = (0..rule.head.terms.len()).collect();
2157        }
2158    }
2159    if needs_projection {
2160        Some(final_columns)
2161    } else {
2162        None
2163    }
2164}
2165
2166/// Indices (into `program.rules`) of exact tuple-level FAEEL rules that are unfounded
2167/// by circular modal self-support and must be excluded from the reduced founded-model
2168/// base without removing predecessor or tuple-permutation edges.
2169///
2170/// A rule qualifies when (a) the program is in FAEEL mode, (b) the rule body contains a
2171/// modal literal `possible p`/`know p` over the rule's head predicate and arity,
2172/// (c) that head has NO independent founded support
2173/// ([`has_independent_founded_support`]) and NO tuple-level founded support
2174/// ([`has_tuple_level_independent_founded_support`]), and (d) excluding the rule does
2175/// NOT silently elide a mode-independent safety failure — i.e. the head carries no
2176/// variable bound ONLY by the self-supporting modal. Condition (d) preserves the clean
2177/// `UnsafeVariable` honest-exit for pure nonzero self-support (`p(X) :- possible p(X)`)
2178/// in EVERY mode (G91 rejects it identically): dropping such a rule would replace a
2179/// precise safety diagnostic with a confusing materialization error.
2180/// Every schema census, shape check, stratified plan, and executable reduction uses
2181/// this same decision so no broader predicate-level approximation can erase live
2182/// support.
2183fn faeel_unfounded_exact_tuple_self_support_rule_indices(program: &Program) -> Vec<usize> {
2184    let Ok(eir) = build_eir(program) else {
2185        return Vec::new();
2186    };
2187    if eir.mode != EirEpistemicMode::Faeel {
2188        return Vec::new();
2189    }
2190    let mut indices = Vec::new();
2191    for (index, (rule, eir_rule)) in program.rules.iter().zip(&eir.rules).enumerate() {
2192        let modal_only_output_variables = modal_only_bound_output_variables(rule);
2193        let drop = eir_rule.body.iter().any(|lit| {
2194            let EirBodyLiteral::Epistemic(modal) = lit else {
2195                return false;
2196            };
2197            if modal.negated
2198                || modal.atom.predicate != eir_rule.head.predicate
2199                || modal.atom.arity != eir_rule.head.arity
2200                || modal.atom.terms != eir_rule.head.terms
2201            {
2202                return false;
2203            }
2204            // Founded by an independent (non-circular) derivation: keep the rule; the
2205            // founded support proves the head, so it stays in the model.
2206            if has_independent_founded_support(&eir, &modal.atom)
2207                || has_tuple_level_independent_founded_support(&eir, eir_rule, &modal.atom)
2208            {
2209                return false;
2210            }
2211            // A head variable bound ONLY by this self-supporting modal would be unbound
2212            // (`UnsafeVariable`) in every mode once the modal is stripped: do NOT drop,
2213            // let the existing safety path raise the precise diagnostic.
2214            if modal
2215                .atom
2216                .terms
2217                .iter()
2218                .any(|term| matches!(term, EirTerm::Variable(name) if modal_only_output_variables.contains(name)))
2219            {
2220                return false;
2221            }
2222            true
2223        });
2224        if drop {
2225            indices.push(index);
2226        }
2227    }
2228    indices
2229}
2230
2231fn program_without_rule_indices(program: &Program, removed_rule_indices: &[usize]) -> Program {
2232    if removed_rule_indices.is_empty() {
2233        return program.clone();
2234    }
2235
2236    let removed_rule_indices = removed_rule_indices
2237        .iter()
2238        .copied()
2239        .collect::<BTreeSet<_>>();
2240    let mut filtered = program.clone();
2241    filtered.rules = program
2242        .rules
2243        .iter()
2244        .enumerate()
2245        .filter(|(index, _)| !removed_rule_indices.contains(index))
2246        .map(|(_, rule)| rule.clone())
2247        .collect();
2248    filtered
2249}
2250
2251/// Validate the complete authored epistemic program before foundedness can elide a rule.
2252///
2253/// Every authored predicate signature receives a temporary name-and-arity identity. This
2254/// keeps a semantically dead `p/1` clause from colliding with a live `p/2` relation while
2255/// preserving all declaration, clause, arithmetic, and modal type evidence within each
2256/// signature. Modal range restriction is checked against the executable contract first;
2257/// the validation clone then reaches the ordinary compiler's production preprocessing and
2258/// lowering checks without requiring ordinary stratification, because supported negated
2259/// modal cycles are dispatched to well-founded execution later.
2260pub fn validate_epistemic_source_program(program: &Program) -> Result<()> {
2261    let prepared = prepare_root_authored_constraint_identity(program)?;
2262    validate_prepared_epistemic_source_program(&prepared)
2263}
2264
2265fn validate_prepared_epistemic_source_program(program: &Program) -> Result<()> {
2266    validate_authored_modal_key_shapes(program)?;
2267    let invariant = InvariantRelations::analyze(program);
2268    let determined = EpistemicallyDeterminedPredicates::analyze(program);
2269    for rule in &program.rules {
2270        validate_modal_variable_bindings(&rule.body, &invariant, &determined)?;
2271    }
2272    for constraint in &program.constraints {
2273        validate_modal_variable_bindings(&constraint.body, &invariant, &determined)?;
2274    }
2275
2276    let mut validation = program.clone();
2277    for rule in &mut validation.rules {
2278        rewrite_modal_literals_for_source_validation(&mut rule.body, &invariant, &determined);
2279    }
2280    for constraint in &mut validation.constraints {
2281        rewrite_modal_literals_for_source_validation(&mut constraint.body, &invariant, &determined);
2282    }
2283
2284    let multi_arity_predicates = all_multi_arity_predicates(program);
2285    qualify_predicate_signatures(&mut validation, &multi_arity_predicates);
2286    Compiler::new().validate_program_without_stratification(&validation)
2287}
2288
2289/// Validate every authored modal tuple key against the relation signatures it can
2290/// address before foundedness or dependency reduction can remove the containing rule.
2291///
2292/// Structured keys are syntax for a flat tuple: `p([X, Y])` addresses `p/2`, not
2293/// `p/1`. The ordinary AST arity is therefore not the runtime key arity. Derive the
2294/// target signatures from declarations and non-modal relation occurrences, flatten
2295/// each EIR modal key through the production normalizer, and reject a mismatch at the
2296/// source boundary. This also preserves the precise finiteness diagnostic for an
2297/// unbounded structured key instead of allowing semantic elision to hide it.
2298fn validate_authored_modal_key_shapes(program: &Program) -> Result<()> {
2299    let target_arities = non_modal_relation_arities(program);
2300    let eir = build_eir(program)?;
2301    for modal in eir
2302        .rules
2303        .iter()
2304        .flat_map(|rule| &rule.body)
2305        .chain(
2306            eir.constraints
2307                .iter()
2308                .flat_map(|constraint| &constraint.body),
2309        )
2310        .filter_map(|literal| match literal {
2311            EirBodyLiteral::Epistemic(modal) => Some(modal),
2312            EirBodyLiteral::Relational { .. }
2313            | EirBodyLiteral::Constraint
2314            | EirBodyLiteral::Binding => None,
2315        })
2316    {
2317        let flattened = flatten_epistemic_literal(modal)?;
2318        let Some(expected) = target_arities.get(&flattened.atom.predicate) else {
2319            // An undeclared relation with no non-modal occurrence may be supplied as
2320            // an external relation. Its schema is established by the caller.
2321            continue;
2322        };
2323        if expected.contains(&flattened.atom.arity) {
2324            continue;
2325        }
2326
2327        let expected_description = if expected.len() == 1 {
2328            format!(
2329                "target arity {}",
2330                expected.first().expect("one target arity")
2331            )
2332        } else {
2333            format!("target arities {expected:?}")
2334        };
2335        return Err(XlogError::UnsupportedEpistemicConstruct {
2336            construct: "epistemic modal tuple key".to_string(),
2337            context: format!(
2338                "modal target `{}` has {expected_description}, but its tuple key flattens to \
2339                 binding arity {}; use one scalar key term per target column",
2340                flattened.atom.predicate, flattened.atom.arity
2341            ),
2342        });
2343    }
2344    Ok(())
2345}
2346
2347fn non_modal_relation_arities(program: &Program) -> BTreeMap<String, BTreeSet<usize>> {
2348    let mut arities = BTreeMap::new();
2349    for declaration in &program.predicates {
2350        arities
2351            .entry(declaration.name.clone())
2352            .or_insert_with(BTreeSet::new)
2353            .insert(declaration.arity());
2354    }
2355    for rule in &program.rules {
2356        record_predicate_signature(&mut arities, &rule.head);
2357        record_non_modal_body_signatures(&mut arities, &rule.body);
2358    }
2359    for constraint in &program.constraints {
2360        record_non_modal_body_signatures(&mut arities, &constraint.body);
2361    }
2362    for query in &program.queries {
2363        record_predicate_signature(&mut arities, &query.atom);
2364    }
2365    for fact in &program.prob_facts {
2366        record_predicate_signature(&mut arities, &fact.atom);
2367    }
2368    for disjunction in &program.annotated_disjunctions {
2369        for choice in &disjunction.choices {
2370            record_predicate_signature(&mut arities, &choice.atom);
2371        }
2372    }
2373    for evidence in &program.evidence {
2374        record_predicate_signature(&mut arities, &evidence.atom);
2375    }
2376    for query in &program.prob_queries {
2377        record_predicate_signature(&mut arities, &query.atom);
2378    }
2379    for declaration in &program.neural_predicates {
2380        record_predicate_signature(&mut arities, &declaration.predicate);
2381    }
2382    for rule in &program.learnable_rules {
2383        record_predicate_signature(&mut arities, &rule.head);
2384        record_non_modal_body_signatures(&mut arities, &rule.body);
2385    }
2386    arities
2387}
2388
2389fn record_non_modal_body_signatures(
2390    signatures: &mut BTreeMap<String, BTreeSet<usize>>,
2391    body: &[BodyLiteral],
2392) {
2393    for literal in body {
2394        if let BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) = literal {
2395            record_predicate_signature(signatures, atom);
2396        }
2397    }
2398}
2399
2400/// Replace modals for validation without changing the authored ordinary literal order.
2401/// Positive modals over invariant or acyclically determined targets remain binders.
2402/// Every other scalar-key modal is appended as an ordinary negated atom so it
2403/// contributes schema and type evidence while the compiler independently verifies that
2404/// all of its variables already have a finite source.
2405fn rewrite_modal_literals_for_source_validation(
2406    body: &mut Vec<BodyLiteral>,
2407    invariant: &InvariantRelations<'_>,
2408    determined: &EpistemicallyDeterminedPredicates,
2409) {
2410    let mut non_binding_modals = Vec::new();
2411    body.retain_mut(|literal| {
2412        let BodyLiteral::Epistemic(modal) = literal else {
2413            return true;
2414        };
2415        if modal.atom.terms.iter().any(|term| {
2416            !matches!(
2417                term,
2418                Term::Variable(_)
2419                    | Term::Anonymous
2420                    | Term::Integer(_)
2421                    | Term::Float(_)
2422                    | Term::String(_)
2423                    | Term::Symbol(_)
2424            )
2425        }) {
2426            // Structured modal keys have a dedicated finite-key normalization and
2427            // diagnostic path. Ordinary list lowering cannot represent an unbounded
2428            // `cons` key and would preempt that precise epistemic diagnostic.
2429            *literal = BodyLiteral::Comparison(Comparison {
2430                left: Term::Integer(1),
2431                op: CompOp::Eq,
2432                right: Term::Integer(1),
2433            });
2434            return true;
2435        }
2436        if !modal.negated
2437            && (invariant.is_invariant(&modal.atom.predicate)
2438                || determined.contains(&modal.atom.predicate))
2439        {
2440            *literal = BodyLiteral::Positive(modal.atom.clone());
2441            true
2442        } else {
2443            non_binding_modals.push(BodyLiteral::Negated(modal.atom.clone()));
2444            false
2445        }
2446    });
2447    body.extend(non_binding_modals);
2448}
2449
2450/// Variables with an ordinary finite source, matching the Lowerer's binding order:
2451/// every positive atom is joined first, then deterministic `is` expressions are applied
2452/// once in source order. A reversed arithmetic dependency is therefore not accepted by a
2453/// fixed-point approximation.
2454fn non_epistemic_bound_variables(body: &[BodyLiteral]) -> BTreeSet<String> {
2455    let mut bound = positive_body_bound_variables(body);
2456
2457    for literal in body {
2458        let BodyLiteral::IsExpr(binding) = literal else {
2459            continue;
2460        };
2461        if binding
2462            .expr
2463            .variables()
2464            .iter()
2465            .all(|name| bound.contains(*name))
2466        {
2467            bound.insert(binding.target.clone());
2468        }
2469    }
2470
2471    bound
2472}
2473
2474/// Enforce the modal binding contract before a reduction turns modal atoms into ordinary
2475/// joins. A co-evolving or negated modal may filter an already-bound tuple but may not
2476/// invent a finite domain; only a positive modal over an invariant or acyclically
2477/// determined relation can bind.
2478fn validate_modal_variable_bindings(
2479    body: &[BodyLiteral],
2480    invariant: &InvariantRelations<'_>,
2481    determined: &EpistemicallyDeterminedPredicates,
2482) -> Result<()> {
2483    let mut bound = non_epistemic_bound_variables(body);
2484
2485    // A rule body is a conjunction, so positive finite modal sources bind
2486    // independently of their textual order. Collect every such binder before
2487    // checking co-evolving or negated modal filters; only deterministic `is`
2488    // expressions retain the Lowerer's source-order contract.
2489    for literal in body {
2490        let BodyLiteral::Epistemic(modal) = literal else {
2491            continue;
2492        };
2493        let may_bind = !modal.negated
2494            && (invariant.is_invariant(&modal.atom.predicate)
2495                || determined.contains(&modal.atom.predicate));
2496        if may_bind {
2497            bound.extend(
2498                modal
2499                    .atom
2500                    .variables()
2501                    .into_iter()
2502                    .filter(|name| *name != "_")
2503                    .map(str::to_string),
2504            );
2505        }
2506    }
2507
2508    for literal in body {
2509        let BodyLiteral::Epistemic(modal) = literal else {
2510            continue;
2511        };
2512        let may_bind = !modal.negated
2513            && (invariant.is_invariant(&modal.atom.predicate)
2514                || determined.contains(&modal.atom.predicate));
2515        for variable in modal.atom.variables() {
2516            if variable == "_" {
2517                continue;
2518            }
2519            if !may_bind && !bound.contains(variable) {
2520                return Err(XlogError::UnsafeVariable(variable.to_string()));
2521            }
2522        }
2523    }
2524    Ok(())
2525}
2526
2527/// Return the ordinary runtime program selected by epistemic dependency
2528/// classification.
2529///
2530/// Admissible ordinary or modal dependency cycles resolve their modal edges into an
2531/// ordinary fixpoint program. Acyclic programs use the single-pass reduction; callers
2532/// must validate its explicit epistemic GPU contract before execution.
2533///
2534/// The augmenting positive-modal resolve is gated on INVARIANT targets only (see the
2535/// body comment): for an invariant `R`, `know R`/`possible R` ranges exactly over
2536/// `R`'s extension, so resolving the modal into an ordinary join binds the augmented
2537/// output column WITHOUT leaking — and the GPU membership filter re-gates
2538/// post hoc. A determined-but-not-invariant target (an epistemic-derived head like a
2539/// multi-column `r`) is NOT resolved here, so its augmenting output variable stays
2540/// unbound and the reduced program fails closed at this strict (execution) entry
2541/// point. See [`reduce_epistemic_program_to_ordinary_for_stratified_schema`] for the
2542/// schema-only relaxation used by the stratified driver.
2543pub fn reduce_epistemic_program_to_ordinary(program: &Program) -> Result<Program> {
2544    let prepared = prepare_root_authored_constraint_identity(program)?;
2545    if let Some(reduced) = try_reduce_case_a_recursive_epistemic_program(&prepared)? {
2546        return Ok(reduced);
2547    }
2548    reduce_epistemic_program_to_ordinary_inner(&prepared, &BTreeSet::new(), &BTreeMap::new())
2549}
2550
2551/// Schema-only reduction for the stratified epistemic driver.
2552///
2553/// Identical to [`reduce_epistemic_program_to_ordinary`] EXCEPT it also resolves an
2554/// augmenting positive modal whose target is epistemically DETERMINED (as classified
2555/// by the internal determined-predicate analysis) but not invariant — e.g. a
2556/// multi-column determined head `r` in `out(X) :- node(X), know r(X, Y)`, where the
2557/// modal binds the augmented output column `Y`. This is used SOLELY to compute the
2558/// plan-wide relation SCHEMAS (column types/arities) for an
2559/// [`EpistemicStratifiedPlan`]; the resolved positive atom over `r` supplies
2560/// `Y`'s declared column type so the schema compiler does not reject the augmented
2561/// `out(X, Y)` head as unsafe.
2562///
2563/// SOUNDNESS / NON-LEAK: a determined `r` IS gated into the store as a materialized
2564/// base relation by the LOWER stratum before the higher stratum runs (the stratified
2565/// executor's `materialize_epistemic_head_relation` at the STORE boundary), and the
2566/// higher stratum is compiled by `compile_stratum_plan` over a sub-program where
2567/// `r`'s defining rule is DROPPED — so there `r` is invariant and the EXISTING strict
2568/// resolve binds `Y` against the GATED `r` for execution. The determined-relaxed
2569/// resolve here therefore NEVER drives runtime data: it only types columns. It is not
2570/// used by the single/joint or Case-A EXECUTION reduce, so it cannot resolve a modal
2571/// into a join over an UN-gated candidate relation.
2572pub fn reduce_epistemic_program_to_ordinary_for_stratified_schema(
2573    program: &Program,
2574) -> Result<Program> {
2575    let prepared = prepare_root_authored_constraint_identity(program)?;
2576    let determined = EpistemicallyDeterminedPredicates::analyze(&prepared);
2577    let path_specific_rules = stratified_schema_reduction_overrides(&prepared)?;
2578    reduce_epistemic_program_to_ordinary_inner(
2579        &prepared,
2580        &determined.determined,
2581        &path_specific_rules,
2582    )
2583}
2584
2585fn prepare_root_authored_constraint_identity(program: &Program) -> Result<Program> {
2586    let mut prepared = program.clone();
2587    if prepared.authored_constraint_source_bound.is_some() {
2588        prepared.validate_prepared_authored_constraint_identity()?;
2589    } else {
2590        prepared.prepare_authored_constraint_identity_at_root()?;
2591    }
2592    Ok(prepared)
2593}
2594
2595/// Shared body of the epistemic-to-ordinary reduction.
2596///
2597/// `schema_only_determined_resolve` names predicates that are epistemically
2598/// DETERMINED and whose augmenting positive modal may additionally be resolved into a
2599/// positive ordinary atom for SCHEMA inference only (empty for the strict execution
2600/// reduce). The INVARIANT-target resolve is always active for both entry points.
2601fn reduce_epistemic_program_to_ordinary_inner(
2602    program: &Program,
2603    schema_only_determined_resolve: &BTreeSet<String>,
2604    path_specific_rules: &BTreeMap<usize, crate::ast::Rule>,
2605) -> Result<Program> {
2606    let path_specific_rule_indices = path_specific_rules.keys().copied().collect::<BTreeSet<_>>();
2607    validate_epistemic_relation_shapes(program, &path_specific_rule_indices)?;
2608
2609    // FAEEL FOUNDED-MODEL EXTENSION: a rule whose head is supported ONLY by circular
2610    // modal self-support (`possible p`/`know p` over its own head, with no independent
2611    // founded derivation) contributes nothing to the FAEEL founded model. Excluding the
2612    // rule from the reduced ordinary base is precisely the founded/equilibrium
2613    // semantics: the unfounded head is absent from the model rather than fabricated by
2614    // the stripped-modal `1=1` filler (which would wrongly found it, the G91 answer).
2615    //
2616    // This is the structural foundedness DECISION (compile-time, reusing the exact
2617    // `has_independent_founded_support` / `has_tuple_level_independent_founded_support`
2618    // structural support predicates) driving the EXTENSION COMPUTATION on the
2619    // GPU/runtime path: the dropped rule simply removes the unfounded head's founding
2620    // base, and the existing GPU world-view validation then accepts the empty/founded
2621    // candidate. G91 keeps the filler (no drop), so `possible p` stays accepted —
2622    // this drop IS the FAEEL-vs-G91 mode difference.
2623    //
2624    // SCOPE: the drop fires only for FAEEL mode. A rule whose head carries a variable
2625    // bound ONLY by the self-supporting modal is NOT dropped here; with the modal
2626    // stripped that variable is genuinely unbound (`UnsafeVariable`) in EVERY mode
2627    // (G91 included), so it must fall through to the existing safety path rather than
2628    // be silently elided. Dropping it would mask a mode-independent safety failure.
2629    let removed_rule_indices = faeel_unfounded_exact_tuple_self_support_rule_indices(program);
2630    let removed_rule_index_set = removed_rule_indices
2631        .iter()
2632        .copied()
2633        .collect::<BTreeSet<_>>();
2634    let active_original_rule_indices = (0..program.rules.len())
2635        .filter(|index| !removed_rule_index_set.contains(index))
2636        .collect::<Vec<_>>();
2637    let mut reduced = program_without_rule_indices(program, &removed_rule_indices);
2638
2639    // AUGMENTING positive modals over INVARIANT relations are resolved into positive
2640    // ordinary join atoms (instead of being stripped) so the augmented head columns
2641    // they introduce are range-restricted in the reduced ordinary candidate program.
2642    //
2643    // An AUGMENTING modal carries a variable that is appended to the head by
2644    // `append_body_local_tuple_key_variables_to_head` (a modal-local variable absent
2645    // from the user-visible head, e.g. `Y` in `one_hop(X) :- node(X), know edge(X,
2646    // Y)`). After the modal is stripped, that augmented `Y` column has no binding, so
2647    // the reduced rule would be unsafe (`UnsafeVariable`). Resolving the positive
2648    // modal over its (invariant) gated relation into a positive ordinary atom binds
2649    // the column. This mirrors the proven-sound Case-A invariant resolution
2650    // (`reduce_case_a_epistemic_program_to_ordinary`): for an INVARIANT relation `R`,
2651    // `know R`/`possible R` ranges exactly over `R`'s extension, so the reduced
2652    // candidate join over `R` enumerates the correct augmented tuples and the GPU
2653    // membership filter then re-gates them against the accepted world view.
2654    //
2655    // STRICTLY SCOPED to keep the prohibition on resolving over still-modal relations
2656    // machine-checked: only POSITIVE modals (negated `not know`/`not possible` is an
2657    // anti-join that does NOT range-restrict, so it is never resolved) over INVARIANT
2658    // targets (a still-modal / epistemic-derived target is NOT invariant, so it is
2659    // never resolved — its augmenting variable stays unbound and the reduced program
2660    // fails closed). Non-augmenting modals keep the existing single- and joint-solver
2661    // strip-and-gate path.
2662    let invariant = InvariantRelations::analyze(program);
2663
2664    // Every rule whose internal head gains tuple-key columns must use a widened
2665    // declaration. The recursive epistemic path has its own non-augmenting reducer;
2666    // this single-pass reduction records the actual head transformation, including
2667    // columns that an ordinary atom already binds. Rule indices retain the exact
2668    // source signature because the head arity changes before reconciliation.
2669    let mut augmented_rule_original_arities = BTreeMap::new();
2670
2671    for ((rule_index, rule), original_rule_index) in reduced
2672        .rules
2673        .iter_mut()
2674        .enumerate()
2675        .zip(active_original_rule_indices)
2676    {
2677        if let Some(path_specific_rule) = path_specific_rules.get(&original_rule_index) {
2678            *rule = path_specific_rule.clone();
2679            continue;
2680        }
2681        let original_head_arity = rule.head.arity();
2682        // Head variables that NO non-epistemic positive body literal binds. After the
2683        // modal is stripped, an output (head) variable bound ONLY by the modal would
2684        // be unsafe in the reduced ordinary program. Computed BEFORE the head is
2685        // mutated by augmentation. (`append_body_local_tuple_key_variables_to_head`
2686        // appends modal-local variables to the head, so both already-present head
2687        // variables like `Y` in `pair(X,Y) :- ..possible edge(X,Y)` AND augmented
2688        // variables like `Y` in `one_hop(X) :- ..know edge(X,Y)` are covered here.)
2689        let modal_only_output_variables = modal_only_bound_output_variables(rule);
2690        append_body_local_tuple_key_variables_to_head(rule);
2691        if rule.head.arity() > original_head_arity {
2692            augmented_rule_original_arities.insert(rule_index, original_head_arity);
2693        }
2694        let was_fact = rule.body.is_empty();
2695        let had_epistemic_body = rule
2696            .body
2697            .iter()
2698            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
2699        // Resolve a POSITIVE modal over an INVARIANT relation into a positive ordinary
2700        // join atom WHEN it is the sole binder of some output variable (so that output
2701        // variable is range-restricted in the reduced candidate program); strip every
2702        // other modal. For an invariant relation `R`, `know R`/`possible R` ranges
2703        // exactly over `R`'s extension, so the reduced join enumerates the correct
2704        // candidate tuples and the GPU filter re-gates against the accepted
2705        // world view. A NEGATED modal (anti-join) never binds and is never resolved; a
2706        // still-modal / epistemic-derived target is NOT invariant and is never
2707        // resolved, so its unbound output variable correctly fails closed downstream.
2708        for lit in &mut rule.body {
2709            if let BodyLiteral::Epistemic(modal) = lit {
2710                // The target is resolvable when it is INVARIANT (always — proven-sound
2711                // for both schema and execution), OR — for SCHEMA inference only — when
2712                // it is epistemically DETERMINED. The determined relaxation is empty for
2713                // the strict execution reduce, so an execution-path reduce never
2714                // resolves a modal over a still-derived (un-gated) relation.
2715                if resolves_augmented_head_variable(
2716                    modal,
2717                    &modal_only_output_variables,
2718                    &invariant,
2719                    schema_only_determined_resolve,
2720                ) {
2721                    *lit = BodyLiteral::Positive(modal.atom.clone());
2722                }
2723            }
2724        }
2725        rule.body
2726            .retain(|lit| !matches!(lit, BodyLiteral::Epistemic(_)));
2727        if !was_fact && had_epistemic_body && rule.body.is_empty() {
2728            rule.body.push(BodyLiteral::Comparison(Comparison {
2729                left: Term::Integer(1),
2730                op: CompOp::Eq,
2731                right: Term::Integer(1),
2732            }));
2733        }
2734    }
2735    // Head augmentation appends modal-local columns to a genuinely-augmented rule head
2736    // (e.g. `one_hop(X)` becomes `one_hop(X, Y)`), so the reduced relation carries the
2737    // augmented columns needed for the GPU tuple-key membership gate. The predicate
2738    // DECLARATION must be widened to the augmented arity, or the runtime would union
2739    // the augmented rule output against the narrow declared (empty) stub and fail with
2740    // a schema mismatch. Infer each appended column's type from the positive body
2741    // atom that binds it; modal-only columns use the resolved invariant atom.
2742    qualify_extensional_multi_arity_predicates(&mut reduced, program, &removed_rule_index_set);
2743
2744    let augmented_signatures =
2745        reconcile_augmented_head_declarations(&mut reduced, &augmented_rule_original_arities)?;
2746
2747    // Drop reduced-program queries that reference an AUGMENTED head: the reduced
2748    // relation is now arity-bumped, so an original arity-N query over it would union
2749    // the arity-N query projection against the augmented relation and fail with a
2750    // schema mismatch. The user-visible query results for epistemic heads are
2751    // surfaced separately from the GPU gated buffers (`epistemic_result_to_query_
2752    // results`, projected to public arity), and the surfacing gate
2753    // (`queried_predicates`) reads the ORIGINAL program's queries, so dropping the
2754    // redundant reduced query here is inert for display and only removes the crash.
2755    // Non-augmented epistemic heads keep their arity-matched reduced queries untouched.
2756    if !augmented_signatures.is_empty() {
2757        reduced.queries.retain(|query| {
2758            !augmented_signatures.contains_key(&(query.atom.predicate.clone(), query.atom.arity()))
2759        });
2760    }
2761
2762    // Constraints that contain epistemic literals are world-view integrity
2763    // constraints: they constrain accepted candidate world views and are
2764    // evaluated by the GPU world-view constraint kernel, NOT by the reduced
2765    // ordinary runtime. Stripping their epistemic literals would leave an
2766    // always-true ordinary constraint, so drop them from the reduced program
2767    // entirely. Purely relational constraints stay as ordinary constraints.
2768    reduced.constraints.retain(|constraint| {
2769        !constraint
2770            .body
2771            .iter()
2772            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
2773    });
2774
2775    Ok(reduced)
2776}
2777
2778/// Reduce an admitted recursive epistemic program to an ordinary program for the
2779/// existing fixpoint engine.
2780///
2781/// Unlike [`reduce_epistemic_program_to_ordinary`] (which strips modal literals and
2782/// gates the single-pass result post hoc), this RESOLVES each positive `know`/
2783/// `possible` literal to its gated relation by rewriting it into an ordinary positive
2784/// body atom over the same predicate. An invariant modal target becomes a fixed join;
2785/// a co-evolving FAEEL target becomes a recursive join whose least fixpoint is its
2786/// founded extension. Gelfond-1991 compatibility cycles are intercepted by
2787/// [`try_prepare_g91_compatibility_reduction`] and require their explicit descending
2788/// tuple fixpoint; this ordinary reducer never deletes those gates. Modal variables
2789/// become ordinary join variables, so tuple transitions remain inside the fixpoint
2790/// instead of being approximated by a post-hoc single-pass gate.
2791///
2792/// Callers MUST first admit the program through
2793/// [`classify_recursive_epistemic_program`]; this function assumes that contract for
2794/// every supported recursive class.
2795pub fn reduce_case_a_epistemic_program_to_ordinary(program: &Program) -> Program {
2796    let mut reduced = program.clone();
2797    for rule in &mut reduced.rules {
2798        resolve_recursive_epistemic_rule_modals(rule);
2799    }
2800    // World-view integrity constraints have no place in this ordinary recursive
2801    // program: the recursion already joins against the resolved relations. Drop any constraint that
2802    // still references a modal literal (purely relational constraints are retained).
2803    reduced.constraints.retain(|constraint| {
2804        !constraint
2805            .body
2806            .iter()
2807            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
2808    });
2809    qualify_extensional_multi_arity_predicates(&mut reduced, program, &BTreeSet::new());
2810    reduced
2811}
2812
2813/// Reduce the surviving acyclic portion of a FAEEL program after exact unfounded
2814/// self-support has been removed.
2815///
2816/// The removed cycle established that this source entered the founded recursive
2817/// route, but the remaining rules no longer need iteration through a modal edge.
2818/// Resolving their modal literals to ordinary atoms preserves the now-determined
2819/// founded extension and, unlike the single-pass candidate reducer, keeps every
2820/// surviving gate load-bearing. Modal integrity constraints are resolved as ordinary
2821/// constraints because the surviving program has a single determined model.
2822fn reduce_founded_epistemic_program_to_ordinary(program: &Program) -> Program {
2823    let mut reduced = program.clone();
2824    for rule in &mut reduced.rules {
2825        resolve_recursive_epistemic_rule_modals(rule);
2826    }
2827    for constraint in &mut reduced.constraints {
2828        for literal in &mut constraint.body {
2829            let BodyLiteral::Epistemic(modal) = literal else {
2830                continue;
2831            };
2832            *literal = if modal.negated {
2833                BodyLiteral::Negated(modal.atom.clone())
2834            } else {
2835                BodyLiteral::Positive(modal.atom.clone())
2836            };
2837        }
2838    }
2839    qualify_extensional_multi_arity_predicates(&mut reduced, program, &BTreeSet::new());
2840    reduced
2841}
2842
2843fn resolve_recursive_epistemic_rule_modals(rule: &mut crate::ast::Rule) {
2844    for literal in &mut rule.body {
2845        if let BodyLiteral::Epistemic(modal) = literal {
2846            *literal = if modal.negated {
2847                BodyLiteral::Negated(modal.atom.clone())
2848            } else {
2849                BodyLiteral::Positive(modal.atom.clone())
2850            };
2851        }
2852    }
2853}
2854
2855/// Output (head) variables of `rule` that are bound ONLY by epistemic literals, i.e.
2856/// no positive non-epistemic body literal binds them.
2857///
2858/// Includes BOTH variables already in the user-visible head (e.g. `Y` in
2859/// `pair(X,Y) :- color(X), possible edge(X,Y)`) AND modal-local variables that
2860/// augmentation will append to the head (e.g. `Y` in
2861/// `one_hop(X) :- node(X), know edge(X,Y)`). After the modal is stripped, each such
2862/// variable would be an unsafe head column unless a positive-invariant modal carrying
2863/// it is resolved into a positive ordinary atom. Computed from the ORIGINAL rule,
2864/// before the head is mutated by augmentation.
2865fn modal_only_bound_output_variables(rule: &crate::ast::Rule) -> BTreeSet<String> {
2866    let positively_bound = non_epistemic_bound_variables(&rule.body);
2867
2868    // Candidate output variables: every variable occurring in the user-visible head
2869    // plus every modal-local variable (which augmentation will append to the head).
2870    let mut modal_only = BTreeSet::new();
2871    let mut consider = |name: &str| {
2872        if name != "_" && !positively_bound.contains(name) {
2873            modal_only.insert(name.to_string());
2874        }
2875    };
2876    for term in &rule.head.terms {
2877        if let Term::Variable(name) = term {
2878            consider(name);
2879        }
2880    }
2881    for lit in &rule.body {
2882        if let BodyLiteral::Epistemic(lit) = lit {
2883            for term in &lit.atom.terms {
2884                if let Term::Variable(name) = term {
2885                    consider(name);
2886                }
2887            }
2888        }
2889    }
2890    modal_only
2891}
2892
2893/// Whether `modal`'s atom carries at least one output variable that no positive
2894/// non-epistemic body literal binds (so resolving this positive-invariant modal into a
2895/// positive ordinary atom range-restricts an otherwise-unbound head column).
2896fn modal_atom_binds_output_variable(
2897    modal: &EpistemicLiteral,
2898    modal_only_output_variables: &BTreeSet<String>,
2899) -> bool {
2900    modal.atom.terms.iter().any(
2901        |term| matches!(term, Term::Variable(name) if modal_only_output_variables.contains(name)),
2902    )
2903}
2904
2905fn resolves_augmented_head_variable(
2906    modal: &EpistemicLiteral,
2907    modal_only_output_variables: &BTreeSet<String>,
2908    invariant: &InvariantRelations,
2909    schema_only_determined_resolve: &BTreeSet<String>,
2910) -> bool {
2911    !modal.negated
2912        && (invariant.is_invariant(&modal.atom.predicate)
2913            || schema_only_determined_resolve.contains(&modal.atom.predicate))
2914        && modal_atom_binds_output_variable(modal, modal_only_output_variables)
2915}
2916
2917fn record_predicate_signature(
2918    signatures: &mut BTreeMap<String, BTreeSet<usize>>,
2919    atom: &crate::ast::Atom,
2920) {
2921    signatures
2922        .entry(atom.predicate.clone())
2923        .or_default()
2924        .insert(atom.arity());
2925}
2926
2927fn record_body_predicate_signatures(
2928    signatures: &mut BTreeMap<String, BTreeSet<usize>>,
2929    body: &[BodyLiteral],
2930) {
2931    for literal in body {
2932        match literal {
2933            BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
2934                record_predicate_signature(signatures, atom);
2935            }
2936            BodyLiteral::Epistemic(modal) => {
2937                let eir_terms = modal
2938                    .atom
2939                    .terms
2940                    .iter()
2941                    .map(convert_term)
2942                    .collect::<Vec<_>>();
2943                // Structured-key validation owns the typed error for unsupported
2944                // shapes. Keep this identity census total for reducers that inspect
2945                // the source before lowering; valid keys use the exact same
2946                // production flattener as planning, while an invalid key retains its
2947                // source arity until validation rejects it.
2948                let arity = flatten_structured_key_terms(&modal.atom.predicate, &eir_terms)
2949                    .map(|(arity, _, _)| arity)
2950                    .unwrap_or_else(|_| modal.atom.arity());
2951                signatures
2952                    .entry(modal.atom.predicate.clone())
2953                    .or_default()
2954                    .insert(arity);
2955            }
2956            BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {}
2957        }
2958    }
2959}
2960
2961fn collect_epistemic_relation_identities(
2962    program: &Program,
2963    removed_rules: &BTreeSet<usize>,
2964) -> (BTreeMap<String, BTreeSet<usize>>, BTreeSet<String>) {
2965    let mut source_arities: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
2966    for declaration in &program.predicates {
2967        source_arities
2968            .entry(declaration.name.clone())
2969            .or_default()
2970            .insert(declaration.arity());
2971    }
2972
2973    let mut derived_predicates = BTreeSet::new();
2974    for (index, rule) in program.rules.iter().enumerate() {
2975        if removed_rules.contains(&index) {
2976            continue;
2977        }
2978        record_predicate_signature(&mut source_arities, &rule.head);
2979        record_body_predicate_signatures(&mut source_arities, &rule.body);
2980        if !rule.body.is_empty() {
2981            derived_predicates.insert(rule.head.predicate.clone());
2982        }
2983    }
2984    for constraint in &program.constraints {
2985        record_body_predicate_signatures(&mut source_arities, &constraint.body);
2986    }
2987    for query in &program.queries {
2988        record_predicate_signature(&mut source_arities, &query.atom);
2989    }
2990    for fact in &program.prob_facts {
2991        record_predicate_signature(&mut source_arities, &fact.atom);
2992    }
2993    for disjunction in &program.annotated_disjunctions {
2994        for choice in &disjunction.choices {
2995            record_predicate_signature(&mut source_arities, &choice.atom);
2996        }
2997    }
2998    for evidence in &program.evidence {
2999        record_predicate_signature(&mut source_arities, &evidence.atom);
3000    }
3001    for query in &program.prob_queries {
3002        record_predicate_signature(&mut source_arities, &query.atom);
3003    }
3004    for declaration in &program.neural_predicates {
3005        record_predicate_signature(&mut source_arities, &declaration.predicate);
3006    }
3007    for rule in &program.learnable_rules {
3008        record_predicate_signature(&mut source_arities, &rule.head);
3009        record_body_predicate_signatures(&mut source_arities, &rule.body);
3010        if !rule.body.is_empty() {
3011            derived_predicates.insert(rule.head.predicate.clone());
3012        }
3013    }
3014
3015    (source_arities, derived_predicates)
3016}
3017
3018fn all_multi_arity_predicates(program: &Program) -> BTreeSet<String> {
3019    collect_epistemic_relation_identities(program, &BTreeSet::new())
3020        .0
3021        .into_iter()
3022        .filter_map(|(predicate, arities)| (arities.len() > 1).then_some(predicate))
3023        .collect()
3024}
3025
3026fn qualify_atom_for_extensional_multi_arity(
3027    atom: &mut crate::ast::Atom,
3028    predicates: &BTreeSet<String>,
3029) {
3030    if predicates.contains(&atom.predicate) {
3031        atom.predicate = format!("{}/{}", atom.predicate, atom.arity());
3032    }
3033}
3034
3035fn qualify_body_for_extensional_multi_arity(
3036    body: &mut [BodyLiteral],
3037    predicates: &BTreeSet<String>,
3038) {
3039    for literal in body {
3040        match literal {
3041            BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
3042                qualify_atom_for_extensional_multi_arity(atom, predicates);
3043            }
3044            BodyLiteral::Epistemic(modal) => {
3045                qualify_atom_for_extensional_multi_arity(&mut modal.atom, predicates);
3046            }
3047            BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {}
3048        }
3049    }
3050}
3051
3052/// Return extensional predicate names whose runtime identity must include arity.
3053///
3054/// The census covers every source surface that can name a relation, including
3055/// constraints, probabilistic constructs, neural declarations, and learnable
3056/// rules. FAEEL rules removed as unfounded support do not make a predicate derived
3057/// or add a live signature. Reducers and fact upload must use this same set so a
3058/// source fact and its compiled scan always receive the same runtime name.
3059pub fn epistemic_extensional_multi_arity_predicates(program: &Program) -> BTreeSet<String> {
3060    let removed_rules = faeel_unfounded_exact_tuple_self_support_rule_indices(program)
3061        .into_iter()
3062        .collect::<BTreeSet<_>>();
3063    extensional_multi_arity_predicates(program, &removed_rules)
3064}
3065
3066fn extensional_multi_arity_predicates(
3067    program: &Program,
3068    removed_rules: &BTreeSet<usize>,
3069) -> BTreeSet<String> {
3070    let (source_arities, derived_predicates) =
3071        collect_epistemic_relation_identities(program, removed_rules);
3072    source_arities
3073        .into_iter()
3074        .filter_map(|(predicate, arities)| {
3075            (arities.len() > 1 && !derived_predicates.contains(&predicate)).then_some(predicate)
3076        })
3077        .collect()
3078}
3079
3080/// Give each extensional source signature the same arity-qualified runtime name
3081/// used by the GPU fact loader.
3082///
3083/// This transformation is limited to predicates with no active defining rule.
3084/// Derived predicates are validated separately because their public output and
3085/// recursive relation identity remain name-keyed.
3086fn qualify_extensional_multi_arity_predicates(
3087    reduced: &mut Program,
3088    source: &Program,
3089    removed_rules: &BTreeSet<usize>,
3090) {
3091    let predicates = extensional_multi_arity_predicates(source, removed_rules);
3092    qualify_predicate_signatures(reduced, &predicates);
3093}
3094
3095/// Apply canonical name-and-arity identities to every AST surface naming one of
3096/// `predicates`. Runtime reduction calls this only for extensional multi-arity names;
3097/// source validation calls it for every multi-arity name so each authored signature
3098/// retains its own declarations and clauses while being checked.
3099fn qualify_predicate_signatures(reduced: &mut Program, predicates: &BTreeSet<String>) {
3100    if predicates.is_empty() {
3101        return;
3102    }
3103
3104    for declaration in &mut reduced.predicates {
3105        if predicates.contains(&declaration.name) {
3106            declaration.name = format!("{}/{}", declaration.name, declaration.arity());
3107        }
3108    }
3109    for rule in &mut reduced.rules {
3110        qualify_atom_for_extensional_multi_arity(&mut rule.head, predicates);
3111        qualify_body_for_extensional_multi_arity(&mut rule.body, predicates);
3112    }
3113    for constraint in &mut reduced.constraints {
3114        qualify_body_for_extensional_multi_arity(&mut constraint.body, predicates);
3115    }
3116    for query in &mut reduced.queries {
3117        qualify_atom_for_extensional_multi_arity(&mut query.atom, predicates);
3118    }
3119    for fact in &mut reduced.prob_facts {
3120        qualify_atom_for_extensional_multi_arity(&mut fact.atom, predicates);
3121    }
3122    for disjunction in &mut reduced.annotated_disjunctions {
3123        for choice in &mut disjunction.choices {
3124            qualify_atom_for_extensional_multi_arity(&mut choice.atom, predicates);
3125        }
3126    }
3127    for evidence in &mut reduced.evidence {
3128        qualify_atom_for_extensional_multi_arity(&mut evidence.atom, predicates);
3129    }
3130    for query in &mut reduced.prob_queries {
3131        qualify_atom_for_extensional_multi_arity(&mut query.atom, predicates);
3132    }
3133    for declaration in &mut reduced.neural_predicates {
3134        qualify_atom_for_extensional_multi_arity(&mut declaration.predicate, predicates);
3135    }
3136    for rule in &mut reduced.learnable_rules {
3137        qualify_atom_for_extensional_multi_arity(&mut rule.head, predicates);
3138        qualify_body_for_extensional_multi_arity(&mut rule.body, predicates);
3139    }
3140}
3141
3142/// Validate the name-keyed runtime identity used for derived epistemic
3143/// relations and return every authored predicate signature.
3144///
3145/// Pure extensional predicates may use the same name at multiple arities because
3146/// their reduced runtime identities are arity-qualified. Once a predicate is
3147/// derived, however, the ordinary compiler and output materializer assign one
3148/// relation identity to its name. Every occurrence is included here so a query,
3149/// body atom, declaration, or auxiliary probabilistic construct cannot alias a
3150/// derived relation at a different arity.
3151fn validate_epistemic_derived_relation_identity(
3152    program: &Program,
3153    removed_rules: &BTreeSet<usize>,
3154) -> Result<BTreeMap<String, BTreeSet<usize>>> {
3155    let (source_arities, derived_predicates) =
3156        collect_epistemic_relation_identities(program, removed_rules);
3157    for predicate in derived_predicates {
3158        let arities = source_arities
3159            .get(&predicate)
3160            .expect("derived predicate has a source signature");
3161        if arities.len() > 1 {
3162            return Err(XlogError::UnsupportedEpistemicConstruct {
3163                construct: "epistemic derived predicate schema".to_string(),
3164                context: format!(
3165                    "derived predicate `{predicate}` uses multiple source arities {arities:?}; \
3166                     epistemic derived relations require one source signature per predicate name"
3167                ),
3168            });
3169        }
3170    }
3171
3172    Ok(source_arities)
3173}
3174
3175/// Ensure every clause for an augmented predicate signature lowers to one internal
3176/// relation arity.
3177///
3178/// A modal-local output variable adds hidden tuple-key columns to a rule head. If a
3179/// sibling clause for the same original signature produces a different number of
3180/// columns, the clauses cannot be unioned into one relation without inventing values
3181/// that the shorter clause does not bind. Reject that unsupported shape before any
3182/// reduced program reaches schema inference.
3183fn validate_epistemic_relation_shapes(
3184    program: &Program,
3185    non_augmenting_rule_indices: &BTreeSet<usize>,
3186) -> Result<()> {
3187    let removed_rules = faeel_unfounded_exact_tuple_self_support_rule_indices(program)
3188        .into_iter()
3189        .collect::<BTreeSet<_>>();
3190    let active_rules = program
3191        .rules
3192        .iter()
3193        .enumerate()
3194        .filter(|(index, _)| {
3195            !removed_rules.contains(index) && !non_augmenting_rule_indices.contains(index)
3196        })
3197        .collect::<Vec<_>>();
3198
3199    validate_epistemic_derived_relation_identity(program, &removed_rules)?;
3200
3201    let mut reduced_rule_arities = Vec::with_capacity(active_rules.len());
3202    let mut augmented_targets: BTreeMap<(String, usize), usize> = BTreeMap::new();
3203
3204    for (_, rule) in &active_rules {
3205        let original_signature = (rule.head.predicate.clone(), rule.head.arity());
3206        let mut reduced_rule = (*rule).clone();
3207        append_body_local_tuple_key_variables_to_head(&mut reduced_rule);
3208        let reduced_arity = reduced_rule.head.arity();
3209
3210        if reduced_arity > original_signature.1 {
3211            augmented_targets
3212                .entry(original_signature.clone())
3213                .and_modify(|target| *target = (*target).max(reduced_arity))
3214                .or_insert(reduced_arity);
3215        }
3216        reduced_rule_arities.push((original_signature, reduced_arity));
3217    }
3218
3219    for ((predicate, original_arity), target_arity) in augmented_targets {
3220        let arities = reduced_rule_arities
3221            .iter()
3222            .filter(|((candidate, arity), _)| candidate == &predicate && *arity == original_arity)
3223            .map(|(_, arity)| *arity)
3224            .collect::<BTreeSet<_>>();
3225        if arities.len() != 1 || !arities.contains(&target_arity) {
3226            return Err(XlogError::UnsupportedEpistemicConstruct {
3227                construct: "epistemic augmented predicate schema".to_string(),
3228                context: format!(
3229                    "rules defining `{predicate}/{original_arity}` lower to incompatible \
3230                     internal arities {arities:?}; every clause for one predicate signature \
3231                     must bind the same augmented tuple shape"
3232                ),
3233            });
3234        }
3235
3236        for query in program.queries.iter().filter(|query| {
3237            query.atom.predicate == predicate && query.atom.arity() == original_arity
3238        }) {
3239            let mut variables = BTreeSet::new();
3240            let unconstrained = query.atom.terms.iter().all(|term| match term {
3241                Term::Variable(name) => name != "_" && variables.insert(name.as_str()),
3242                Term::Anonymous
3243                | Term::Integer(_)
3244                | Term::Float(_)
3245                | Term::String(_)
3246                | Term::Symbol(_)
3247                | Term::List(_)
3248                | Term::Cons { .. }
3249                | Term::Compound { .. }
3250                | Term::PredRef(_)
3251                | Term::Aggregate(_) => false,
3252            });
3253            if !unconstrained {
3254                return Err(XlogError::UnsupportedEpistemicConstruct {
3255                    construct: "epistemic augmented head query".to_string(),
3256                    context: format!(
3257                        "query `{predicate}/{original_arity}` is not a tuple of distinct named \
3258                         variables; an augmented epistemic head can currently surface only \
3259                         queries whose arguments are distinct named variables"
3260                    ),
3261                });
3262            }
3263        }
3264    }
3265
3266    let eir = build_eir(program)?;
3267    let mut clauses_by_signature: BTreeMap<(String, usize), Vec<(usize, &crate::ast::Rule)>> =
3268        BTreeMap::new();
3269    for (rule_index, rule) in active_rules {
3270        clauses_by_signature
3271            .entry((rule.head.predicate.clone(), rule.head.arity()))
3272            .or_default()
3273            .push((rule_index, rule));
3274    }
3275    for ((predicate, arity), clauses) in clauses_by_signature {
3276        if clauses.len() > 1
3277            && clauses.iter().any(|(_, rule)| {
3278                rule.body
3279                    .iter()
3280                    .any(|literal| matches!(literal, BodyLiteral::Epistemic(_)))
3281            })
3282            && !epistemic_rule_union_gates_are_redundant(program, &eir, &clauses)
3283        {
3284            return Err(XlogError::UnsupportedEpistemicConstruct {
3285                construct: "epistemic rule-union materialization".to_string(),
3286                context: format!(
3287                    "predicate `{predicate}/{arity}` has multiple defining clauses and at least \
3288                     one epistemic clause; single-pass materialization cannot preserve \
3289                     per-clause modal provenance, so it cannot safely filter the clause union"
3290                ),
3291            });
3292        }
3293    }
3294
3295    Ok(())
3296}
3297
3298/// Prove that applying one clause's modal filters to an already-unioned relation
3299/// cannot remove rows contributed by its ordinary sibling clauses.
3300///
3301/// The single-pass materializer has no per-clause provenance. A multi-clause head is
3302/// therefore admitted only when either every clause has the same normalized modal
3303/// conjunction relative to its output columns, or there is exactly one epistemic
3304/// clause and every one of its modal gates is positive and provably true for every
3305/// candidate row:
3306///
3307/// - a ground atom has unconditional ordinary support from explicit facts/rules; or
3308/// - the modal atom is exactly a bijective all-variable clause head tuple and that
3309///   tuple has independent founded support under the clause's positive relational
3310///   domain; or
3311/// - G91 admits that same exact tuple under a positive `possible` self-support gate.
3312///
3313/// Equal conjunctions distribute over a union. The exact-head proofs are safe because
3314/// ordinary sibling clauses derive each head tuple directly, while the epistemic
3315/// clause's own rows are founded or explicitly self-supported under G91. Repeated
3316/// variables, constants, and wildcards are not bijective: they can map a sibling row
3317/// to a different modal key and therefore remain unsupported unless every clause has
3318/// the same normalized filter.
3319fn epistemic_rule_union_gates_are_redundant(
3320    program: &Program,
3321    eir: &EirProgram,
3322    clauses: &[(usize, &crate::ast::Rule)],
3323) -> bool {
3324    let invariant = InvariantRelations::analyze(program);
3325    let mut normalized_conjunctions = clauses.iter().map(|(rule_index, _)| {
3326        eir.rules
3327            .get(*rule_index)
3328            .and_then(|rule| normalized_rule_union_gates(rule, &invariant))
3329    });
3330    if let Some(Some(first)) = normalized_conjunctions.next() {
3331        if !first.is_empty()
3332            && normalized_conjunctions.all(|candidate| {
3333                candidate.is_some_and(|candidate| rule_union_gate_sets_equal(&first, &candidate))
3334            })
3335        {
3336            return true;
3337        }
3338    }
3339
3340    let epistemic_clauses = clauses
3341        .iter()
3342        .filter(|(_, rule)| {
3343            rule.body
3344                .iter()
3345                .any(|literal| matches!(literal, BodyLiteral::Epistemic(_)))
3346        })
3347        .collect::<Vec<_>>();
3348    if epistemic_clauses.is_empty() {
3349        return true;
3350    }
3351
3352    // Every modal filter is redundant when it is a positive ground atom with an
3353    // unconditional founded proof. This remains distributive even when several
3354    // sibling clauses use different ground modal atoms: every gate is true before
3355    // the clause outputs are unioned, so no per-clause provenance is needed later.
3356    let every_gate_is_unconditionally_true = epistemic_clauses.iter().all(|(rule_index, _)| {
3357        eir.rules.get(*rule_index).is_some_and(|eir_rule| {
3358            let modal_literals = eir_rule
3359                .body
3360                .iter()
3361                .filter_map(|literal| match literal {
3362                    EirBodyLiteral::Epistemic(modal) => Some(modal),
3363                    _ => None,
3364                })
3365                .collect::<Vec<_>>();
3366            !modal_literals.is_empty()
3367                && modal_literals.iter().all(|modal| {
3368                    !modal.negated && has_unconditional_ground_founded_support(eir, &modal.atom)
3369                })
3370        })
3371    });
3372    if every_gate_is_unconditionally_true {
3373        return true;
3374    }
3375
3376    if epistemic_clauses.len() != 1 {
3377        return false;
3378    }
3379
3380    let (rule_index, _) = epistemic_clauses[0];
3381    let Some(eir_rule) = eir.rules.get(*rule_index) else {
3382        return false;
3383    };
3384    let modal_literals = eir_rule
3385        .body
3386        .iter()
3387        .filter_map(|literal| match literal {
3388            EirBodyLiteral::Epistemic(modal) => Some(modal),
3389            _ => None,
3390        })
3391        .collect::<Vec<_>>();
3392
3393    !modal_literals.is_empty()
3394        && modal_literals.iter().all(|modal| {
3395            !modal.negated
3396                && (has_unconditional_ground_founded_support(eir, &modal.atom)
3397                    || (modal.atom == eir_rule.head
3398                        && eir_head_is_bijective_variable_tuple(&eir_rule.head)
3399                        && ((eir.mode == EirEpistemicMode::G91
3400                            && modal.op == EirEpistemicOp::Possible)
3401                            || has_tuple_level_independent_founded_support(
3402                                eir,
3403                                eir_rule,
3404                                &modal.atom,
3405                            ))))
3406        })
3407}
3408
3409#[derive(Debug, Clone, PartialEq, Eq)]
3410enum RuleUnionGateTerm {
3411    OutputColumn(usize),
3412    Literal(EirTerm),
3413}
3414
3415#[derive(Debug, Clone, PartialEq, Eq)]
3416struct RuleUnionGate {
3417    predicate: String,
3418    arity: usize,
3419    terms: Vec<RuleUnionGateTerm>,
3420    op: Option<EirEpistemicOp>,
3421    negated: bool,
3422}
3423
3424/// Normalize a clause's modal conjunction to the exact tuple-key binding that the
3425/// materializer applies. Variable names are replaced by output-column positions, and
3426/// `know`/`possible` are identified over invariant relations because those relations
3427/// have one fixed extension in every accepted world.
3428fn normalized_rule_union_gates(
3429    rule: &xlog_ir::EirRule,
3430    invariant: &InvariantRelations<'_>,
3431) -> Option<Vec<RuleUnionGate>> {
3432    let output_terms = augmented_eir_head_terms(rule);
3433    let mut gates = Vec::new();
3434    for literal in &rule.body {
3435        let EirBodyLiteral::Epistemic(modal) = literal else {
3436            continue;
3437        };
3438        let bound_columns = bound_output_columns_for_terms(&modal.atom.terms, &output_terms);
3439        let terms = modal
3440            .atom
3441            .terms
3442            .iter()
3443            .zip(bound_columns)
3444            .map(|(term, output_column)| match (term, output_column) {
3445                (EirTerm::Variable(_), Some(column)) => {
3446                    Some(RuleUnionGateTerm::OutputColumn(column))
3447                }
3448                (
3449                    term @ (EirTerm::Anonymous
3450                    | EirTerm::Integer(_)
3451                    | EirTerm::FloatBits(_)
3452                    | EirTerm::String(_)
3453                    | EirTerm::Symbol(_)
3454                    | EirTerm::PredRef(_)),
3455                    None,
3456                ) => Some(RuleUnionGateTerm::Literal(term.clone())),
3457                _ => None,
3458            })
3459            .collect::<Option<Vec<_>>>()?;
3460        let gate = RuleUnionGate {
3461            predicate: modal.atom.predicate.clone(),
3462            arity: modal.atom.arity,
3463            terms,
3464            op: (!invariant.is_invariant(&modal.atom.predicate)).then_some(modal.op),
3465            negated: modal.negated,
3466        };
3467        if !gates.contains(&gate) {
3468            gates.push(gate);
3469        }
3470    }
3471    Some(gates)
3472}
3473
3474fn rule_union_gate_sets_equal(left: &[RuleUnionGate], right: &[RuleUnionGate]) -> bool {
3475    left.len() == right.len() && left.iter().all(|gate| right.contains(gate))
3476}
3477
3478fn eir_head_is_bijective_variable_tuple(head: &xlog_ir::EirAtom) -> bool {
3479    let mut variables = BTreeSet::new();
3480    head.terms.iter().all(|term| match term {
3481        EirTerm::Variable(name) => variables.insert(name),
3482        _ => false,
3483    })
3484}
3485
3486/// Widen each predicate's declaration to the maximum arity of its (now possibly
3487/// augmented) defining rule heads, inferring appended column types from the positive
3488/// body atoms that bind the augmented head variables.
3489///
3490/// Augmentation appends modal-local columns to a rule head; without widening the
3491/// matching `PredDecl`, the runtime would union the augmented rule output against the
3492/// narrow declared (empty) relation stub and fail with a schema mismatch.
3493///
3494/// Only rules in `augmented_rule_original_arities` are reconciled. Original arity is
3495/// retained separately because augmentation has already changed the rule head by the
3496/// time reconciliation runs.
3497///
3498/// Returns each original predicate signature that was augmented and its resulting
3499/// arity, whether or not that signature has an explicit declaration.
3500fn reconcile_augmented_head_declarations(
3501    reduced: &mut Program,
3502    augmented_rule_original_arities: &BTreeMap<usize, usize>,
3503) -> Result<BTreeMap<(String, usize), usize>> {
3504    use crate::ast::{PredColumn, TypeRef};
3505
3506    // Per original head signature: the maximum augmented rule-head arity and, per
3507    // column position, an inferred type from a positive body atom (the resolved modal
3508    // or any binder).
3509    let mut augmented_signatures: BTreeMap<(String, usize), usize> = BTreeMap::new();
3510    let mut inferred_types: BTreeMap<(String, usize), Vec<Option<TypeRef>>> = BTreeMap::new();
3511
3512    // Use the ordinary lowerer's fixed-point schema inference after extensional
3513    // multi-arity names have been qualified. This is the same source of truth used
3514    // by production compilation, so undeclared facts, transitive rule chains,
3515    // declarations, domains, and arithmetic bindings all contribute their real
3516    // scalar types instead of falling through to a guessed hidden-column type.
3517    let mut lowerer = Lowerer::new();
3518    lowerer.infer_schemas(reduced)?;
3519    let schemas = lowerer.schemas().clone();
3520
3521    for (rule_index, rule) in reduced.rules.iter().enumerate() {
3522        if rule.body.is_empty() {
3523            continue;
3524        }
3525        // Only rules where the invariant-resolve genuinely fired are reconciled.
3526        let Some(&original_arity) = augmented_rule_original_arities.get(&rule_index) else {
3527            continue;
3528        };
3529        let arity = rule.head.terms.len();
3530        if arity <= original_arity {
3531            continue;
3532        }
3533        let signature = (rule.head.predicate.clone(), original_arity);
3534        let entry = augmented_signatures.entry(signature.clone()).or_insert(0);
3535        if arity > *entry {
3536            *entry = arity;
3537        }
3538        let types = inferred_types
3539            .entry(signature)
3540            .or_insert_with(|| vec![None; arity]);
3541        if types.len() < arity {
3542            types.resize(arity, None);
3543        }
3544        let variable_types = lowerer.infer_rule_variable_types(rule, |atom, index| {
3545            schemas
3546                .get(&atom.predicate)
3547                .and_then(|schema| schema.column_type(index))
3548        })?;
3549
3550        // Infer each head variable's type from every binding form understood by
3551        // ordinary lowering, including body atoms and deterministic `is` results.
3552        for (col, term) in rule.head.terms.iter().enumerate() {
3553            if types[col].is_some() {
3554                continue;
3555            }
3556            let Term::Variable(head_var) = term else {
3557                continue;
3558            };
3559            if let Some((typ, _)) = variable_types.get(head_var) {
3560                types[col] = Some(TypeRef::Scalar(*typ));
3561            }
3562        }
3563    }
3564
3565    for decl in &mut reduced.predicates {
3566        let signature = (decl.name.clone(), decl.arity());
3567        let Some(&target_arity) = augmented_signatures.get(&signature) else {
3568            continue;
3569        };
3570        let mut columns = decl.schema_columns();
3571        if target_arity <= columns.len() {
3572            continue;
3573        }
3574        let inferred = inferred_types.get(&signature);
3575        for col in columns.len()..target_arity {
3576            let typ = inferred
3577                .and_then(|types| types.get(col))
3578                .and_then(|t| t.clone())
3579                // Default appended columns to U32 (the modal relation key column type).
3580                .unwrap_or(TypeRef::Scalar(xlog_core::ScalarType::U32));
3581            columns.push(PredColumn { name: None, typ });
3582        }
3583        decl.types = columns.iter().map(|column| column.typ.clone()).collect();
3584        decl.columns = columns;
3585    }
3586
3587    Ok(augmented_signatures)
3588}
3589
3590fn append_body_local_tuple_key_variables_to_head(rule: &mut crate::ast::Rule) {
3591    let mut hidden_variables = Vec::new();
3592    for lit in &rule.body {
3593        let BodyLiteral::Epistemic(lit) = lit else {
3594            continue;
3595        };
3596        for term in &lit.atom.terms {
3597            let Term::Variable(variable) = term else {
3598                continue;
3599            };
3600            if variable == "_" {
3601                continue;
3602            }
3603            let already_in_head = rule
3604                .head
3605                .terms
3606                .iter()
3607                .any(|head_term| matches!(head_term, Term::Variable(name) if name == variable));
3608            if !already_in_head && !hidden_variables.iter().any(|name| name == variable) {
3609                hidden_variables.push(variable.clone());
3610            }
3611        }
3612    }
3613    for variable in hidden_variables {
3614        rule.head.terms.push(Term::Variable(variable));
3615    }
3616}
3617
3618fn wcoj_status_for_reduction(
3619    positive_relational_atoms: &[xlog_ir::EirAtom],
3620    has_negated_relational_atom: bool,
3621) -> EpistemicWcojReductionStatus {
3622    if !has_negated_relational_atom
3623        && positive_relational_atoms_are_supported_wcoj_shape(positive_relational_atoms)
3624    {
3625        EpistemicWcojReductionStatus::RequiresPlannerEligibility
3626    } else {
3627        EpistemicWcojReductionStatus::NotWcojCandidate
3628    }
3629}
3630
3631fn positive_relational_atoms_are_supported_wcoj_shape(atoms: &[xlog_ir::EirAtom]) -> bool {
3632    let mut edges: BTreeSet<(String, String)> = BTreeSet::new();
3633    let mut degrees: BTreeMap<String, usize> = BTreeMap::new();
3634    for atom in atoms {
3635        if atom.arity != 2 || atom.terms.len() != 2 {
3636            return false;
3637        }
3638        let Some(left) = eir_variable_name(&atom.terms[0]) else {
3639            return false;
3640        };
3641        let Some(right) = eir_variable_name(&atom.terms[1]) else {
3642            return false;
3643        };
3644        if left == right {
3645            return false;
3646        }
3647        let edge = if left < right {
3648            (left.to_string(), right.to_string())
3649        } else {
3650            (right.to_string(), left.to_string())
3651        };
3652        if !edges.insert(edge.clone()) {
3653            return false;
3654        }
3655        *degrees.entry(edge.0).or_insert(0) += 1;
3656        *degrees.entry(edge.1).or_insert(0) += 1;
3657    }
3658
3659    match edges.len() {
3660        3 => degrees.len() == 3 && degrees.values().all(|degree| *degree == 2),
3661        4 => degrees.len() == 4 && degrees.values().all(|degree| *degree == 2),
3662        10 | 15 | 21 | 28 => {
3663            let variable_count = degrees.len();
3664            (5..=8).contains(&variable_count)
3665                && edges.len() == variable_count * (variable_count - 1) / 2
3666                && degrees.values().all(|degree| *degree == variable_count - 1)
3667        }
3668        _ => false,
3669    }
3670}
3671
3672fn eir_variable_name(term: &EirTerm) -> Option<&str> {
3673    match term {
3674        EirTerm::Variable(name) => Some(name.as_str()),
3675        _ => None,
3676    }
3677}
3678
3679/// Result of bounded FAEEL candidate evaluation.
3680#[derive(Debug, Clone, PartialEq, Eq)]
3681pub enum FaeelCandidateResult {
3682    /// Candidate satisfies the bounded FAEEL fixture semantics.
3683    Model,
3684    /// Candidate has no model for a typed reason.
3685    NoModel(FaeelNoModelReason),
3686}
3687
3688/// Typed no-model reason for bounded FAEEL fixtures.
3689#[derive(Debug, Clone, PartialEq, Eq)]
3690pub enum FaeelNoModelReason {
3691    /// Candidate uses possible-only support where FAEEL requires founded knowledge.
3692    UnfoundedPossible {
3693        /// Predicate name.
3694        predicate: String,
3695        /// Predicate arity.
3696        arity: usize,
3697    },
3698    /// Candidate marks the same atom known and rejected.
3699    Contradiction {
3700        /// Predicate name.
3701        predicate: String,
3702        /// Predicate arity.
3703        arity: usize,
3704    },
3705    /// An epistemic literal is unsatisfied by the candidate.
3706    UnsatisfiedLiteral {
3707        /// Predicate name.
3708        predicate: String,
3709        /// Predicate arity.
3710        arity: usize,
3711    },
3712}
3713
3714/// Configuration for bounded Generate-Propagate-Test fixture execution.
3715#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3716pub struct GeneratePropagateTestConfig {
3717    /// Maximum candidate count accepted by the generate phase.
3718    pub max_candidates: usize,
3719}
3720
3721/// Phase counters emitted by bounded Generate-Propagate-Test execution.
3722#[derive(Debug, Clone, Default, PartialEq, Eq)]
3723pub struct GeneratePropagateTestTrace {
3724    /// Number of generated candidates.
3725    pub generated: usize,
3726    /// Number of epistemic guesses generated.
3727    pub guesses: usize,
3728    /// Number of candidates that survived propagation.
3729    pub propagated: usize,
3730    /// Number of candidates pruned during propagation.
3731    pub pruned: usize,
3732    /// Number of reduced-program models inspected by the test phase.
3733    pub reduced_program_models: usize,
3734    /// Number of candidates tested.
3735    pub tested: usize,
3736    /// Number of accepted candidates.
3737    pub accepted: usize,
3738    /// Number of accepted world views.
3739    pub accepted_world_views: usize,
3740    /// Number of rejected candidates.
3741    pub rejected: usize,
3742    /// Rejection reasons observed during propagation and testing.
3743    pub rejection_reasons: Vec<FaeelNoModelReason>,
3744}
3745
3746/// Result of bounded Generate-Propagate-Test fixture execution.
3747#[derive(Debug, Clone, PartialEq, Eq)]
3748pub struct GeneratePropagateTestOutcome {
3749    /// Phase counts.
3750    pub trace: GeneratePropagateTestTrace,
3751    /// Original indices of accepted candidates.
3752    pub accepted_candidate_indices: Vec<usize>,
3753    /// Original indices of rejected candidates in rejection-reason order.
3754    pub rejected_candidate_indices: Vec<usize>,
3755}
3756
3757/// Reason that two source rules were coalesced into the same dependency component.
3758///
3759/// These reasons make the split planner's structural decisions explainable: a
3760/// caller can read, for every component, *why* its rules could not be solved
3761/// independently of one another through split-component diagnostics.
3762#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
3763pub enum EpistemicComponentMergeReason {
3764    /// Two rules share the same head predicate, so they jointly define one
3765    /// derived relation and must be solved together.
3766    SharedHeadPredicate {
3767        /// Head predicate defined by both rules.
3768        predicate: String,
3769    },
3770    /// One rule's body consumes a predicate that another rule derives in its
3771    /// head (an ordinary/negated derived dependency).
3772    DerivedPredicate {
3773        /// Head predicate produced by the producer rule and consumed by the
3774        /// consumer rule body.
3775        predicate: String,
3776    },
3777    /// Two rules reference the same epistemic (modal) predicate, so their
3778    /// world-view acceptance is mutually dependent.
3779    SharedModalPredicate {
3780        /// Epistemic predicate referenced by both rules, with arity.
3781        predicate: String,
3782    },
3783    /// An integrity constraint mentions head predicates owned by both rules, so
3784    /// the constraint coalesces exactly those components.
3785    Constraint {
3786        /// Constraint-mentioned head predicates that forced the coalesce.
3787        predicates: Vec<String>,
3788    },
3789}
3790
3791/// One deterministic dependency component for epistemic splitting.
3792#[derive(Debug, Clone, PartialEq, Eq)]
3793pub struct EpistemicDependencyComponent {
3794    /// Sorted predicate names in the component.
3795    pub predicates: Vec<String>,
3796    /// Source rule indices owned by the component.
3797    pub rule_indices: Vec<usize>,
3798    /// Sorted, deduplicated reasons the component's rules were coalesced.
3799    ///
3800    /// Empty when the component is a single independent rule that no
3801    /// dependency forced together (it was split out on its own).
3802    pub merge_reasons: Vec<EpistemicComponentMergeReason>,
3803}
3804
3805/// Deterministic dependency graph used by bounded epistemic splitting.
3806#[derive(Debug, Clone, PartialEq, Eq)]
3807pub struct EpistemicDependencyGraph {
3808    /// Sorted components.
3809    pub components: Vec<EpistemicDependencyComponent>,
3810}
3811
3812/// Split plan for independently solvable epistemic components.
3813#[derive(Debug, Clone, PartialEq, Eq)]
3814pub struct EpistemicSplitPlan {
3815    /// Components to solve independently.
3816    pub components: Vec<EpistemicDependencyComponent>,
3817}
3818
3819impl EpistemicSplitPlan {
3820    /// Return the original rule order recovered from all components.
3821    pub fn recomposed_rule_indices(&self) -> Vec<usize> {
3822        let mut indices: Vec<usize> = self
3823            .components
3824            .iter()
3825            .flat_map(|component| component.rule_indices.iter().copied())
3826            .collect();
3827        indices.sort_unstable();
3828        indices
3829    }
3830}
3831
3832/// One split component lowered through the production epistemic GPU plan path.
3833#[derive(Debug, Clone)]
3834pub struct EpistemicSplitExecutableComponent {
3835    /// Source dependency component covered by this executable subplan.
3836    pub component: EpistemicDependencyComponent,
3837    /// GPU contract plus reduced runtime plan for this component.
3838    pub executable: EpistemicExecutablePlan,
3839}
3840
3841/// Executable split plan whose components reuse the normal epistemic GPU lowering.
3842#[derive(Debug, Clone)]
3843pub struct EpistemicSplitExecutablePlan {
3844    /// Original bounded split plan.
3845    pub split_plan: EpistemicSplitPlan,
3846    /// Epistemic components compiled into GPU executable subplans.
3847    pub components: Vec<EpistemicSplitExecutableComponent>,
3848}
3849
3850impl EpistemicSplitExecutablePlan {
3851    /// Return the source rule indices actually recomposed by GPU split execution.
3852    ///
3853    /// This reflects the rules the *executable* plan runs: epistemic-bearing
3854    /// components only. Pure-ordinary independent components carry no epistemic
3855    /// output buffer and are not part of the epistemic execution surface, so
3856    /// they are intentionally excluded here. The full dependency-graph view
3857    /// (including non-executed ordinary components) lives on
3858    /// [`EpistemicSplitPlan::recomposed_rule_indices`]; the two coincide exactly
3859    /// when every component is epistemic-bearing.
3860    pub fn recomposed_rule_indices(&self) -> Vec<usize> {
3861        let mut indices: Vec<usize> = self
3862            .components
3863            .iter()
3864            .flat_map(|component| component.component.rule_indices.iter().copied())
3865            .collect();
3866        indices.sort_unstable();
3867        indices
3868    }
3869
3870    /// Return the full dependency-graph recomposition view, including
3871    /// independent non-epistemic components that the executable plan does not run.
3872    pub fn planned_recomposed_rule_indices(&self) -> Vec<usize> {
3873        self.split_plan.recomposed_rule_indices()
3874    }
3875
3876    /// Return executable components ordered by the first source rule they cover.
3877    pub fn recomposed_components(&self) -> Vec<&EpistemicSplitExecutableComponent> {
3878        let mut components: Vec<_> = self.components.iter().collect();
3879        components.sort_by_key(|component| {
3880            component
3881                .component
3882                .rule_indices
3883                .iter()
3884                .copied()
3885                .min()
3886                .unwrap_or(usize::MAX)
3887        });
3888        components
3889    }
3890}
3891
3892/// Evaluate a single parsed epistemic literal against a bounded interpretation.
3893pub fn evaluate_epistemic_literal(
3894    mode: EpistemicMode,
3895    lit: &EpistemicLiteral,
3896    interpretation: &EpistemicInterpretation,
3897) -> TruthValue {
3898    let value = match lit.op {
3899        EpistemicOp::Know => interpretation.contains_known(&lit.atom),
3900        EpistemicOp::Possible => match mode {
3901            EpistemicMode::G91 => {
3902                interpretation.contains_known(&lit.atom)
3903                    || interpretation.contains_possible(&lit.atom)
3904            }
3905            EpistemicMode::Faeel => interpretation.contains_known(&lit.atom),
3906        },
3907    };
3908
3909    TruthValue::from_bool(if lit.negated { !value } else { value })
3910}
3911
3912/// Evaluate all epistemic literals in a program under bounded FAEEL fixture semantics.
3913pub fn evaluate_faeel_candidate(
3914    program: &Program,
3915    interpretation: &EpistemicInterpretation,
3916) -> Result<FaeelCandidateResult> {
3917    evaluate_epistemic_candidate(program, interpretation, EpistemicMode::Faeel)
3918}
3919
3920/// Evaluate all epistemic literals in a program under a bounded fixture semantics mode.
3921pub fn evaluate_epistemic_candidate(
3922    program: &Program,
3923    interpretation: &EpistemicInterpretation,
3924    mode: EpistemicMode,
3925) -> Result<FaeelCandidateResult> {
3926    let prepared = prepare_root_authored_constraint_identity(program)?;
3927    evaluate_prepared_epistemic_candidate(&prepared, interpretation, mode)
3928}
3929
3930fn evaluate_prepared_epistemic_candidate(
3931    program: &Program,
3932    interpretation: &EpistemicInterpretation,
3933    mode: EpistemicMode,
3934) -> Result<FaeelCandidateResult> {
3935    reject_gpt_epistemic_constraints(program)?;
3936    if let Some((predicate, arity)) = interpretation.first_contradiction() {
3937        return Ok(FaeelCandidateResult::NoModel(
3938            FaeelNoModelReason::Contradiction { predicate, arity },
3939        ));
3940    }
3941
3942    for rule in &program.rules {
3943        for body_lit in &rule.body {
3944            let BodyLiteral::Epistemic(lit) = body_lit else {
3945                continue;
3946            };
3947            if interpretation.contains_known(&lit.atom)
3948                && interpretation.contains_rejected(&lit.atom)
3949            {
3950                return Ok(FaeelCandidateResult::NoModel(
3951                    FaeelNoModelReason::Contradiction {
3952                        predicate: lit.atom.predicate.clone(),
3953                        arity: lit.atom.arity(),
3954                    },
3955                ));
3956            }
3957            if mode == EpistemicMode::Faeel
3958                && lit.op == EpistemicOp::Possible
3959                && interpretation.contains_possible(&lit.atom)
3960                && !interpretation.contains_known(&lit.atom)
3961            {
3962                return Ok(FaeelCandidateResult::NoModel(
3963                    FaeelNoModelReason::UnfoundedPossible {
3964                        predicate: lit.atom.predicate.clone(),
3965                        arity: lit.atom.arity(),
3966                    },
3967                ));
3968            }
3969            if evaluate_epistemic_literal(mode, lit, interpretation) == TruthValue::False {
3970                return Ok(FaeelCandidateResult::NoModel(
3971                    FaeelNoModelReason::UnsatisfiedLiteral {
3972                        predicate: lit.atom.predicate.clone(),
3973                        arity: lit.atom.arity(),
3974                    },
3975                ));
3976            }
3977        }
3978    }
3979
3980    Ok(FaeelCandidateResult::Model)
3981}
3982
3983/// Run bounded Generate-Propagate-Test execution over explicit candidates.
3984pub fn run_generate_propagate_test(
3985    program: &Program,
3986    candidates: Vec<EpistemicInterpretation>,
3987    config: GeneratePropagateTestConfig,
3988) -> Result<GeneratePropagateTestOutcome> {
3989    run_generate_propagate_test_with_mode(
3990        program,
3991        candidates,
3992        config,
3993        program.directives.epistemic_mode_or_default(),
3994    )
3995}
3996
3997/// Run bounded Generate-Propagate-Test execution over explicit candidates and semantics mode.
3998pub fn run_generate_propagate_test_with_mode(
3999    program: &Program,
4000    candidates: Vec<EpistemicInterpretation>,
4001    config: GeneratePropagateTestConfig,
4002    mode: EpistemicMode,
4003) -> Result<GeneratePropagateTestOutcome> {
4004    let prepared = prepare_root_authored_constraint_identity(program)?;
4005    reject_gpt_epistemic_constraints(&prepared)?;
4006    if candidates.len() > config.max_candidates {
4007        return Err(xlog_core::XlogError::CapacityExceeded {
4008            context: "epistemic GPT candidate guard".to_string(),
4009            required: candidates.len() as u64,
4010            limit: config.max_candidates as u64,
4011            unit: "candidates".to_string(),
4012        });
4013    }
4014
4015    let generated = candidates.len();
4016    let guesses = candidates
4017        .iter()
4018        .map(EpistemicInterpretation::epistemic_guess_count)
4019        .sum();
4020    let mut propagated_candidates = Vec::new();
4021    let mut rejection_reasons = Vec::new();
4022    let mut rejected_candidate_indices = Vec::new();
4023    for (idx, candidate) in candidates.into_iter().enumerate() {
4024        if let Some((predicate, arity)) = candidate.first_contradiction() {
4025            rejection_reasons.push(FaeelNoModelReason::Contradiction { predicate, arity });
4026            rejected_candidate_indices.push(idx);
4027        } else {
4028            propagated_candidates.push((idx, candidate));
4029        }
4030    }
4031
4032    let mut trace = GeneratePropagateTestTrace {
4033        generated,
4034        guesses,
4035        propagated: propagated_candidates.len(),
4036        pruned: generated.saturating_sub(propagated_candidates.len()),
4037        reduced_program_models: propagated_candidates.len(),
4038        rejection_reasons,
4039        ..GeneratePropagateTestTrace::default()
4040    };
4041    let mut accepted_candidate_indices = Vec::new();
4042
4043    for (idx, candidate) in &propagated_candidates {
4044        trace.tested += 1;
4045        match evaluate_prepared_epistemic_candidate(&prepared, candidate, mode)? {
4046            FaeelCandidateResult::Model => {
4047                trace.accepted += 1;
4048                trace.accepted_world_views += 1;
4049                accepted_candidate_indices.push(*idx);
4050            }
4051            FaeelCandidateResult::NoModel(reason) => {
4052                trace.rejected += 1;
4053                trace.rejection_reasons.push(reason);
4054                rejected_candidate_indices.push(*idx);
4055            }
4056        }
4057    }
4058
4059    Ok(GeneratePropagateTestOutcome {
4060        trace,
4061        accepted_candidate_indices,
4062        rejected_candidate_indices,
4063    })
4064}
4065
4066/// Build a deterministic dependency graph for bounded epistemic splitting.
4067pub fn build_epistemic_dependency_graph(program: &Program) -> Result<EpistemicDependencyGraph> {
4068    if program.rules.is_empty() {
4069        return Ok(EpistemicDependencyGraph { components: vec![] });
4070    }
4071
4072    let mut parents: Vec<usize> = (0..program.rules.len()).collect();
4073    let mut rule_predicates = Vec::with_capacity(program.rules.len());
4074    let mut head_owner: BTreeMap<String, usize> = BTreeMap::new();
4075    // Each merge records (one source rule index touched by the merge, reason).
4076    // After roots collapse, reasons are attributed to the surviving root so the
4077    // emitted component carries an explainable account of why it was coalesced.
4078    let mut merge_log: Vec<(usize, EpistemicComponentMergeReason)> = Vec::new();
4079
4080    for (idx, rule) in program.rules.iter().enumerate() {
4081        if rule.body.is_empty() {
4082            continue;
4083        }
4084        if let Some(owner) = head_owner.get(&rule.head.predicate).copied() {
4085            union_components(&mut parents, owner, idx);
4086            merge_log.push((
4087                idx,
4088                EpistemicComponentMergeReason::SharedHeadPredicate {
4089                    predicate: rule.head.predicate.clone(),
4090                },
4091            ));
4092        } else {
4093            head_owner.insert(rule.head.predicate.clone(), idx);
4094        }
4095    }
4096
4097    let mut modal_owner: BTreeMap<EpistemicAtomKey, usize> = BTreeMap::new();
4098    for (idx, rule) in program.rules.iter().enumerate() {
4099        let mut predicates = BTreeSet::new();
4100        predicates.insert(rule.head.predicate.clone());
4101        for lit in &rule.body {
4102            if let BodyLiteral::Epistemic(lit) = lit {
4103                let key =
4104                    EpistemicAtomKey::from_arity(lit.atom.predicate.clone(), lit.atom.arity());
4105                if let Some(owner) = modal_owner.get(&key).copied() {
4106                    union_components(&mut parents, owner, idx);
4107                    merge_log.push((
4108                        idx,
4109                        EpistemicComponentMergeReason::SharedModalPredicate {
4110                            predicate: format!("{}/{}", lit.atom.predicate, lit.atom.arity()),
4111                        },
4112                    ));
4113                } else {
4114                    modal_owner.insert(key, idx);
4115                }
4116            }
4117            if let Some(atom) = lit.atom() {
4118                if let Some(owner) = head_owner.get(&atom.predicate).copied() {
4119                    if owner != idx {
4120                        union_components(&mut parents, owner, idx);
4121                        merge_log.push((
4122                            idx,
4123                            EpistemicComponentMergeReason::DerivedPredicate {
4124                                predicate: atom.predicate.clone(),
4125                            },
4126                        ));
4127                    }
4128                }
4129                predicates.insert(atom.predicate.clone());
4130            }
4131        }
4132
4133        rule_predicates.push(predicates);
4134    }
4135
4136    let mut constraint_predicates = Vec::with_capacity(program.constraints.len());
4137    for constraint in &program.constraints {
4138        let predicates = constraint_predicate_set(constraint);
4139        let mut owners = predicates
4140            .iter()
4141            .filter_map(|predicate| head_owner.get(predicate).copied());
4142        if let Some(first_owner) = owners.next() {
4143            let mut coalesced_any = false;
4144            for owner in owners {
4145                if find_component(&mut parents, first_owner) != find_component(&mut parents, owner)
4146                {
4147                    coalesced_any = true;
4148                }
4149                union_components(&mut parents, first_owner, owner);
4150            }
4151            if coalesced_any {
4152                let constraint_heads: Vec<String> = predicates
4153                    .iter()
4154                    .filter(|predicate| head_owner.contains_key(*predicate))
4155                    .cloned()
4156                    .collect();
4157                merge_log.push((
4158                    first_owner,
4159                    EpistemicComponentMergeReason::Constraint {
4160                        predicates: constraint_heads,
4161                    },
4162                ));
4163            }
4164        }
4165        constraint_predicates.push(predicates);
4166    }
4167
4168    let mut grouped: BTreeMap<usize, (BTreeSet<String>, Vec<usize>)> = BTreeMap::new();
4169    for (idx, predicates) in rule_predicates.into_iter().enumerate() {
4170        let root = find_component(&mut parents, idx);
4171        let entry = grouped
4172            .entry(root)
4173            .or_insert_with(|| (BTreeSet::new(), vec![]));
4174        entry.0.extend(predicates);
4175        entry.1.push(idx);
4176    }
4177    for predicates in constraint_predicates {
4178        let Some(root) = predicates
4179            .iter()
4180            .filter_map(|predicate| head_owner.get(predicate).copied())
4181            .map(|idx| find_component(&mut parents, idx))
4182            .next()
4183        else {
4184            continue;
4185        };
4186        grouped
4187            .entry(root)
4188            .or_insert_with(|| (BTreeSet::new(), vec![]))
4189            .0
4190            .extend(predicates);
4191    }
4192
4193    // Attribute every recorded merge reason to its surviving component root.
4194    let mut reasons_by_root: BTreeMap<usize, BTreeSet<EpistemicComponentMergeReason>> =
4195        BTreeMap::new();
4196    for (touched_idx, reason) in merge_log {
4197        let root = find_component(&mut parents, touched_idx);
4198        reasons_by_root.entry(root).or_default().insert(reason);
4199    }
4200
4201    let mut components: Vec<EpistemicDependencyComponent> = grouped
4202        .into_iter()
4203        .map(|(root, (predicates, mut rule_indices))| {
4204            rule_indices.sort_unstable();
4205            let merge_reasons = reasons_by_root
4206                .remove(&root)
4207                .map(|reasons| reasons.into_iter().collect())
4208                .unwrap_or_default();
4209            EpistemicDependencyComponent {
4210                predicates: predicates.into_iter().collect(),
4211                rule_indices,
4212                merge_reasons,
4213            }
4214        })
4215        .collect();
4216    components.sort_by(|a, b| a.predicates.cmp(&b.predicates));
4217    Ok(EpistemicDependencyGraph { components })
4218}
4219
4220fn constraint_predicate_set(constraint: &Constraint) -> BTreeSet<String> {
4221    constraint
4222        .body
4223        .iter()
4224        .filter_map(|lit| lit.atom().map(|atom| atom.predicate.clone()))
4225        .collect()
4226}
4227
4228fn find_component(parents: &mut [usize], idx: usize) -> usize {
4229    if parents[idx] != idx {
4230        let root = find_component(parents, parents[idx]);
4231        parents[idx] = root;
4232    }
4233    parents[idx]
4234}
4235
4236fn union_components(parents: &mut [usize], left: usize, right: usize) {
4237    let left_root = find_component(parents, left);
4238    let right_root = find_component(parents, right);
4239    if left_root != right_root {
4240        parents[right_root] = left_root;
4241    }
4242}
4243
4244/// Split an epistemic program into independently solvable bounded components.
4245/// One stratum of a stratified epistemic program: a self-contained sub-program
4246/// whose epistemic heads gate only over EDB/invariant relations OR over the
4247/// materialized (now-base) outputs of strictly-lower strata.
4248#[derive(Debug, Clone)]
4249pub struct EpistemicStratum {
4250    /// The epistemic output head predicate(s) this stratum materializes.
4251    pub head_predicates: Vec<String>,
4252    /// Source-rule indices owned by this stratum.
4253    pub rule_indices: Vec<usize>,
4254    /// The self-contained sub-program for this stratum (its own defining rules
4255    /// plus the facts/EDB it needs). Lower-stratum heads are NOT redefined here;
4256    /// at execution they are present in the store as materialized base relations.
4257    pub program: Program,
4258}
4259
4260/// A stratified epistemic execution plan: an ordered sequence of strata.
4261///
4262/// Stratum `i`'s epistemic heads are materialized (gated) into the relation store
4263/// BEFORE stratum `i+1` runs, so a higher stratum's `know`/`possible` over a
4264/// lower stratum's head reads the GATED extension through the EXISTING
4265/// membership filter (no resolve-into-body, no double-gating).
4266#[derive(Debug, Clone)]
4267pub struct EpistemicStratifiedPlan {
4268    /// Strata in execution (topological) order.
4269    pub strata: Vec<EpistemicStratum>,
4270    /// One prepared ordinary closure and constraint stage executed after every
4271    /// stratum has materialized its gated heads.
4272    pub ordinary_post_program: Program,
4273}
4274
4275/// Predicates whose epistemic extension is DETERMINED once lower strata are fixed.
4276///
4277/// A predicate is *epistemically determined* when every defining rule uses only
4278/// (a) positive `know`/`possible` modals and ordinary positive/negated literals,
4279/// (b) all ranging over predicates that are themselves invariant (EDB/lower
4280/// non-epistemic stratum) OR already epistemically determined, and (c) the
4281/// dependency is acyclic through BOTH modal and ordinary edges. Such a head's
4282/// materialized (gated) extension IS its truth, so it can be materialized into the
4283/// store as a base relation and a higher stratum can gate against it.
4284///
4285/// This is a STANDALONE analysis: it never feeds
4286/// [`reduce_case_a_epistemic_program_to_ordinary`] / `is_invariant`, so it cannot
4287/// trigger the resolve-into-body double-gating that the single-pass GPU filter
4288/// already performs.
4289struct EpistemicallyDeterminedPredicates {
4290    determined: BTreeSet<String>,
4291}
4292
4293impl EpistemicallyDeterminedPredicates {
4294    fn analyze(program: &Program) -> Self {
4295        let invariant = InvariantRelations::analyze(program);
4296
4297        // Heads defined by at least one rule.
4298        let mut derived_heads: BTreeSet<&str> = BTreeSet::new();
4299        for rule in &program.rules {
4300            if !rule.body.is_empty() {
4301                derived_heads.insert(rule.head.predicate.as_str());
4302            }
4303        }
4304
4305        // Least-fixpoint closure over ALL derived heads (epistemic AND ordinary): a
4306        // predicate becomes determined when EVERY rule defining it ranges (modal +
4307        // ordinary) only over invariant or already-determined predicates, with no
4308        // self-reference (acyclic).
4309        //
4310        // An ORDINARY head is determined transitively when every defining rule ranges
4311        // only over determined/invariant relations (e.g. `r :- a` with `a` a
4312        // determined epistemic head). Such an `r` is determined-in-principle: its
4313        // extension is fixed once the determined heads it derives from are fixed, so a
4314        // higher modal `know r`/`possible r` can stratify against the materialized
4315        // base `r` via the existing membership filter. The acyclicity guard in
4316        // `head_is_determined` (self-reference returns false) plus the fixpoint's
4317        // monotonicity keep every recursive predicate OUT of `determined`, so a
4318        // circular `know reach` in a recursive SCC is never determined
4319        // and stays fail-closed.
4320        let mut determined: BTreeSet<String> = BTreeSet::new();
4321        let mut changed = true;
4322        while changed {
4323            changed = false;
4324            for head in &derived_heads {
4325                if determined.contains(*head) {
4326                    continue;
4327                }
4328                if Self::head_is_determined(program, head, &invariant, &derived_heads, &determined)
4329                {
4330                    determined.insert((*head).to_string());
4331                    changed = true;
4332                }
4333            }
4334        }
4335
4336        Self { determined }
4337    }
4338
4339    /// Whether `head`'s every defining rule ranges only over invariant or
4340    /// already-determined predicates (acyclic — no reference to `head` itself).
4341    fn head_is_determined(
4342        program: &Program,
4343        head: &str,
4344        invariant: &InvariantRelations,
4345        derived_heads: &BTreeSet<&str>,
4346        determined: &BTreeSet<String>,
4347    ) -> bool {
4348        let mut defined = false;
4349        for rule in &program.rules {
4350            if rule.head.predicate != head || rule.body.is_empty() {
4351                continue;
4352            }
4353            defined = true;
4354            for lit in &rule.body {
4355                let referenced = match lit {
4356                    BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
4357                        atom.predicate.as_str()
4358                    }
4359                    BodyLiteral::Epistemic(modal) => modal.atom.predicate.as_str(),
4360                    BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {
4361                        continue
4362                    }
4363                };
4364                if referenced == head {
4365                    // Self-reference: not acyclically determined (recursion /
4366                    // circular modality). Hand back to the recursive/FAEEL paths.
4367                    return false;
4368                }
4369                let ok = invariant.is_invariant(referenced)
4370                    || determined.contains(referenced)
4371                    // A pure-EDB predicate not seen by `derived_heads` is invariant.
4372                    || !derived_heads.contains(referenced);
4373                if !ok {
4374                    return false;
4375                }
4376            }
4377        }
4378        defined
4379    }
4380
4381    fn contains(&self, predicate: &str) -> bool {
4382        self.determined.contains(predicate)
4383    }
4384}
4385
4386/// Plan a STRATIFIED epistemic execution when the program contains a modal literal
4387/// over an epistemic-derived head that is itself epistemically DETERMINED.
4388///
4389/// This intercepts exactly the chained/nested-epistemic coupling that the joint
4390/// single-enumeration path fails closed on (`b :- know a` where `a :- know p`, `p`
4391/// invariant). It partitions the program's epistemic heads into strata by modal
4392/// dependency, where a head whose modal ranges over a lower DETERMINED head sits in
4393/// a strictly-higher stratum. Each stratum is a self-contained sub-program compiled
4394/// through the EXISTING single/joint epistemic path; at runtime the executor
4395/// materializes each stratum's GATED head into the store before the next stratum
4396/// runs, so the higher stratum gates against the materialized (now-base) relation
4397/// via the existing membership filter — never via resolve-into-body.
4398///
4399/// Returns:
4400/// - `Ok(Some(plan))` when the program genuinely needs (and admits) stratification:
4401///   at least one modal literal ranges over an epistemically-determined derived
4402///   head, and a sound stratification exists.
4403/// - `Ok(None)` when no modal ranges over a determined derived head (the existing
4404///   joint/split/single paths own the program — for example, a shared modal whose
4405///   target is extensional data rather than a determined derived head), OR
4406///   when the nested target is NOT determined (circular modality / recursion /
4407///   unfounded self-support is handed back to the recursive + FAEEL/G91 guards,
4408///   which keep ownership and fail closed there).
4409pub fn try_plan_stratified_epistemic_program(
4410    program: &Program,
4411) -> Result<Option<EpistemicStratifiedPlan>> {
4412    let prepared = prepare_root_authored_constraint_identity(program)?;
4413    let program = &prepared;
4414    let determined = EpistemicallyDeterminedPredicates::analyze(program);
4415
4416    // A stratification is needed only when some modal literal ranges over a
4417    // DETERMINED epistemic-derived head. (A modal over a base/EDB predicate is the
4418    // ordinary single/joint path and must NOT be intercepted.)
4419    let mut needs_stratification = false;
4420    for rule in &program.rules {
4421        for lit in &rule.body {
4422            if let BodyLiteral::Epistemic(modal) = lit {
4423                if determined.contains(modal.atom.predicate.as_str())
4424                    && modal.atom.predicate != rule.head.predicate
4425                {
4426                    needs_stratification = true;
4427                }
4428            }
4429        }
4430    }
4431    if !needs_stratification {
4432        return Ok(None);
4433    }
4434    let removed_rules = faeel_unfounded_exact_tuple_self_support_rule_indices(program)
4435        .into_iter()
4436        .collect::<BTreeSet<_>>();
4437    validate_epistemic_derived_relation_identity(program, &removed_rules)?;
4438
4439    // Assign each epistemic-derived head a stratum level = longest modal-dependency
4440    // chain to a determined head it gates over. Heads not determined cannot be
4441    // stratified soundly here; if any modal ranges over a non-determined derived
4442    // epistemic head, hand back to the joint path's fail-closed diagnostic.
4443    let stratum_level = assign_epistemic_strata(program, &determined)?;
4444    let Some(stratum_level) = stratum_level else {
4445        return Ok(None);
4446    };
4447
4448    // Group epistemic-bearing rules by their head's stratum level.
4449    let mut levels: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
4450    for (idx, rule) in program.rules.iter().enumerate() {
4451        let has_epistemic = rule
4452            .body
4453            .iter()
4454            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
4455        if !has_epistemic {
4456            continue;
4457        }
4458        let Some(level) = stratum_level.get(rule.head.predicate.as_str()) else {
4459            // An epistemic head with no assigned level means the analysis could not
4460            // place it soundly; hand back.
4461            return Ok(None);
4462        };
4463        levels.entry(*level).or_default().push(idx);
4464    }
4465
4466    if levels.len() < 2 {
4467        // Only one stratum: there is no lower stratum to materialize, so this is
4468        // not a genuine stratification (the existing paths own it).
4469        return Ok(None);
4470    }
4471
4472    let mut strata = Vec::with_capacity(levels.len());
4473    for (_level, rule_indices) in levels {
4474        let head_predicates: Vec<String> = rule_indices
4475            .iter()
4476            .filter_map(|idx| program.rules.get(*idx))
4477            .map(|rule| rule.head.predicate.clone())
4478            .collect::<BTreeSet<_>>()
4479            .into_iter()
4480            .collect();
4481        let stratum_program =
4482            build_stratum_subprogram(program, &rule_indices, &head_predicates, &stratum_level)?;
4483        strata.push(EpistemicStratum {
4484            head_predicates,
4485            rule_indices,
4486            program: stratum_program,
4487        });
4488    }
4489
4490    // Validate each stratum according to the reducer that will execute it. An
4491    // admissible recursive stratum resolves modal literals into ordinary rule bodies
4492    // and never appends hidden head columns; non-recursive strata use the single-pass
4493    // materializer and therefore require its union/shape checks.
4494    for stratum in &strata {
4495        if try_reduce_case_a_recursive_epistemic_program(&stratum.program)?.is_none() {
4496            validate_epistemic_relation_shapes(&stratum.program, &BTreeSet::new())?;
4497        }
4498    }
4499
4500    let ordinary_post_program = build_post_stratification_ordinary_program(program);
4501    ordinary_post_program.validate_prepared_authored_constraint_identity()?;
4502
4503    Ok(Some(EpistemicStratifiedPlan {
4504        strata,
4505        ordinary_post_program,
4506    }))
4507}
4508
4509/// Build the single ordinary epilogue for a stratified epistemic execution.
4510///
4511/// Modal rules belong to their ordered strata. Every non-modal rule is replayed once
4512/// after all gated heads are materialized so deferred transitive closure (for example
4513/// `c :- b` where `b` is a top-stratum head) reaches its final extension before the
4514/// authored ordinary constraints are evaluated. Queries stay attached so the
4515/// high-level executor can surface relations derived only by this final stage.
4516fn build_post_stratification_ordinary_program(program: &Program) -> Program {
4517    let mut post = program.clone();
4518    post.rules.retain(|rule| {
4519        rule.body
4520            .iter()
4521            .all(|literal| !matches!(literal, BodyLiteral::Epistemic(_)))
4522    });
4523    post.constraints.retain(|constraint| {
4524        constraint
4525            .body
4526            .iter()
4527            .all(|literal| !matches!(literal, BodyLiteral::Epistemic(_)))
4528    });
4529    post
4530}
4531
4532/// Build the rule rewrites used only for plan-wide schema inference in a
4533/// stratified program.
4534///
4535/// Each recursive stratum must contribute schemas from the same recursive epistemic
4536/// reducer selected for its executable plan. Applying the generic single-pass reducer
4537/// to those rules would append hidden tuple-key columns that their actual recursive
4538/// plan never produces.
4539fn stratified_schema_reduction_overrides(
4540    program: &Program,
4541) -> Result<BTreeMap<usize, crate::ast::Rule>> {
4542    let Some(plan) = try_plan_stratified_epistemic_program(program)? else {
4543        return Ok(BTreeMap::new());
4544    };
4545
4546    let mut overrides = BTreeMap::new();
4547    for stratum in plan.strata {
4548        if try_reduce_case_a_recursive_epistemic_program(&stratum.program)?.is_none() {
4549            continue;
4550        }
4551        for rule_index in stratum.rule_indices {
4552            let mut rule = program.rules.get(rule_index).cloned().ok_or_else(|| {
4553                XlogError::Compilation(format!(
4554                    "stratified epistemic rule index {rule_index} is outside the source program"
4555                ))
4556            })?;
4557            resolve_recursive_epistemic_rule_modals(&mut rule);
4558            overrides.insert(rule_index, rule);
4559        }
4560    }
4561    Ok(overrides)
4562}
4563
4564/// Assign each epistemic-derived head an integer stratum level.
4565///
4566/// Level 0 heads gate only over invariant/EDB relations. A head whose modal ranges
4567/// over a determined head at level `k` is at level `>= k + 1`. Returns `Ok(None)`
4568/// if any modal ranges over a derived-epistemic head that is NOT determined (those
4569/// genuinely-undefined / fail-closed shapes are owned by the joint/recursive
4570/// guards, which already produce typed diagnostics).
4571fn assign_epistemic_strata(
4572    program: &Program,
4573    determined: &EpistemicallyDeterminedPredicates,
4574) -> Result<Option<BTreeMap<String, usize>>> {
4575    // Epistemic-derived heads.
4576    let mut epistemic_heads: BTreeSet<&str> = BTreeSet::new();
4577    for rule in &program.rules {
4578        if rule
4579            .body
4580            .iter()
4581            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
4582        {
4583            epistemic_heads.insert(rule.head.predicate.as_str());
4584        }
4585    }
4586
4587    // Modal-over-derived-epistemic-head edges: head -> set of derived-epistemic
4588    // predicates its modals range over.
4589    //
4590    // A modal can target either a determined EPISTEMIC head directly (`b :- know a`),
4591    // or an ORDINARY predicate transitively derived from determined epistemic heads
4592    // (`b :- know r` with `r :- a`, `a` epistemic-determined). For the ordinary case,
4593    // the modal's head must sit strictly ABOVE the epistemic head(s) in the ordinary
4594    // target's transitive determined support, so those epistemic heads are materialized
4595    // (gated) into the store first and the ordinary `r :- a` is then computed over the
4596    // materialized base (making `r` locally invariant). We therefore route an edge from
4597    // the modal's head to EACH epistemic determined head in the target's support.
4598    let mut modal_edges: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
4599    for rule in &program.rules {
4600        let head = rule.head.predicate.as_str();
4601        for lit in &rule.body {
4602            if let BodyLiteral::Epistemic(modal) = lit {
4603                let target = modal.atom.predicate.as_str();
4604                if epistemic_heads.contains(target) {
4605                    if !determined.contains(target) {
4606                        // Modal over a non-determined epistemic head: not soundly
4607                        // stratifiable here. Hand back to the joint/recursive guard.
4608                        return Ok(None);
4609                    }
4610                    modal_edges.entry(head).or_default().insert(target);
4611                } else if determined.contains(target) {
4612                    // Modal over an ORDINARY determined predicate: route edges to the
4613                    // epistemic determined heads in its transitive support so the
4614                    // modal's head sits above them.
4615                    let support =
4616                        epistemic_support_of_determined_ordinary(program, target, &epistemic_heads);
4617                    if support.is_empty() {
4618                        // No epistemic head in the support means the target is fully
4619                        // invariant (pure-ordinary over EDB) — that is the ordinary
4620                        // single/joint path, not a stratification. Hand back.
4621                        return Ok(None);
4622                    }
4623                    let entry = modal_edges.entry(head).or_default();
4624                    for support_head in support {
4625                        entry.insert(support_head);
4626                    }
4627                }
4628            }
4629        }
4630    }
4631
4632    // Longest-path level via memoized DFS over modal_edges (acyclicity guaranteed
4633    // by `EpistemicallyDeterminedPredicates`, which rejects self-reference).
4634    let mut level: BTreeMap<String, usize> = BTreeMap::new();
4635    fn visit<'a>(
4636        head: &'a str,
4637        modal_edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
4638        level: &mut BTreeMap<String, usize>,
4639        active: &mut BTreeSet<&'a str>,
4640    ) -> Result<usize> {
4641        if let Some(l) = level.get(head) {
4642            return Ok(*l);
4643        }
4644        if !active.insert(head) {
4645            // A cycle through modal edges should have been excluded upstream; be
4646            // defensive and refuse to stratify.
4647            return Err(recursive_epistemic_rejection(
4648                "stratified epistemic planning encountered a modal dependency cycle",
4649            ));
4650        }
4651        let mut l = 0;
4652        if let Some(targets) = modal_edges.get(head) {
4653            for target in targets {
4654                let tl = visit(target, modal_edges, level, active)?;
4655                l = l.max(tl + 1);
4656            }
4657        }
4658        active.remove(head);
4659        level.insert(head.to_string(), l);
4660        Ok(l)
4661    }
4662
4663    for head in &epistemic_heads {
4664        visit(head, &modal_edges, &mut level, &mut BTreeSet::new())?;
4665    }
4666
4667    Ok(Some(level))
4668}
4669
4670/// The epistemic determined heads in the transitive ordinary support of a determined
4671/// ORDINARY predicate.
4672///
4673/// For `r :- a` with `a` an epistemic-determined head, `support_of("r") = {"a"}`. The
4674/// search follows positive/negated ordinary body atoms (the ordinary derivation), and
4675/// collects any referenced predicate that is itself an epistemic head. Bounded by the
4676/// (acyclic) determined-closure, so a simple visited-set DFS terminates.
4677fn epistemic_support_of_determined_ordinary<'a>(
4678    program: &'a Program,
4679    predicate: &'a str,
4680    epistemic_heads: &BTreeSet<&'a str>,
4681) -> BTreeSet<&'a str> {
4682    let mut support: BTreeSet<&'a str> = BTreeSet::new();
4683    let mut seen: BTreeSet<&'a str> = BTreeSet::new();
4684    let mut stack: Vec<&'a str> = vec![predicate];
4685    while let Some(current) = stack.pop() {
4686        if !seen.insert(current) {
4687            continue;
4688        }
4689        for rule in &program.rules {
4690            if rule.head.predicate != current || rule.body.is_empty() {
4691                continue;
4692            }
4693            for lit in &rule.body {
4694                let referenced = match lit {
4695                    BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
4696                        atom.predicate.as_str()
4697                    }
4698                    // An epistemic literal in the support means `current` is itself an
4699                    // epistemic head; record it and do not descend through the modal.
4700                    BodyLiteral::Epistemic(_)
4701                    | BodyLiteral::Comparison(_)
4702                    | BodyLiteral::IsExpr(_)
4703                    | BodyLiteral::Univ(_) => continue,
4704                };
4705                if epistemic_heads.contains(referenced) {
4706                    support.insert(referenced);
4707                } else {
4708                    // Descend through ordinary derivations toward their epistemic roots.
4709                    stack.push(referenced);
4710                }
4711            }
4712        }
4713        // If `current` itself is an epistemic head, it is its own support root.
4714        if epistemic_heads.contains(current) && current != predicate {
4715            support.insert(current);
4716        }
4717    }
4718    support
4719}
4720
4721/// Build a self-contained sub-program for one stratum.
4722///
4723/// Includes this stratum's epistemic-defining rules plus every fact and every
4724/// ordinary (non-epistemic) supporting rule whose head is NOT a lower-stratum
4725/// epistemic head. Lower-stratum epistemic heads are intentionally OMITTED: at
4726/// execution they are present in the store as materialized base relations, and
4727/// including their (modal-stripped, ungated) defining rules would overwrite the
4728/// gated extension. Their `pred` declarations are retained so the reduced compiler
4729/// sees a schema for the materialized base relation.
4730fn build_stratum_subprogram(
4731    program: &Program,
4732    rule_indices: &[usize],
4733    head_predicates: &[String],
4734    stratum_level: &BTreeMap<String, usize>,
4735) -> Result<Program> {
4736    let this_level = head_predicates
4737        .iter()
4738        .filter_map(|h| stratum_level.get(h))
4739        .copied()
4740        .max()
4741        .unwrap_or(0);
4742
4743    // Lower-stratum epistemic heads: present as materialized base relations at
4744    // runtime; their defining rules must NOT appear in this sub-program.
4745    let lower_epistemic_heads: BTreeSet<&str> = stratum_level
4746        .iter()
4747        .filter(|(_, level)| **level < this_level)
4748        .map(|(head, _)| head.as_str())
4749        .collect();
4750
4751    // All epistemic-derived heads (used to compute an ordinary rule's epistemic
4752    // support for deferral of determined-ordinary supporting rules).
4753    let all_epistemic_heads: BTreeSet<&str> = program
4754        .rules
4755        .iter()
4756        .filter(|rule| {
4757            rule.body
4758                .iter()
4759                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
4760        })
4761        .map(|rule| rule.head.predicate.as_str())
4762        .collect();
4763
4764    let own_rule_indices: BTreeSet<usize> = rule_indices.iter().copied().collect();
4765
4766    let mut stratum = program.clone();
4767    stratum.rules = program
4768        .rules
4769        .iter()
4770        .enumerate()
4771        .filter_map(|(idx, rule)| {
4772            if own_rule_indices.contains(&idx) {
4773                return Some(rule.clone());
4774            }
4775            // Drop any rule that (re)defines a lower-stratum epistemic head.
4776            if lower_epistemic_heads.contains(rule.head.predicate.as_str()) {
4777                return None;
4778            }
4779            // Keep facts and ordinary supporting rules (EDB + non-epistemic
4780            // derivations the stratum's bodies may reference).
4781            let has_epistemic = rule
4782                .body
4783                .iter()
4784                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
4785            if has_epistemic && !own_rule_indices.contains(&idx) {
4786                // Another stratum's epistemic rule: exclude.
4787                return None;
4788            }
4789            // An ORDINARY supporting rule whose transitive epistemic support includes a
4790            // head NOT yet materialized (gated) at this level must NOT run here — it
4791            // would compute over the UNGATED candidate extension of that head and leak
4792            // the wrong tuples into the store (which the higher stratum then gates
4793            // against). Defer it to the lowest stratum where ALL its epistemic support
4794            // is already a materialized gated base relation. E.g. `r :- a` (a an
4795            // epistemic-determined head) is dropped from `a`'s own stratum (level 0) and
4796            // kept only in the strictly-higher stratum where `a` is materialized base,
4797            // so `r` is computed once from the gated `a`. Pure-ordinary rules over EDB
4798            // (empty epistemic support) are never deferred.
4799            let support = epistemic_support_of_determined_ordinary(
4800                program,
4801                rule.head.predicate.as_str(),
4802                &all_epistemic_heads,
4803            );
4804            if support
4805                .iter()
4806                .any(|h| stratum_level.get(*h).copied().unwrap_or(0) >= this_level)
4807            {
4808                return None;
4809            }
4810            Some(rule.clone())
4811        })
4812        .collect();
4813
4814    // Authored queries are compiled exactly once by the post-stratification stage.
4815    // Keeping compiler-local `__xlog_query_N` relations in modal strata would reuse
4816    // the same local names across strata and can collide when query projections have
4817    // different schemas.
4818    let head_set: BTreeSet<&str> = head_predicates.iter().map(String::as_str).collect();
4819    stratum.queries.clear();
4820
4821    // Ordinary constraints are global postconditions and belong only to the single
4822    // post-stratification ordinary stage. Modal constraints retain predicate-local
4823    // ownership because they participate in candidate/world-view semantics here.
4824    stratum.constraints = program
4825        .constraints
4826        .iter()
4827        .filter(|constraint| {
4828            let is_ordinary = constraint
4829                .body
4830                .iter()
4831                .all(|literal| !matches!(literal, BodyLiteral::Epistemic(_)));
4832            if is_ordinary {
4833                return false;
4834            }
4835            constraint_predicate_set(constraint)
4836                .iter()
4837                .all(|p| head_set.contains(p.as_str()) || !is_program_head(program, p))
4838        })
4839        .cloned()
4840        .collect();
4841
4842    Ok(stratum)
4843}
4844
4845fn is_program_head(program: &Program, predicate: &str) -> bool {
4846    program
4847        .rules
4848        .iter()
4849        .any(|rule| !rule.body.is_empty() && rule.head.predicate == predicate)
4850}
4851
4852/// Partition an epistemic program into independently-evaluable components.
4853///
4854/// Builds the epistemic dependency graph (coalescing rules that couple distinct
4855/// epistemic body predicates into one component) and returns an
4856/// [`EpistemicSplitPlan`] describing which output heads evaluate together versus
4857/// in isolation. This is the entry point for the safe-split / joint-solving and
4858/// stratified-execution routing decisions in the GPU driver.
4859pub fn split_epistemic_program(program: &Program) -> Result<EpistemicSplitPlan> {
4860    // rules that couple more than one distinct epistemic body predicate
4861    // are NOT rejected here. The dependency graph already unions every such rule
4862    // into a single component (each epistemic predicate occurrence routes through
4863    // `modal_owner` in `build_epistemic_dependency_graph`), and that component is
4864    // recompiled through the unsplit joint path
4865    // (`compile_epistemic_gpu_execution`), which enumerates the full candidate
4866    // lattice and validates the FULL modal conjunction jointly on device. Any
4867    // genuinely out-of-fragment coupling (unsafe variables, unsupported
4868    // tuple-key/nested-modal semantics) stays fail-closed via the downstream
4869    // joint-path guards (`build_eir` safety analysis,
4870    // `validate_tuple_membership_bindings`, `validate_solver_contract`) with their
4871    // own typed source-contextualized diagnostics, so no blanket coupling
4872    // rejection is needed at the split boundary.
4873    Ok(EpistemicSplitPlan {
4874        components: build_epistemic_dependency_graph(program)?.components,
4875    })
4876}
4877
4878/// Compile valid epistemic split components through the production GPU executable path.
4879pub fn compile_epistemic_gpu_split_execution(
4880    program: &Program,
4881) -> Result<EpistemicSplitExecutablePlan> {
4882    compile_epistemic_gpu_split_execution_with_stats_snapshot(program, None)
4883}
4884
4885/// Compile valid epistemic split components with an optional production stats snapshot.
4886///
4887/// Each component subprogram is lowered through
4888/// [`compile_epistemic_gpu_execution_with_stats_snapshot`], so split execution
4889/// reuses the same GPU contract, reduced compiler pipeline, WCOJ promotion, and
4890/// helper-splitting surfaces as unsplit epistemic execution.
4891pub fn compile_epistemic_gpu_split_execution_with_stats_snapshot(
4892    program: &Program,
4893    stats_snapshot: Option<&StatsSnapshot>,
4894) -> Result<EpistemicSplitExecutablePlan> {
4895    let mut prepared = program.clone();
4896    if prepared.authored_constraint_source_bound.is_some() {
4897        prepared.validate_prepared_authored_constraint_identity()?;
4898    } else {
4899        prepared.prepare_authored_constraint_identity_at_root()?;
4900    }
4901    let program = &prepared;
4902    validate_epistemic_relation_shapes(program, &BTreeSet::new())?;
4903    reject_epistemic_constraints(program)?;
4904    let split_plan = split_epistemic_program(program)?;
4905    let mut components = Vec::new();
4906
4907    for component in &split_plan.components {
4908        if !component_has_epistemic_rule(program, component) {
4909            continue;
4910        }
4911
4912        // Cross-component coupling carrying >1 epistemic output head is either
4913        // JOINT-SOLVED (a coalesced component whose modal literals all range over
4914        // base/invariant predicates -- a shared accepted world view materializes
4915        // every head) or fails closed with a precise typed diagnostic (a modal
4916        // literal ranges over an epistemic-derived head of the same component, so
4917        // the heads' world-view acceptance is genuinely interdependent and the
4918        // independent split would be unsound). A single epistemic head is always
4919        // the existing single-output joint path.
4920        let coupling = classify_cross_component_modal_coupling(program, component)?;
4921
4922        let component_program = split_component_program(program, component)?;
4923        let executable = compile_epistemic_gpu_execution_inner(
4924            &component_program,
4925            stats_snapshot,
4926            coupling.allows_multiple_output_heads(),
4927        )?;
4928        components.push(EpistemicSplitExecutableComponent {
4929            component: component.clone(),
4930            executable,
4931        });
4932    }
4933
4934    if components.is_empty() {
4935        return Err(XlogError::UnsupportedEpistemicConstruct {
4936            construct: "epistemic GPU split execution".to_string(),
4937            context: "requires at least one epistemic split component".to_string(),
4938        });
4939    }
4940
4941    Ok(EpistemicSplitExecutablePlan {
4942        split_plan,
4943        components,
4944    })
4945}
4946
4947fn component_has_epistemic_rule(
4948    program: &Program,
4949    component: &EpistemicDependencyComponent,
4950) -> bool {
4951    component
4952        .rule_indices
4953        .iter()
4954        .filter_map(|idx| program.rules.get(*idx))
4955        .any(|rule| {
4956            rule.body
4957                .iter()
4958                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
4959        })
4960}
4961
4962/// Distinct head predicates of the component's epistemic-bearing rules, sorted.
4963///
4964/// Each such head is a final epistemic output relation the joint single-pass GPU
4965/// path would have to materialize. The single-output-buffer contract
4966/// ([`require_single_epistemic_output_relation`]) admits exactly one, so a count
4967/// above one means the component is genuinely *coupled* across what local
4968/// analysis would otherwise split — its epistemic outputs cannot be solved
4969/// independently AND cannot be jointly materialized into one buffer.
4970fn component_epistemic_output_heads(
4971    program: &Program,
4972    component: &EpistemicDependencyComponent,
4973) -> Vec<String> {
4974    let mut heads: BTreeSet<String> = BTreeSet::new();
4975    for idx in &component.rule_indices {
4976        let Some(rule) = program.rules.get(*idx) else {
4977            continue;
4978        };
4979        let has_epistemic_body = rule
4980            .body
4981            .iter()
4982            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
4983        if has_epistemic_body {
4984            heads.insert(rule.head.predicate.clone());
4985        }
4986    }
4987    heads.into_iter().collect()
4988}
4989
4990/// Render a coalesced component's merge reasons into a stable, human-readable list
4991/// for the cross-component coupling diagnostic.
4992///
4993/// These reasons (`DerivedPredicate`, `SharedModalPredicate`, `SharedHeadPredicate`,
4994/// `Constraint`) are exactly *why* the dependency graph could not split the
4995/// component's epistemic outputs, so naming them tells the caller which structural
4996/// coupling forced the fail-closed.
4997fn format_component_merge_reasons(component: &EpistemicDependencyComponent) -> String {
4998    if component.merge_reasons.is_empty() {
4999        return "no recorded coalesce reason".to_string();
5000    }
5001    component
5002        .merge_reasons
5003        .iter()
5004        .map(|reason| match reason {
5005            EpistemicComponentMergeReason::SharedHeadPredicate { predicate } => {
5006                format!("SharedHeadPredicate({predicate})")
5007            }
5008            EpistemicComponentMergeReason::DerivedPredicate { predicate } => {
5009                format!("DerivedPredicate({predicate})")
5010            }
5011            EpistemicComponentMergeReason::SharedModalPredicate { predicate } => {
5012                format!("SharedModalPredicate({predicate})")
5013            }
5014            EpistemicComponentMergeReason::Constraint { predicates } => {
5015                format!("Constraint({})", predicates.join(", "))
5016            }
5017        })
5018        .collect::<Vec<_>>()
5019        .join(", ")
5020}
5021
5022/// Classification of a coalesced epistemic component's cross-component coupling.
5023#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5024enum CrossComponentCoupling {
5025    /// At most one epistemic output head, or a multi-head component whose modal
5026    /// literals all range over base/invariant predicates. The shared accepted
5027    /// world view materializes every head, so the component is JOINT-SOLVED.
5028    JointSolvable,
5029}
5030
5031impl CrossComponentCoupling {
5032    /// True when the component's GPU plan is permitted to carry more than one
5033    /// epistemic output head (joint multi-head materialization).
5034    fn allows_multiple_output_heads(self) -> bool {
5035        match self {
5036            CrossComponentCoupling::JointSolvable => true,
5037        }
5038    }
5039}
5040
5041/// Classify a coalesced component's cross-component modal coupling, JOINT-SOLVING
5042/// the canonical shared-base-modal case and failing closed (with a precise typed
5043/// diagnostic) on genuinely interdependent nested-epistemic coupling.
5044///
5045/// A coalesced component carrying more than one epistemic output head is either:
5046///
5047/// - **Joint-solvable** — every modal literal in the component ranges over a
5048///   predicate that is NOT an epistemic-derived head of the component (a
5049///   base/invariant relation or an ordinary-derived relation). The accepted
5050///   world-view set is then determined independently of which head is being
5051///   materialized, so one joint candidate enumeration + world-view validation
5052///   over the combined modal literals yields a single accepted world view, and
5053///   each head materialized against THAT world view equals its per-head
5054///   reduced-program evaluation. This is the canonical `SharedModalPredicate`
5055///   joint-solving target (`a(X):-know q(X). b(X):-possible q(X).` over base `q`).
5056///
5057/// - **Genuinely interdependent (fail closed)** — some modal literal ranges over
5058///   an EPISTEMIC-DERIVED head of the same component (`flagged():-know trusted()`
5059///   where `trusted` is itself `know`-derived). The modal truth of that predicate
5060///   depends on a DIFFERENT head's accepted world view, so the heads' acceptance
5061///   is mutually entangled (nested/stratified epistemic dependency). Solving it
5062///   would require stratified world-view nesting that the single joint enumeration
5063///   does not provide, so it stays FAIL-CLOSED with a typed diagnostic naming the
5064///   coupled heads, the modal predicate, and the merge reason -- never silently
5065///   mis-evaluated.
5066///
5067/// SAFE single-epistemic-head coupling (an ordinary body consuming an epistemic
5068/// head, `b():-a()` over `a():-know p()`) and EDB-only sharing are both
5069/// `JointSolvable` (one or zero coupled heads), so they stay accepted.
5070/// Compute the predicates whose extension depends, directly or transitively
5071/// through ordinary rules in the component, on an epistemic-derived head.
5072///
5073/// Seeded with the component's epistemic output heads (each is "tainted" because
5074/// its extension is gated by a modal literal), then closed under the rule
5075/// dependency relation: a head becomes tainted when ANY rule defining it (within
5076/// the component) references an already-tainted predicate in its body. A modal
5077/// literal over a tainted predicate is a nested/stratified epistemic dependency.
5078fn epistemic_tainted_predicates<'a>(
5079    program: &'a Program,
5080    component: &EpistemicDependencyComponent,
5081    epistemic_heads: &'a [String],
5082) -> BTreeSet<&'a str> {
5083    let mut tainted: BTreeSet<&str> = epistemic_heads.iter().map(String::as_str).collect();
5084    // Iterate the component's rules to a least fixpoint: a rule's head is tainted
5085    // if any body atom references a tainted predicate.
5086    let mut changed = true;
5087    while changed {
5088        changed = false;
5089        for idx in &component.rule_indices {
5090            let Some(rule) = program.rules.get(*idx) else {
5091                continue;
5092            };
5093            if tainted.contains(rule.head.predicate.as_str()) {
5094                continue;
5095            }
5096            // `BodyLiteral::atom()` covers relational AND epistemic literals
5097            // (the modal predicate), so this taints a head whether it depends on a
5098            // tainted predicate ordinarily or through a modal literal.
5099            let body_touches_tainted = rule.body.iter().any(|lit| {
5100                lit.atom()
5101                    .map(|atom| tainted.contains(atom.predicate.as_str()))
5102                    .unwrap_or(false)
5103            });
5104            if body_touches_tainted {
5105                tainted.insert(rule.head.predicate.as_str());
5106                changed = true;
5107            }
5108        }
5109    }
5110    tainted
5111}
5112
5113fn classify_cross_component_modal_coupling(
5114    program: &Program,
5115    component: &EpistemicDependencyComponent,
5116) -> Result<CrossComponentCoupling> {
5117    let epistemic_heads = component_epistemic_output_heads(program, component);
5118    if epistemic_heads.len() <= 1 {
5119        return Ok(CrossComponentCoupling::JointSolvable);
5120    }
5121
5122    // A modal literal ranging over a predicate whose extension DEPENDS (directly
5123    // OR TRANSITIVELY, through ordinary rules in this component) on an
5124    // epistemic-derived head is a nested/stratified epistemic dependency that the
5125    // single joint enumeration cannot solve soundly: that modal's truth would have
5126    // to be re-evaluated under EACH candidate world view chosen for the head it
5127    // depends on, which one shared world-view enumeration does not provide.
5128    //
5129    // "Epistemic-tainted" predicates = epistemic-derived heads, closed under the
5130    // ordinary rule dependency relation within the component (least fixpoint). A
5131    // modal over any tainted predicate fails closed. A modal over a purely
5132    // base/invariant or epistemic-INDEPENDENT predicate is joint-solvable.
5133    let tainted = epistemic_tainted_predicates(program, component, &epistemic_heads);
5134
5135    let mut nested_modal_predicates: BTreeSet<String> = BTreeSet::new();
5136    for idx in &component.rule_indices {
5137        let Some(rule) = program.rules.get(*idx) else {
5138            continue;
5139        };
5140        for lit in &rule.body {
5141            if let BodyLiteral::Epistemic(modal) = lit {
5142                if tainted.contains(modal.atom.predicate.as_str()) {
5143                    nested_modal_predicates.insert(format!(
5144                        "{}/{}",
5145                        modal.atom.predicate,
5146                        modal.atom.arity()
5147                    ));
5148                }
5149            }
5150        }
5151    }
5152
5153    if nested_modal_predicates.is_empty() {
5154        // Every modal literal ranges over a predicate that is independent of every
5155        // epistemic-derived head, so the accepted world view is determined solely
5156        // by base/invariant relations and the component is joint-solvable over one
5157        // shared accepted world view.
5158        return Ok(CrossComponentCoupling::JointSolvable);
5159    }
5160
5161    Err(XlogError::UnsupportedEpistemicConstruct {
5162        construct: "cross-component epistemic coupling".to_string(),
5163        context: format!(
5164            "epistemic output heads {:?} are coupled into a single dependency \
5165             component (reasons: {}) through nested modal literals over \
5166             epistemic-derived predicates {:?}; the modal truth of an \
5167             epistemic-derived head depends on another head's accepted world view, \
5168             so a single joint world-view enumeration would mis-evaluate the \
5169             nested modality and an independent split would be unsound, so this \
5170             fails closed",
5171            epistemic_heads,
5172            format_component_merge_reasons(component),
5173            nested_modal_predicates.into_iter().collect::<Vec<_>>(),
5174        ),
5175    })
5176}
5177
5178fn split_component_program(
5179    program: &Program,
5180    component: &EpistemicDependencyComponent,
5181) -> Result<Program> {
5182    let mut component_program = program.clone();
5183    let component_predicates: BTreeSet<&str> =
5184        component.predicates.iter().map(String::as_str).collect();
5185    let component_rule_indices: BTreeSet<usize> = component.rule_indices.iter().copied().collect();
5186    let head_predicates: BTreeSet<&str> = program
5187        .rules
5188        .iter()
5189        .map(|rule| rule.head.predicate.as_str())
5190        .collect();
5191    component_program.rules = program
5192        .rules
5193        .iter()
5194        .enumerate()
5195        .filter_map(|(idx, rule)| {
5196            (component_rule_indices.contains(&idx)
5197                || (rule.body.is_empty()
5198                    && component_predicates.contains(rule.head.predicate.as_str())))
5199            .then_some(rule.clone())
5200        })
5201        .collect();
5202    component_program.constraints = program
5203        .constraints
5204        .iter()
5205        .filter(|constraint| {
5206            let predicates = constraint_predicate_set(constraint);
5207            let has_component_owned_predicate = predicates
5208                .iter()
5209                .any(|predicate| head_predicates.contains(predicate.as_str()));
5210            !has_component_owned_predicate
5211                || predicates
5212                    .iter()
5213                    .all(|predicate| component_predicates.contains(predicate.as_str()))
5214        })
5215        .cloned()
5216        .collect();
5217    Ok(component_program)
5218}
5219
5220#[cfg(test)]
5221mod tests {
5222    use super::*;
5223    use crate::ast::{PredColumn, PredDecl, TypeRef};
5224    use crate::parse_program;
5225
5226    #[test]
5227    fn augmented_head_reconciliation_preserves_columns_only_declarations() {
5228        let symbol = TypeRef::Scalar(xlog_core::ScalarType::Symbol);
5229        let wide_integer = TypeRef::Scalar(xlog_core::ScalarType::I64);
5230        let mut program = Program::new();
5231        program.predicates = vec![
5232            PredDecl {
5233                name: "result".to_string(),
5234                types: Vec::new(),
5235                columns: vec![PredColumn {
5236                    name: Some("key".to_string()),
5237                    typ: symbol.clone(),
5238                }],
5239                is_private: false,
5240            },
5241            PredDecl {
5242                name: "source".to_string(),
5243                types: Vec::new(),
5244                columns: vec![
5245                    PredColumn {
5246                        name: Some("key".to_string()),
5247                        typ: symbol.clone(),
5248                    },
5249                    PredColumn {
5250                        name: Some("value".to_string()),
5251                        typ: wide_integer.clone(),
5252                    },
5253                ],
5254                is_private: false,
5255            },
5256        ];
5257        program.rules.push(crate::ast::Rule {
5258            head: Atom {
5259                predicate: "result".to_string(),
5260                terms: vec![
5261                    Term::Variable("Key".to_string()),
5262                    Term::Variable("Value".to_string()),
5263                ],
5264            },
5265            body: vec![BodyLiteral::Positive(Atom {
5266                predicate: "source".to_string(),
5267                terms: vec![
5268                    Term::Variable("Key".to_string()),
5269                    Term::Variable("Value".to_string()),
5270                ],
5271            })],
5272        });
5273        let resolved = BTreeMap::from([(0, 1)]);
5274
5275        let widened = reconcile_augmented_head_declarations(&mut program, &resolved)
5276            .expect("reconcile augmented declaration");
5277        let declaration = program
5278            .predicates
5279            .iter()
5280            .find(|declaration| declaration.name == "result")
5281            .unwrap();
5282
5283        assert_eq!(widened.get(&("result".to_string(), 1)), Some(&2));
5284        assert_eq!(declaration.arity(), 2);
5285        assert_eq!(
5286            declaration.types,
5287            vec![symbol.clone(), wide_integer.clone()]
5288        );
5289        assert_eq!(
5290            declaration
5291                .columns
5292                .iter()
5293                .map(|column| column.typ.clone())
5294                .collect::<Vec<_>>(),
5295            vec![symbol, wide_integer]
5296        );
5297    }
5298
5299    #[test]
5300    fn augmented_head_reconciliation_uses_body_declarations_by_name_and_arity() {
5301        let program = parse_program(
5302            r#"
5303            #pragma epistemic_mode = faeel
5304            pred node(symbol).
5305            pred source(symbol, i64).
5306            pred source(u32).
5307            pred result(symbol).
5308            node(key).
5309            source(key, 5000000000).
5310            source(1).
5311            result(X) :- node(X), know source(X, Y).
5312            "#,
5313        )
5314        .expect("parse same-name multi-arity schema fixture");
5315
5316        let reduced = reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
5317            .expect("same-name body declarations should reduce");
5318        let result = reduced
5319            .predicates
5320            .iter()
5321            .find(|declaration| declaration.name == "result")
5322            .expect("missing result declaration");
5323        let types = result
5324            .schema_columns()
5325            .into_iter()
5326            .map(|column| column.typ)
5327            .collect::<Vec<_>>();
5328
5329        assert_eq!(
5330            types,
5331            vec![
5332                TypeRef::Scalar(xlog_core::ScalarType::Symbol),
5333                TypeRef::Scalar(xlog_core::ScalarType::I64),
5334            ]
5335        );
5336        assert!(reduced
5337            .predicates
5338            .iter()
5339            .any(|declaration| declaration.name == "source/2"));
5340        assert!(reduced
5341            .predicates
5342            .iter()
5343            .any(|declaration| declaration.name == "source/1"));
5344        crate::compile::Compiler::new()
5345            .compile_program(&reduced)
5346            .expect("arity-qualified reduced program should compile");
5347        compile_epistemic_gpu_execution(&program)
5348            .expect("production epistemic compilation should use the exact source signature");
5349    }
5350
5351    #[test]
5352    fn augmented_head_reconciliation_uses_undeclared_fixed_point_schemas() {
5353        let sources = [
5354            r#"
5355                #pragma epistemic_mode = faeel
5356                pred node(symbol).
5357                pred result(symbol).
5358                node(key).
5359                edge(key, 5000000000).
5360                result(X) :- node(X), know edge(X, Y).
5361            "#,
5362            r#"
5363                #pragma epistemic_mode = faeel
5364                pred node(symbol).
5365                pred result(symbol).
5366                node(key).
5367                raw(key, 5000000000).
5368                edge(X, Y) :- raw(X, Y).
5369                result(X) :- node(X), know edge(X, Y).
5370            "#,
5371        ];
5372
5373        for source in sources {
5374            let program = parse_program(source).expect("parse inferred-schema fixture");
5375            let reduced = reduce_epistemic_program_to_ordinary(&program)
5376                .expect("inferred modal source should reduce");
5377            let result = reduced
5378                .predicates
5379                .iter()
5380                .find(|declaration| declaration.name == "result")
5381                .expect("missing result declaration");
5382            assert_eq!(
5383                result
5384                    .schema_columns()
5385                    .into_iter()
5386                    .map(|column| column.typ)
5387                    .collect::<Vec<_>>(),
5388                vec![
5389                    TypeRef::Scalar(xlog_core::ScalarType::Symbol),
5390                    TypeRef::Scalar(xlog_core::ScalarType::I64),
5391                ]
5392            );
5393            crate::compile::Compiler::new()
5394                .compile_program(&reduced)
5395                .expect("fixed-point inferred hidden-column type should compile");
5396        }
5397    }
5398
5399    #[test]
5400    fn augmented_head_reconciliation_uses_arithmetic_binding_type() {
5401        let program = parse_program(
5402            r#"
5403                #pragma epistemic_mode = faeel
5404                pred node(symbol).
5405                pred allowed(u64).
5406                pred result(symbol).
5407                node(key).
5408                allowed(1).
5409                result(X) :- node(X), Y is cast(1, u64), not know allowed(Y).
5410            "#,
5411        )
5412        .expect("parse arithmetic-binding fixture");
5413
5414        let reduced = reduce_epistemic_program_to_ordinary(&program)
5415            .expect("arithmetic-bound hidden column should reduce");
5416        let result = reduced
5417            .predicates
5418            .iter()
5419            .find(|declaration| declaration.name == "result")
5420            .expect("missing result declaration");
5421        assert_eq!(
5422            result
5423                .schema_columns()
5424                .into_iter()
5425                .map(|column| column.typ)
5426                .collect::<Vec<_>>(),
5427            vec![
5428                TypeRef::Scalar(xlog_core::ScalarType::Symbol),
5429                TypeRef::Scalar(xlog_core::ScalarType::U64),
5430            ]
5431        );
5432        crate::compile::Compiler::new()
5433            .compile_program(&reduced)
5434            .expect("arithmetic-bound hidden-column type should compile");
5435    }
5436
5437    #[test]
5438    fn augmented_head_reconciliation_widens_only_the_original_signature() {
5439        let mut reduced = parse_program(
5440            r#"
5441            pred edge(symbol, i64).
5442            pred triple(symbol, i64, u32).
5443            pred result(symbol, i64, u32).
5444            pred result(symbol).
5445            result(X, Y, Z) :- triple(X, Y, Z).
5446            result(X, Y) :- edge(X, Y).
5447            ?- result(A, B, C).
5448            ?- result(X).
5449            "#,
5450        )
5451        .expect("parse exact-signature reconciliation fixture");
5452
5453        let augmented =
5454            reconcile_augmented_head_declarations(&mut reduced, &BTreeMap::from([(1, 1)]))
5455                .expect("reconcile exact augmented signature");
5456        let declaration_arities = reduced
5457            .predicates
5458            .iter()
5459            .filter(|declaration| declaration.name == "result")
5460            .map(PredDecl::arity)
5461            .collect::<Vec<_>>();
5462        let rule_arities = reduced
5463            .rules
5464            .iter()
5465            .filter(|rule| rule.head.predicate == "result")
5466            .map(|rule| rule.head.arity())
5467            .collect::<Vec<_>>();
5468        let query_arities = reduced
5469            .queries
5470            .iter()
5471            .filter(|query| query.atom.predicate == "result")
5472            .map(|query| query.atom.arity())
5473            .collect::<Vec<_>>();
5474
5475        assert_eq!(augmented.get(&("result".to_string(), 1)), Some(&2));
5476        assert_eq!(declaration_arities, vec![3, 2]);
5477        assert_eq!(rule_arities, vec![3, 2]);
5478        assert_eq!(query_arities, vec![3, 1]);
5479    }
5480
5481    #[test]
5482    fn augmented_undeclared_head_removes_its_original_query() {
5483        let program = parse_program(
5484            r#"
5485            #pragma epistemic_mode = faeel
5486            node(key).
5487            edge(key, 5000000000).
5488            result(X) :- node(X), know edge(X, Y).
5489            ?- result(X).
5490            "#,
5491        )
5492        .expect("parse undeclared augmented-head fixture");
5493
5494        let reduced = reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
5495            .expect("undeclared augmented head should reduce");
5496
5497        assert!(reduced
5498            .rules
5499            .iter()
5500            .any(|rule| rule.head.predicate == "result" && rule.head.arity() == 2));
5501        assert!(!reduced
5502            .queries
5503            .iter()
5504            .any(|query| query.atom.predicate == "result" && query.atom.arity() == 1));
5505    }
5506
5507    #[test]
5508    fn divergent_augmented_head_arities_are_rejected_before_reduction() {
5509        let different_modal_widths = r#"
5510            #pragma epistemic_mode = faeel
5511            pred node(symbol).
5512            pred edge(symbol, i64).
5513            pred triple(symbol, i64, u32).
5514            pred result(symbol).
5515            node(key).
5516            edge(key, 5000000000).
5517            triple(key, 5000000000, 1).
5518            result(X) :- node(X), know edge(X, Y).
5519            result(X) :- node(X), know triple(X, Y, Z).
5520        "#;
5521        let ordinary_sibling = r#"
5522            #pragma epistemic_mode = faeel
5523            pred base(symbol).
5524            pred node(symbol).
5525            pred edge(symbol, i64).
5526            pred result(symbol).
5527            base(key).
5528            node(key).
5529            edge(key, 5000000000).
5530            result(X) :- base(X).
5531            result(X) :- node(X), know edge(X, Y).
5532        "#;
5533
5534        for source in [different_modal_widths, ordinary_sibling] {
5535            let program = parse_program(source).expect("parse divergent augmented-head fixture");
5536            let errors = [
5537                plan_epistemic_gpu_execution(&program)
5538                    .expect_err("GPU planning must reject divergent reduced arities"),
5539                reduce_epistemic_program_to_ordinary(&program)
5540                    .expect_err("execution reduction must reject divergent arities"),
5541                reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
5542                    .expect_err("schema reduction must reject divergent arities"),
5543            ];
5544
5545            for error in errors {
5546                let message = error.to_string();
5547                assert!(message.contains("epistemic augmented predicate schema"));
5548                assert!(message.contains("result/1"), "{message}");
5549                assert!(
5550                    message.contains("incompatible internal arities"),
5551                    "{message}"
5552                );
5553            }
5554        }
5555    }
5556
5557    #[test]
5558    fn single_pass_epistemic_rule_unions_without_clause_provenance_are_rejected() {
5559        let fixtures = [
5560            r#"
5561                #pragma epistemic_mode = faeel
5562                pred p().
5563                pred q().
5564                pred result(symbol).
5565                q().
5566                result(a) :- know p().
5567                result(b) :- know q().
5568                ?- result(X).
5569            "#,
5570            r#"
5571                #pragma epistemic_mode = faeel
5572                pred q().
5573                pred result(symbol).
5574                result(a).
5575                result(b) :- know q().
5576                ?- result(X).
5577            "#,
5578        ];
5579
5580        for source in fixtures {
5581            let program = parse_program(source).expect("parse epistemic rule-union fixture");
5582            let errors = [
5583                plan_epistemic_gpu_execution(&program)
5584                    .expect_err("GPU planning must reject a provenance-free rule union"),
5585                reduce_epistemic_program_to_ordinary(&program)
5586                    .expect_err("execution reduction must reject a provenance-free rule union"),
5587                reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
5588                    .expect_err("schema reduction must reject a provenance-free rule union"),
5589            ];
5590            for error in errors {
5591                let message = error.to_string();
5592                assert!(message.contains("epistemic rule-union materialization"));
5593                assert!(message.contains("result/1"), "{message}");
5594                assert!(message.contains("per-clause modal provenance"), "{message}");
5595            }
5596        }
5597    }
5598
5599    #[test]
5600    fn equivalent_epistemic_rule_union_filters_are_distributive() {
5601        let program = parse_program(
5602            r#"
5603                #pragma epistemic_mode = faeel
5604                pred target(u32).
5605                pred left(u32).
5606                pred right(u32).
5607                pred result(u32).
5608                target(1).
5609                target(2).
5610                left(1).
5611                right(2).
5612                result(X) :- left(X), know target(X).
5613                result(Y) :- right(Y), possible target(Y).
5614                ?- result(Value).
5615            "#,
5616        )
5617        .expect("parse equivalent-filter rule union");
5618
5619        plan_epistemic_gpu_execution(&program)
5620            .expect("equivalent invariant modal filters must distribute over the rule union");
5621        reduce_epistemic_program_to_ordinary(&program)
5622            .expect("execution reduction must preserve an equivalent-filter rule union");
5623        reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
5624            .expect("schema reduction must preserve an equivalent-filter rule union");
5625    }
5626
5627    #[test]
5628    fn independently_founded_ground_gates_are_distributive_across_rule_union() {
5629        let program = parse_program(
5630            r#"
5631                #pragma epistemic_mode = g91
5632                pred left(u32).
5633                pred right(u32).
5634                pred result(u32).
5635                left(1).
5636                right(1).
5637                result(1) :- possible left(1).
5638                result(2) :- possible right(1).
5639                ?- result(Value).
5640            "#,
5641        )
5642        .expect("parse independently founded ground-gate rule union");
5643
5644        plan_epistemic_gpu_execution(&program)
5645            .expect("independently founded ground gates must distribute over the rule union");
5646        reduce_epistemic_program_to_ordinary(&program)
5647            .expect("execution reduction must preserve independently founded ground gates");
5648    }
5649
5650    #[test]
5651    fn g91_exact_head_possible_union_preserves_compatibility_self_support() {
5652        let program = parse_program(
5653            r#"
5654                #pragma epistemic_mode = g91
5655                pred seed(u32).
5656                pred node(u32).
5657                pred p(u32).
5658                seed(1).
5659                node(2).
5660                p(X) :- seed(X).
5661                p(X) :- node(X), possible p(X).
5662                ?- p(X).
5663            "#,
5664        )
5665        .expect("parse G91 exact-head possibility union");
5666
5667        assert_eq!(
5668            classify_recursive_epistemic_program(&program).unwrap(),
5669            RecursiveEpistemicClass::ModalCycle
5670        );
5671        let prepared = prepare_epistemic_program(&program).expect("validate G91 source");
5672        let compatibility = try_prepare_g91_compatibility_reduction(&prepared)
5673            .expect("G91 modal cycle must be admitted")
5674            .expect("G91 modal cycle must use an explicit compatibility fixpoint");
5675        Compiler::new()
5676            .compile_program(compatibility.upper_bound_program())
5677            .expect("G91 upper-bound program must compile");
5678        Compiler::new()
5679            .compile_program(compatibility.refinement_program())
5680            .expect("declared G91 snapshot program must compile");
5681        assert_eq!(compatibility.snapshot_relations().len(), 1);
5682    }
5683
5684    #[test]
5685    fn g91_compatibility_applies_to_exact_tuples_across_one_recursive_component() {
5686        let program = parse_program(
5687            r#"
5688                #pragma epistemic_mode = g91
5689                pred domain(u32).
5690                pred p(u32).
5691                pred q(u32).
5692                domain(1).
5693                p(X) :- domain(X), possible q(X).
5694                q(X) :- domain(X), possible p(X).
5695                ?- p(X).
5696                ?- q(X).
5697            "#,
5698        )
5699        .expect("parse mutual G91 modal component");
5700
5701        let prepared = prepare_epistemic_program(&program).expect("validate mutual G91 source");
5702        let compatibility = try_prepare_g91_compatibility_reduction(&prepared)
5703            .expect("mutual G91 modal component must be admitted")
5704            .expect("mutual G91 modal component must use compatibility iteration");
5705        for rule in compatibility
5706            .upper_bound_program()
5707            .rules
5708            .iter()
5709            .filter(|rule| matches!(rule.head.predicate.as_str(), "p" | "q"))
5710        {
5711            assert!(
5712                rule.body
5713                    .iter()
5714                    .any(|literal| matches!(literal, BodyLiteral::Comparison(_))),
5715                "the exact tuple compatibility edge must become a tautological conjunct: {rule:?}"
5716            );
5717            assert!(
5718                !rule.body.iter().any(|literal| {
5719                    matches!(literal, BodyLiteral::Positive(atom) if atom.predicate == "p" || atom.predicate == "q")
5720                }),
5721                "a compatibility edge must not become an ordinary recursive join: {rule:?}"
5722            );
5723        }
5724        for rule in compatibility
5725            .refinement_program()
5726            .rules
5727            .iter()
5728            .filter(|rule| matches!(rule.head.predicate.as_str(), "p" | "q"))
5729        {
5730            assert!(rule.body.iter().any(|literal| {
5731                matches!(literal, BodyLiteral::Positive(atom) if atom.predicate.starts_with("__xlog_g91_snapshot_"))
5732            }));
5733            assert!(!rule
5734                .body
5735                .iter()
5736                .any(|literal| matches!(literal, BodyLiteral::Epistemic(_))));
5737        }
5738    }
5739
5740    #[test]
5741    fn g91_snapshot_names_avoid_programmatic_relation_collisions() {
5742        let mut program = parse_program("pred p(u32).").expect("parse source relation");
5743        program.predicates.push(PredDecl {
5744            name: "__xlog_g91_snapshot_p".to_string(),
5745            types: vec![TypeRef::Scalar(xlog_core::ScalarType::U32)],
5746            columns: vec![PredColumn {
5747                name: None,
5748                typ: TypeRef::Scalar(xlog_core::ScalarType::U32),
5749            }],
5750            is_private: false,
5751        });
5752        let target = "p".to_string();
5753        let names = g91_snapshot_relation_names(&program, std::iter::once(&target));
5754        assert_eq!(
5755            names.get("p").map(String::as_str),
5756            Some("__xlog_g91_snapshot_p_0")
5757        );
5758    }
5759
5760    #[test]
5761    fn g91_compatibility_rejects_recursive_aggregation_in_the_selected_component() {
5762        let program = parse_program(
5763            r#"
5764                #pragma epistemic_mode = g91
5765                pred seed(u32).
5766                pred p(u32).
5767                pred totals(u64).
5768                seed(1).
5769                p(X) :- seed(X), possible p(X).
5770                p(X) :- seed(X), totals(_).
5771                totals(count(X)) :- p(X).
5772                ?- p(X).
5773            "#,
5774        )
5775        .expect("parse recursive aggregate compatibility fixture");
5776
5777        let prepared = prepare_epistemic_program(&program).expect("validate G91 source");
5778        let error = try_prepare_g91_compatibility_reduction(&prepared)
5779            .expect_err("recursive aggregation makes compatibility refinement non-monotone");
5780        let message = error.to_string();
5781        assert!(message.contains("Gelfond-1991 compatibility"), "{message}");
5782        assert!(message.contains("aggregate"), "{message}");
5783        assert!(message.contains("totals"), "{message}");
5784    }
5785
5786    #[test]
5787    fn g91_compatibility_rejects_recursive_negation_in_the_selected_component() {
5788        for negated_dependency in [
5789            "not blocked(X)",
5790            "not possible blocked(X)",
5791            "not know blocked(X)",
5792        ] {
5793            let program = parse_program(&format!(
5794                r#"
5795                    #pragma epistemic_mode = g91
5796                    pred seed(u32).
5797                    pred p(u32).
5798                    pred blocked(u32).
5799                    seed(1).
5800                    p(X) :- seed(X), possible p(X).
5801                    p(X) :- seed(X), {negated_dependency}.
5802                    blocked(X) :- p(X).
5803                    ?- p(X).
5804                "#,
5805            ))
5806            .expect("parse recursive negation compatibility fixture");
5807
5808            let prepared = prepare_epistemic_program(&program).expect("validate G91 source");
5809            let error = match try_prepare_g91_compatibility_reduction(&prepared) {
5810                Err(error) => error,
5811                Ok(_) => panic!(
5812                    "recursive dependency `{negated_dependency}` must make compatibility \
5813                     refinement non-monotone"
5814                ),
5815            };
5816            let message = error.to_string();
5817            assert!(message.contains("Gelfond-1991 compatibility"), "{message}");
5818            assert!(message.contains("negation"), "{message}");
5819            assert!(message.contains("p"), "{message}");
5820        }
5821    }
5822
5823    #[test]
5824    fn source_validation_combines_modal_and_arithmetic_type_evidence_before_elision() {
5825        let program = parse_program(
5826            r#"
5827                #pragma epistemic_mode = faeel
5828                pred p(u32).
5829                p(X) :- X is cast(1, u64), possible p(X).
5830                ?- p(X).
5831            "#,
5832        )
5833        .expect("parse arithmetic and modal type-conflict fixture");
5834
5835        let error = prepare_epistemic_program(&program)
5836            .expect_err("foundedness must not hide the authored type conflict");
5837        let message = error.to_string();
5838        assert!(message.contains("Type mismatch"), "{message}");
5839        assert!(
5840            message.contains("U32") && message.contains("U64"),
5841            "{message}"
5842        );
5843    }
5844
5845    #[test]
5846    fn source_validation_uses_lowerer_arithmetic_order_before_elision() {
5847        let program = parse_program(
5848            r#"
5849                #pragma epistemic_mode = faeel
5850                pred p(i64).
5851                p(X) :- X is Y + 1, Y is 1, possible p(X).
5852            "#,
5853        )
5854        .expect("parse reversed arithmetic dependency fixture");
5855
5856        let error = prepare_epistemic_program(&program)
5857            .expect_err("a later arithmetic binding cannot retroactively validate an earlier one");
5858        assert!(
5859            error.to_string().contains("variable X not bound"),
5860            "{error}"
5861        );
5862    }
5863
5864    #[test]
5865    fn source_validation_rejects_structured_modal_arity_before_rule_elision() {
5866        for mode in ["faeel", "g91"] {
5867            let program = parse_program(&format!(
5868                r#"
5869                    #pragma epistemic_mode = {mode}
5870                    pred p(list<symbol>).
5871                    p([a, b]) :- possible p([a, b]).
5872                    ?- p(X).
5873                "#
5874            ))
5875            .expect("parse structured exact-self-support fixture");
5876
5877            let error = prepare_epistemic_program(&program)
5878                .expect_err("a flattened modal key must match its authored target arity");
5879            let message = error.to_string();
5880            assert!(message.contains("epistemic modal tuple key"), "{message}");
5881            assert!(message.contains("target arity 1"), "{message}");
5882            assert!(message.contains("binding arity 2"), "{message}");
5883        }
5884    }
5885
5886    #[test]
5887    fn source_validation_accepts_structured_key_matching_flat_target_arity() {
5888        let program = parse_program(
5889            r#"
5890                #pragma epistemic_mode = faeel
5891                pred host(u32, u32).
5892                pred watched(u32, u32).
5893                pred out(u32, u32).
5894                host(1, 2).
5895                watched(1, 2).
5896                out(X, Y) :- host(X, Y), know watched([X, Y]).
5897                ?- out(X, Y).
5898            "#,
5899        )
5900        .expect("parse matching structured modal key fixture");
5901
5902        validate_epistemic_source_program(&program)
5903            .expect("a two-element structured key must address a binary target");
5904
5905        let multi_arity = epistemic_extensional_multi_arity_predicates(&program);
5906        assert!(
5907            !multi_arity.contains("watched"),
5908            "a two-column structured modal key and watched/2 are one signature"
5909        );
5910    }
5911
5912    #[test]
5913    fn invariant_analysis_treats_shared_acyclic_dependencies_as_a_diamond() {
5914        let program = parse_program(
5915            r#"
5916                pred base(u32).
5917                pred left(u32).
5918                pred right(u32).
5919                pred joined(u32).
5920                pred out(u32).
5921                base(1).
5922                left(X) :- base(X).
5923                right(X) :- base(X).
5924                joined(X) :- left(X), right(X).
5925                out(X) :- possible joined(X).
5926                ?- out(X).
5927            "#,
5928        )
5929        .expect("parse invariant diamond fixture");
5930
5931        let invariant = InvariantRelations::analyze(&program);
5932        assert!(invariant.is_invariant("joined"));
5933        validate_epistemic_source_program(&program)
5934            .expect("the positive modal over an acyclic diamond must bind its output");
5935        reduce_epistemic_program_to_ordinary(&program)
5936            .expect("the invariant modal binder must reduce to an ordinary join");
5937    }
5938
5939    #[test]
5940    fn positive_modal_binders_are_independent_of_conjunct_order() {
5941        for mode in ["faeel", "g91"] {
5942            for modal_body in [
5943                "possible p(X), possible base(X)",
5944                "possible base(X), possible p(X)",
5945            ] {
5946                let program = parse_program(&format!(
5947                    r#"
5948                        #pragma epistemic_mode = {mode}
5949                        pred base(u32).
5950                        pred p(u32).
5951                        base(1).
5952                        p(X) :- {modal_body}.
5953                        ?- p(X).
5954                    "#
5955                ))
5956                .expect("parse modal binder ordering fixture");
5957
5958                validate_epistemic_source_program(&program).unwrap_or_else(|error| {
5959                    panic!("{mode} body `{modal_body}` must be range-restricted: {error}")
5960                });
5961            }
5962        }
5963    }
5964
5965    #[test]
5966    fn negated_exact_modal_cycles_are_never_removed_as_foundedness_elision() {
5967        let program = parse_program(
5968            r#"
5969                #pragma epistemic_mode = faeel
5970                pred p().
5971                p() :- not possible p().
5972                ?- p().
5973            "#,
5974        )
5975        .expect("parse negated exact modal cycle");
5976
5977        let prepared = prepare_epistemic_program(&program)
5978            .expect("negated modal cycle must survive source preparation");
5979        assert!(!prepared.removed_unfounded_rules());
5980        assert!(prepared.active_program().rules.iter().any(|rule| {
5981            rule.body
5982                .iter()
5983                .any(|literal| matches!(literal, BodyLiteral::Epistemic(modal) if modal.negated))
5984        }));
5985    }
5986
5987    #[test]
5988    fn modal_fixpoint_reduction_preserves_non_bijective_sibling_unions() {
5989        let fixtures = [
5990            r#"
5991                #pragma epistemic_mode = faeel
5992                pred domain(symbol).
5993                pred other(symbol, symbol).
5994                pred p(symbol, symbol).
5995                domain(a).
5996                other(c, d).
5997                p(X, X) :- domain(X).
5998                p(A, B) :- other(A, B).
5999                p(X, X) :- domain(X), know p(X, X).
6000                ?- p(A, B).
6001            "#,
6002            r#"
6003                #pragma epistemic_mode = faeel
6004                pred left(symbol).
6005                pred right(symbol).
6006                pred p(symbol, symbol).
6007                left(x).
6008                right(y).
6009                p(a, X) :- left(X).
6010                p(b, Y) :- right(Y).
6011                p(a, X) :- left(X), know p(a, X).
6012                ?- p(A, B).
6013            "#,
6014        ];
6015
6016        for source in fixtures {
6017            let program = parse_program(source).expect("parse non-bijective rule union");
6018            assert_eq!(
6019                classify_recursive_epistemic_program(&program).unwrap(),
6020                RecursiveEpistemicClass::ModalCycle
6021            );
6022            let reduced = try_reduce_case_a_recursive_epistemic_program(&program)
6023                .expect("modal-cycle sibling union must be admitted")
6024                .expect("modal-cycle sibling union must reduce to a fixpoint");
6025            Compiler::new()
6026                .compile_program(&reduced)
6027                .expect("ordinary fixpoint preserves per-clause sibling rows");
6028        }
6029    }
6030
6031    #[test]
6032    fn constrained_support_does_not_found_a_wider_modal_domain() {
6033        let program = parse_program(
6034            r#"
6035                #pragma epistemic_mode = faeel
6036                pred seed(u32).
6037                pred p(u32).
6038                seed(1).
6039                seed(2).
6040                p(X) :- seed(X), X = 1.
6041                p(X) :- seed(X), possible p(X).
6042                ?- p(X).
6043            "#,
6044        )
6045        .expect("parse constrained foundedness program");
6046
6047        let reduced = reduce_epistemic_program_to_ordinary(&program)
6048            .expect("the unfounded modal clause must be removed, not rejected");
6049        assert_eq!(
6050            reduced
6051                .rules
6052                .iter()
6053                .filter(|rule| rule.head.predicate == "p" && !rule.body.is_empty())
6054                .count(),
6055            1,
6056            "a constrained support clause cannot found the wider self-support domain"
6057        );
6058    }
6059
6060    #[test]
6061    fn derived_predicate_source_arity_collisions_are_rejected() {
6062        let fixtures = [
6063            r#"
6064                #pragma epistemic_mode = faeel
6065                unary(a).
6066                binary(a, b).
6067                result(X) :- unary(X), know unary(X).
6068                result(X, Y) :- binary(X, Y), know binary(X, Y).
6069            "#,
6070            r#"
6071                #pragma epistemic_mode = faeel
6072                node(key).
6073                edge(key, 5000000000).
6074                result(X) :- node(X), know edge(X, Y).
6075                ?- result(A, B).
6076            "#,
6077            r#"
6078                #pragma epistemic_mode = faeel
6079                node(key).
6080                edge(key, 5000000000).
6081                result(X) :- node(X), know edge(X, Y).
6082                observer(X) :- result(X, Y).
6083            "#,
6084        ];
6085
6086        for source in fixtures {
6087            let program = parse_program(source).expect("parse source-arity collision fixture");
6088            let errors = [
6089                plan_epistemic_gpu_execution(&program)
6090                    .expect_err("GPU planning must reject derived source-arity collisions"),
6091                reduce_epistemic_program_to_ordinary(&program)
6092                    .expect_err("execution reduction must reject source-arity collisions"),
6093                reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
6094                    .expect_err("schema reduction must reject source-arity collisions"),
6095            ];
6096            for error in errors {
6097                let message = error.to_string();
6098                assert!(
6099                    message.contains("epistemic derived predicate schema"),
6100                    "{message}"
6101                );
6102                assert!(message.contains("result"), "{message}");
6103                assert!(message.contains("{1, 2}"), "{message}");
6104            }
6105        }
6106    }
6107
6108    #[test]
6109    fn constrained_augmented_head_queries_are_rejected() {
6110        let fixtures = [
6111            r#"
6112                #pragma epistemic_mode = faeel
6113                node(key).
6114                edge(key, 5000000000).
6115                result(X) :- node(X), know edge(X, Y).
6116                ?- result(other).
6117            "#,
6118            r#"
6119                #pragma epistemic_mode = faeel
6120                pair(left, right).
6121                edge(left, 5000000000).
6122                result(X, Y) :- pair(X, Y), know edge(X, Z).
6123                ?- result(Value, Value).
6124            "#,
6125            r#"
6126                #pragma epistemic_mode = faeel
6127                node(key).
6128                edge(key, 5000000000).
6129                allowed(5000000000).
6130                result(X) :- node(X), edge(X, Y), know allowed(Y).
6131                ?- result(key).
6132            "#,
6133        ];
6134
6135        for source in fixtures {
6136            let program = parse_program(source).expect("parse constrained-query fixture");
6137            let errors = [
6138                plan_epistemic_gpu_execution(&program)
6139                    .expect_err("GPU planning must reject a constrained augmented query"),
6140                reduce_epistemic_program_to_ordinary(&program)
6141                    .expect_err("execution reduction must reject a constrained augmented query"),
6142                reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
6143                    .expect_err("schema reduction must reject a constrained augmented query"),
6144            ];
6145            for error in errors {
6146                let message = error.to_string();
6147                assert!(
6148                    message.contains("epistemic augmented head query"),
6149                    "{message}"
6150                );
6151                assert!(message.contains("distinct named variables"), "{message}");
6152            }
6153        }
6154    }
6155
6156    #[test]
6157    fn removed_unfounded_sibling_does_not_create_an_augmented_arity_conflict() {
6158        let program = parse_program(
6159            r#"
6160                #pragma epistemic_mode = faeel
6161                pred node(symbol).
6162                pred edge(symbol, i64).
6163                pred result(symbol).
6164                node(key).
6165                edge(key, 5000000000).
6166                result(X) :- node(X), know edge(X, Y).
6167                result(key) :- possible result(key).
6168            "#,
6169        )
6170        .expect("parse foundedness fixture");
6171
6172        let reduced = reduce_epistemic_program_to_ordinary(&program)
6173            .expect("removed unfounded support must not affect the surviving relation shape");
6174        let result_rules = reduced
6175            .rules
6176            .iter()
6177            .filter(|rule| rule.head.predicate == "result")
6178            .collect::<Vec<_>>();
6179        assert_eq!(result_rules.len(), 1);
6180        assert_eq!(result_rules[0].head.arity(), 1);
6181    }
6182
6183    #[test]
6184    fn ordinary_bound_appended_columns_participate_in_shape_validation() {
6185        let program = parse_program(
6186            r#"
6187                #pragma epistemic_mode = faeel
6188                pred node(symbol).
6189                pred edge(symbol, i64).
6190                pred allowed(i64).
6191                pred result(symbol).
6192                node(key).
6193                edge(key, 5000000000).
6194                allowed(5000000000).
6195                result(X) :- node(X).
6196                result(X) :- node(X), edge(X, Y), know allowed(Y).
6197                ?- result(X).
6198            "#,
6199        )
6200        .expect("parse ordinary-bound augmentation fixture");
6201
6202        let errors = [
6203            plan_epistemic_gpu_execution(&program)
6204                .expect_err("GPU planning must reject divergent internal arities"),
6205            reduce_epistemic_program_to_ordinary(&program)
6206                .expect_err("execution reduction must reject divergent internal arities"),
6207            reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
6208                .expect_err("schema reduction must reject divergent internal arities"),
6209        ];
6210        for error in errors {
6211            let message = error.to_string();
6212            assert!(
6213                message.contains("epistemic augmented predicate schema"),
6214                "{message}"
6215            );
6216            assert!(message.contains("result/1"), "{message}");
6217            assert!(message.contains("{1, 2}"), "{message}");
6218        }
6219    }
6220
6221    #[test]
6222    fn ordinary_bound_appended_columns_widen_the_internal_declaration() {
6223        let program = parse_program(
6224            r#"
6225                #pragma epistemic_mode = faeel
6226                pred node(symbol).
6227                pred edge(symbol, i64).
6228                pred allowed(i64).
6229                pred result(symbol).
6230                node(key).
6231                edge(key, 5000000000).
6232                allowed(5000000000).
6233                result(X) :- node(X), edge(X, Y), know allowed(Y).
6234                ?- result(X).
6235            "#,
6236        )
6237        .expect("parse ordinary-bound declaration fixture");
6238
6239        let reduced = reduce_epistemic_program_to_ordinary(&program)
6240            .expect("uniform ordinary-bound augmentation should reduce");
6241        let declaration = reduced
6242            .predicates
6243            .iter()
6244            .find(|declaration| declaration.name == "result")
6245            .expect("missing result declaration");
6246        assert_eq!(declaration.arity(), 2);
6247        crate::compile::Compiler::new()
6248            .compile_program(&reduced)
6249            .expect("reconciled ordinary-bound augmentation should compile");
6250    }
6251
6252    #[test]
6253    fn stratified_schema_reduction_uses_the_recursive_stratum_reducer() {
6254        let program = parse_program(
6255            r#"
6256                #pragma epistemic_mode = faeel
6257                pred node(u32).
6258                pred edge(u32, u32).
6259                pred accepted_edge(u32, u32).
6260                pred reach(u32, u32).
6261                node(1).
6262                node(2).
6263                node(3).
6264                edge(1, 2).
6265                edge(2, 3).
6266                accepted_edge(X, Y) :- node(X), node(Y), know edge(X, Y).
6267                reach(X, Y) :- node(X), node(Y), know accepted_edge(X, Y).
6268                reach(X, Z) :- reach(X, Y), node(Z), know accepted_edge(Y, Z).
6269                ?- reach(X, Z).
6270            "#,
6271        )
6272        .expect("parse stratified recursive fixture");
6273
6274        let plan = try_plan_stratified_epistemic_program(&program)
6275            .expect("stratified planning should succeed")
6276            .expect("fixture requires stratified execution");
6277        assert_eq!(plan.strata.len(), 2);
6278
6279        let reduced = reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
6280            .expect("schema reduction should follow each stratum's executable reducer");
6281        assert!(reduced
6282            .rules
6283            .iter()
6284            .filter(|rule| rule.head.predicate == "reach")
6285            .all(|rule| rule.head.arity() == 2));
6286        assert_eq!(
6287            reduced
6288                .predicates
6289                .iter()
6290                .find(|declaration| declaration.name == "reach")
6291                .expect("missing reach declaration")
6292                .arity(),
6293            2
6294        );
6295        crate::compile::Compiler::new()
6296            .compile_program(&reduced)
6297            .expect("path-aligned stratified schema program should compile");
6298    }
6299
6300    #[test]
6301    fn high_arity_epistemic_adapter_reduction_is_not_wcoj_required() {
6302        let program = parse_program(
6303            r#"
6304            pred case_variant(u32, u32).
6305            pred case_domain_variant(u32, u32, u32).
6306            pred domain_adapter_root(u32, u32, u32).
6307            pred domain_adapter_intervention(u32, u32, u32).
6308            pred domain_candidate_seed(u32, u32, u32, u32).
6309            pred heldout_label_seed(u32, u32).
6310            pred blocked_candidate(u32, u32, u32).
6311            pred generated_candidate(u32, u32, u32, u32, u32).
6312
6313            generated_candidate(Case, Variant, Candidate, Root, Intervention) :-
6314                case_domain_variant(Case, Variant, Domain),
6315                domain_adapter_root(Domain, Candidate, Root),
6316                domain_adapter_intervention(Domain, Candidate, Intervention),
6317                domain_candidate_seed(Domain, Candidate, Root, Intervention),
6318                know domain_candidate_seed(Domain, Candidate, Root, Intervention),
6319                possible case_variant(Case, Variant),
6320                not know heldout_label_seed(Case, Candidate),
6321                not possible blocked_candidate(Case, Variant, Candidate).
6322            "#,
6323        )
6324        .expect("parse high-arity adapter epistemic program");
6325
6326        let plan = plan_epistemic_gpu_execution(&program)
6327            .expect("plan high-arity adapter epistemic program");
6328
6329        assert_eq!(plan.reductions.len(), 1);
6330        assert_eq!(
6331            plan.reductions[0].wcoj_status,
6332            EpistemicWcojReductionStatus::NotWcojCandidate
6333        );
6334    }
6335
6336    #[test]
6337    fn binary_triangle_epistemic_reduction_still_requires_wcoj() {
6338        let program = parse_program(
6339            r#"
6340            pred xy(u32, u32).
6341            pred yz(u32, u32).
6342            pred xz(u32, u32).
6343            pred tri(u32, u32, u32).
6344
6345            tri(X, Y, Z) :-
6346                xy(X, Y),
6347                yz(Y, Z),
6348                xz(X, Z),
6349                know xy(X, Y).
6350            "#,
6351        )
6352        .expect("parse binary triangle epistemic program");
6353
6354        let plan =
6355            plan_epistemic_gpu_execution(&program).expect("plan binary triangle epistemic program");
6356
6357        assert_eq!(plan.reductions.len(), 1);
6358        assert_eq!(
6359            plan.reductions[0].wcoj_status,
6360            EpistemicWcojReductionStatus::RequiresPlannerEligibility
6361        );
6362    }
6363
6364    #[test]
6365    fn binary_eight_clique_epistemic_reduction_requires_wcoj() {
6366        let program = parse_program(
6367            r#"
6368            pred edge(u32, u32).
6369            pred clique8(u32, u32, u32, u32, u32, u32, u32, u32).
6370
6371            clique8(A, B, C, D, E, F, G, H) :-
6372                edge(A, B),
6373                edge(A, C),
6374                edge(A, D),
6375                edge(A, E),
6376                edge(A, F),
6377                edge(A, G),
6378                edge(A, H),
6379                edge(B, C),
6380                edge(B, D),
6381                edge(B, E),
6382                edge(B, F),
6383                edge(B, G),
6384                edge(B, H),
6385                edge(C, D),
6386                edge(C, E),
6387                edge(C, F),
6388                edge(C, G),
6389                edge(C, H),
6390                edge(D, E),
6391                edge(D, F),
6392                edge(D, G),
6393                edge(D, H),
6394                edge(E, F),
6395                edge(E, G),
6396                edge(E, H),
6397                edge(F, G),
6398                edge(F, H),
6399                edge(G, H),
6400                know edge(A, B).
6401            "#,
6402        )
6403        .expect("parse binary eight-clique epistemic program");
6404
6405        let plan = plan_epistemic_gpu_execution(&program)
6406            .expect("plan binary eight-clique epistemic program");
6407
6408        assert_eq!(plan.reductions.len(), 1);
6409        assert_eq!(
6410            plan.reductions[0].wcoj_status,
6411            EpistemicWcojReductionStatus::RequiresPlannerEligibility
6412        );
6413    }
6414}