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::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 stay rejected with a precise finiteness/resource
2013/// diagnostic -- NOT an "unsupported construct".
2014fn flatten_structured_key_terms(
2015    predicate: &str,
2016    terms: &[EirTerm],
2017) -> Result<(usize, Vec<EirTerm>, Vec<usize>)> {
2018    let mut flattened: Vec<EirTerm> = Vec::with_capacity(terms.len());
2019    for term in terms {
2020        match term {
2021            EirTerm::List(items) => {
2022                flatten_structured_elements(predicate, "list", items, &mut flattened)?;
2023            }
2024            EirTerm::Compound { functor, args } => {
2025                flatten_structured_elements(
2026                    predicate,
2027                    &format!("compound {functor}/{}", args.len()),
2028                    args,
2029                    &mut flattened,
2030                )?;
2031            }
2032            EirTerm::Cons { .. } => {
2033                return Err(XlogError::ResourceExhausted {
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                    estimated_bytes: 0,
2040                    budget_bytes: 0,
2041                });
2042            }
2043            EirTerm::PredRef(name) => {
2044                return Err(XlogError::ResourceExhausted {
2045                    context: format!(
2046                        "modal tuple-key for {predicate} uses predref `{name}`, which has no \
2047                         finite, typed GPU key-column encoding"
2048                    ),
2049                    estimated_bytes: 0,
2050                    budget_bytes: 0,
2051                });
2052            }
2053            EirTerm::Aggregate { op, variable } => {
2054                return Err(XlogError::ResourceExhausted {
2055                    context: format!(
2056                        "modal tuple-key for {predicate} uses aggregate `{op}({variable})`, whose \
2057                         value is not a finite, typed GPU key-column tuple"
2058                    ),
2059                    estimated_bytes: 0,
2060                    budget_bytes: 0,
2061                });
2062            }
2063            scalar => flattened.push(scalar.clone()),
2064        }
2065    }
2066
2067    let arity = flattened.len();
2068    let key_columns = (0..arity).collect();
2069    Ok((arity, flattened, key_columns))
2070}
2071
2072/// Splice the elements of a fixed-arity structured key term into `flattened`.
2073///
2074/// Each element must itself be a scalar/Symbol key element; a nested structure
2075/// would need a column to hold its own sub-tuple, which a flat relation schema
2076/// cannot express, so it is rejected with a precise finiteness diagnostic.
2077fn flatten_structured_elements(
2078    predicate: &str,
2079    shape: &str,
2080    elements: &[EirTerm],
2081    flattened: &mut Vec<EirTerm>,
2082) -> Result<()> {
2083    for element in elements {
2084        if eir_term_is_scalar_key_element(element) {
2085            flattened.push(element.clone());
2086        } else {
2087            return Err(XlogError::ResourceExhausted {
2088                context: format!(
2089                    "modal tuple-key for {predicate} nests a non-scalar element {element:?} inside \
2090                     a {shape}; only fixed-arity structures of scalar/Symbol-typed elements have a \
2091                     finite, typed GPU key-column encoding"
2092                ),
2093                estimated_bytes: 0,
2094                budget_bytes: 0,
2095            });
2096        }
2097    }
2098    Ok(())
2099}
2100
2101fn bound_output_columns_for_terms(
2102    key_terms: &[EirTerm],
2103    output_terms: &[EirTerm],
2104) -> Vec<Option<usize>> {
2105    key_terms
2106        .iter()
2107        .map(|term| match term {
2108            EirTerm::Variable(variable) => output_terms.iter().position(
2109                |head_term| matches!(head_term, EirTerm::Variable(name) if name == variable),
2110            ),
2111            _ => None,
2112        })
2113        .collect()
2114}
2115
2116fn augmented_eir_head_terms(rule: &xlog_ir::EirRule) -> Vec<EirTerm> {
2117    let mut output_terms = rule.head.terms.clone();
2118    for lit in &rule.body {
2119        let EirBodyLiteral::Epistemic(lit) = lit else {
2120            continue;
2121        };
2122        // A modal key variable may be NESTED inside a structured key term
2123        // (`know p([X, Y])`), so flatten before collecting variables that need a
2124        // reduced output column to bind against. Flattening failures are surfaced
2125        // by the binding-construction path; here we fall back to the raw terms so
2126        // diagnostics remain anchored at that site.
2127        let key_terms = flatten_structured_key_terms(&lit.atom.predicate, &lit.atom.terms)
2128            .map(|(_, terms, _)| terms)
2129            .unwrap_or_else(|_| lit.atom.terms.clone());
2130        for term in &key_terms {
2131            let EirTerm::Variable(variable) = term else {
2132                continue;
2133            };
2134            if !output_terms
2135                .iter()
2136                .any(|head_term| matches!(head_term, EirTerm::Variable(name) if name == variable))
2137            {
2138                output_terms.push(EirTerm::Variable(variable.clone()));
2139            }
2140        }
2141    }
2142    output_terms
2143}
2144
2145fn final_output_columns_for_eir(eir: &EirProgram) -> Option<Vec<usize>> {
2146    let mut final_columns = Vec::new();
2147    let mut needs_projection = false;
2148    for rule in &eir.rules {
2149        if !rule
2150            .body
2151            .iter()
2152            .any(|lit| matches!(lit, EirBodyLiteral::Epistemic(_)))
2153        {
2154            continue;
2155        }
2156        let augmented_len = augmented_eir_head_terms(rule).len();
2157        if augmented_len > rule.head.terms.len() {
2158            needs_projection = true;
2159        }
2160        if final_columns.is_empty() {
2161            final_columns = (0..rule.head.terms.len()).collect();
2162        }
2163    }
2164    if needs_projection {
2165        Some(final_columns)
2166    } else {
2167        None
2168    }
2169}
2170
2171/// Indices (into `program.rules`) of exact tuple-level FAEEL rules that are unfounded
2172/// by circular modal self-support and must be excluded from the reduced founded-model
2173/// base without removing predecessor or tuple-permutation edges.
2174///
2175/// A rule qualifies when (a) the program is in FAEEL mode, (b) the rule body contains a
2176/// modal literal `possible p`/`know p` over the rule's head predicate and arity,
2177/// (c) that head has NO independent founded support
2178/// ([`has_independent_founded_support`]) and NO tuple-level founded support
2179/// ([`has_tuple_level_independent_founded_support`]), and (d) excluding the rule does
2180/// NOT silently elide a mode-independent safety failure — i.e. the head carries no
2181/// variable bound ONLY by the self-supporting modal. Condition (d) preserves the clean
2182/// `UnsafeVariable` honest-exit for pure nonzero self-support (`p(X) :- possible p(X)`)
2183/// in EVERY mode (G91 rejects it identically): dropping such a rule would replace a
2184/// precise safety diagnostic with a confusing materialization error.
2185/// Every schema census, shape check, stratified plan, and executable reduction uses
2186/// this same decision so no broader predicate-level approximation can erase live
2187/// support.
2188fn faeel_unfounded_exact_tuple_self_support_rule_indices(program: &Program) -> Vec<usize> {
2189    let Ok(eir) = build_eir(program) else {
2190        return Vec::new();
2191    };
2192    if eir.mode != EirEpistemicMode::Faeel {
2193        return Vec::new();
2194    }
2195    let mut indices = Vec::new();
2196    for (index, (rule, eir_rule)) in program.rules.iter().zip(&eir.rules).enumerate() {
2197        let modal_only_output_variables = modal_only_bound_output_variables(rule);
2198        let drop = eir_rule.body.iter().any(|lit| {
2199            let EirBodyLiteral::Epistemic(modal) = lit else {
2200                return false;
2201            };
2202            if modal.negated
2203                || modal.atom.predicate != eir_rule.head.predicate
2204                || modal.atom.arity != eir_rule.head.arity
2205                || modal.atom.terms != eir_rule.head.terms
2206            {
2207                return false;
2208            }
2209            // Founded by an independent (non-circular) derivation: keep the rule; the
2210            // founded support proves the head, so it stays in the model.
2211            if has_independent_founded_support(&eir, &modal.atom)
2212                || has_tuple_level_independent_founded_support(&eir, eir_rule, &modal.atom)
2213            {
2214                return false;
2215            }
2216            // A head variable bound ONLY by this self-supporting modal would be unbound
2217            // (`UnsafeVariable`) in every mode once the modal is stripped: do NOT drop,
2218            // let the existing safety path raise the precise diagnostic.
2219            if modal
2220                .atom
2221                .terms
2222                .iter()
2223                .any(|term| matches!(term, EirTerm::Variable(name) if modal_only_output_variables.contains(name)))
2224            {
2225                return false;
2226            }
2227            true
2228        });
2229        if drop {
2230            indices.push(index);
2231        }
2232    }
2233    indices
2234}
2235
2236fn program_without_rule_indices(program: &Program, removed_rule_indices: &[usize]) -> Program {
2237    if removed_rule_indices.is_empty() {
2238        return program.clone();
2239    }
2240
2241    let removed_rule_indices = removed_rule_indices
2242        .iter()
2243        .copied()
2244        .collect::<BTreeSet<_>>();
2245    let mut filtered = program.clone();
2246    filtered.rules = program
2247        .rules
2248        .iter()
2249        .enumerate()
2250        .filter(|(index, _)| !removed_rule_indices.contains(index))
2251        .map(|(_, rule)| rule.clone())
2252        .collect();
2253    filtered
2254}
2255
2256/// Validate the complete authored epistemic program before foundedness can elide a rule.
2257///
2258/// Every authored predicate signature receives a temporary name-and-arity identity. This
2259/// keeps a semantically dead `p/1` clause from colliding with a live `p/2` relation while
2260/// preserving all declaration, clause, arithmetic, and modal type evidence within each
2261/// signature. Modal range restriction is checked against the executable contract first;
2262/// the validation clone then reaches the ordinary compiler's production preprocessing and
2263/// lowering checks without requiring ordinary stratification, because supported negated
2264/// modal cycles are dispatched to well-founded execution later.
2265pub fn validate_epistemic_source_program(program: &Program) -> Result<()> {
2266    let prepared = prepare_root_authored_constraint_identity(program)?;
2267    validate_prepared_epistemic_source_program(&prepared)
2268}
2269
2270fn validate_prepared_epistemic_source_program(program: &Program) -> Result<()> {
2271    validate_authored_modal_key_shapes(program)?;
2272    let invariant = InvariantRelations::analyze(program);
2273    let determined = EpistemicallyDeterminedPredicates::analyze(program);
2274    for rule in &program.rules {
2275        validate_modal_variable_bindings(&rule.body, &invariant, &determined)?;
2276    }
2277    for constraint in &program.constraints {
2278        validate_modal_variable_bindings(&constraint.body, &invariant, &determined)?;
2279    }
2280
2281    let mut validation = program.clone();
2282    for rule in &mut validation.rules {
2283        rewrite_modal_literals_for_source_validation(&mut rule.body, &invariant, &determined);
2284    }
2285    for constraint in &mut validation.constraints {
2286        rewrite_modal_literals_for_source_validation(&mut constraint.body, &invariant, &determined);
2287    }
2288
2289    let multi_arity_predicates = all_multi_arity_predicates(program);
2290    qualify_predicate_signatures(&mut validation, &multi_arity_predicates);
2291    Compiler::new().validate_program_without_stratification(&validation)
2292}
2293
2294/// Validate every authored modal tuple key against the relation signatures it can
2295/// address before foundedness or dependency reduction can remove the containing rule.
2296///
2297/// Structured keys are syntax for a flat tuple: `p([X, Y])` addresses `p/2`, not
2298/// `p/1`. The ordinary AST arity is therefore not the runtime key arity. Derive the
2299/// target signatures from declarations and non-modal relation occurrences, flatten
2300/// each EIR modal key through the production normalizer, and reject a mismatch at the
2301/// source boundary. This also preserves the precise finiteness diagnostic for an
2302/// unbounded structured key instead of allowing semantic elision to hide it.
2303fn validate_authored_modal_key_shapes(program: &Program) -> Result<()> {
2304    let target_arities = non_modal_relation_arities(program);
2305    let eir = build_eir(program)?;
2306    for modal in eir
2307        .rules
2308        .iter()
2309        .flat_map(|rule| &rule.body)
2310        .chain(
2311            eir.constraints
2312                .iter()
2313                .flat_map(|constraint| &constraint.body),
2314        )
2315        .filter_map(|literal| match literal {
2316            EirBodyLiteral::Epistemic(modal) => Some(modal),
2317            EirBodyLiteral::Relational { .. }
2318            | EirBodyLiteral::Constraint
2319            | EirBodyLiteral::Binding => None,
2320        })
2321    {
2322        let flattened = flatten_epistemic_literal(modal)?;
2323        let Some(expected) = target_arities.get(&flattened.atom.predicate) else {
2324            // An undeclared relation with no non-modal occurrence may be supplied as
2325            // an external relation. Its schema is established by the caller.
2326            continue;
2327        };
2328        if expected.contains(&flattened.atom.arity) {
2329            continue;
2330        }
2331
2332        let expected_description = if expected.len() == 1 {
2333            format!(
2334                "target arity {}",
2335                expected.first().expect("one target arity")
2336            )
2337        } else {
2338            format!("target arities {expected:?}")
2339        };
2340        return Err(XlogError::UnsupportedEpistemicConstruct {
2341            construct: "epistemic modal tuple key".to_string(),
2342            context: format!(
2343                "modal target `{}` has {expected_description}, but its tuple key flattens to \
2344                 binding arity {}; use one scalar key term per target column",
2345                flattened.atom.predicate, flattened.atom.arity
2346            ),
2347        });
2348    }
2349    Ok(())
2350}
2351
2352fn non_modal_relation_arities(program: &Program) -> BTreeMap<String, BTreeSet<usize>> {
2353    let mut arities = BTreeMap::new();
2354    for declaration in &program.predicates {
2355        arities
2356            .entry(declaration.name.clone())
2357            .or_insert_with(BTreeSet::new)
2358            .insert(declaration.arity());
2359    }
2360    for rule in &program.rules {
2361        record_predicate_signature(&mut arities, &rule.head);
2362        record_non_modal_body_signatures(&mut arities, &rule.body);
2363    }
2364    for constraint in &program.constraints {
2365        record_non_modal_body_signatures(&mut arities, &constraint.body);
2366    }
2367    for query in &program.queries {
2368        record_predicate_signature(&mut arities, &query.atom);
2369    }
2370    for fact in &program.prob_facts {
2371        record_predicate_signature(&mut arities, &fact.atom);
2372    }
2373    for disjunction in &program.annotated_disjunctions {
2374        for choice in &disjunction.choices {
2375            record_predicate_signature(&mut arities, &choice.atom);
2376        }
2377    }
2378    for evidence in &program.evidence {
2379        record_predicate_signature(&mut arities, &evidence.atom);
2380    }
2381    for query in &program.prob_queries {
2382        record_predicate_signature(&mut arities, &query.atom);
2383    }
2384    for declaration in &program.neural_predicates {
2385        record_predicate_signature(&mut arities, &declaration.predicate);
2386    }
2387    for rule in &program.learnable_rules {
2388        record_predicate_signature(&mut arities, &rule.head);
2389        record_non_modal_body_signatures(&mut arities, &rule.body);
2390    }
2391    arities
2392}
2393
2394fn record_non_modal_body_signatures(
2395    signatures: &mut BTreeMap<String, BTreeSet<usize>>,
2396    body: &[BodyLiteral],
2397) {
2398    for literal in body {
2399        if let BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) = literal {
2400            record_predicate_signature(signatures, atom);
2401        }
2402    }
2403}
2404
2405/// Replace modals for validation without changing the authored ordinary literal order.
2406/// Positive modals over invariant or acyclically determined targets remain binders.
2407/// Every other scalar-key modal is appended as an ordinary negated atom so it
2408/// contributes schema and type evidence while the compiler independently verifies that
2409/// all of its variables already have a finite source.
2410fn rewrite_modal_literals_for_source_validation(
2411    body: &mut Vec<BodyLiteral>,
2412    invariant: &InvariantRelations<'_>,
2413    determined: &EpistemicallyDeterminedPredicates,
2414) {
2415    let mut non_binding_modals = Vec::new();
2416    body.retain_mut(|literal| {
2417        let BodyLiteral::Epistemic(modal) = literal else {
2418            return true;
2419        };
2420        if modal.atom.terms.iter().any(|term| {
2421            !matches!(
2422                term,
2423                Term::Variable(_)
2424                    | Term::Anonymous
2425                    | Term::Integer(_)
2426                    | Term::Float(_)
2427                    | Term::String(_)
2428                    | Term::Symbol(_)
2429            )
2430        }) {
2431            // Structured modal keys have a dedicated finite-key normalization and
2432            // diagnostic path. Ordinary list lowering cannot represent an unbounded
2433            // `cons` key and would preempt that precise epistemic diagnostic.
2434            *literal = BodyLiteral::Comparison(Comparison {
2435                left: Term::Integer(1),
2436                op: CompOp::Eq,
2437                right: Term::Integer(1),
2438            });
2439            return true;
2440        }
2441        if !modal.negated
2442            && (invariant.is_invariant(&modal.atom.predicate)
2443                || determined.contains(&modal.atom.predicate))
2444        {
2445            *literal = BodyLiteral::Positive(modal.atom.clone());
2446            true
2447        } else {
2448            non_binding_modals.push(BodyLiteral::Negated(modal.atom.clone()));
2449            false
2450        }
2451    });
2452    body.extend(non_binding_modals);
2453}
2454
2455/// Variables with an ordinary finite source, matching the Lowerer's binding order:
2456/// every positive atom is joined first, then deterministic `is` expressions are applied
2457/// once in source order. A reversed arithmetic dependency is therefore not accepted by a
2458/// fixed-point approximation.
2459fn non_epistemic_bound_variables(body: &[BodyLiteral]) -> BTreeSet<String> {
2460    let mut bound = BTreeSet::new();
2461    for literal in body {
2462        let BodyLiteral::Positive(atom) = literal else {
2463            continue;
2464        };
2465        bound.extend(
2466            atom.variables()
2467                .into_iter()
2468                .filter(|name| *name != "_")
2469                .map(str::to_string),
2470        );
2471    }
2472
2473    for literal in body {
2474        let BodyLiteral::IsExpr(binding) = literal else {
2475            continue;
2476        };
2477        if binding
2478            .expr
2479            .variables()
2480            .iter()
2481            .all(|name| bound.contains(*name))
2482        {
2483            bound.insert(binding.target.clone());
2484        }
2485    }
2486
2487    bound
2488}
2489
2490/// Enforce the modal binding contract before a reduction turns modal atoms into ordinary
2491/// joins. A co-evolving or negated modal may filter an already-bound tuple but may not
2492/// invent a finite domain; only a positive modal over an invariant or acyclically
2493/// determined relation can bind.
2494fn validate_modal_variable_bindings(
2495    body: &[BodyLiteral],
2496    invariant: &InvariantRelations<'_>,
2497    determined: &EpistemicallyDeterminedPredicates,
2498) -> Result<()> {
2499    let mut bound = non_epistemic_bound_variables(body);
2500
2501    // A rule body is a conjunction, so positive finite modal sources bind
2502    // independently of their textual order. Collect every such binder before
2503    // checking co-evolving or negated modal filters; only deterministic `is`
2504    // expressions retain the Lowerer's source-order contract.
2505    for literal in body {
2506        let BodyLiteral::Epistemic(modal) = literal else {
2507            continue;
2508        };
2509        let may_bind = !modal.negated
2510            && (invariant.is_invariant(&modal.atom.predicate)
2511                || determined.contains(&modal.atom.predicate));
2512        if may_bind {
2513            bound.extend(
2514                modal
2515                    .atom
2516                    .variables()
2517                    .into_iter()
2518                    .filter(|name| *name != "_")
2519                    .map(str::to_string),
2520            );
2521        }
2522    }
2523
2524    for literal in body {
2525        let BodyLiteral::Epistemic(modal) = literal else {
2526            continue;
2527        };
2528        let may_bind = !modal.negated
2529            && (invariant.is_invariant(&modal.atom.predicate)
2530                || determined.contains(&modal.atom.predicate));
2531        for variable in modal.atom.variables() {
2532            if variable == "_" {
2533                continue;
2534            }
2535            if !may_bind && !bound.contains(variable) {
2536                return Err(XlogError::UnsafeVariable(variable.to_string()));
2537            }
2538        }
2539    }
2540    Ok(())
2541}
2542
2543/// Return the ordinary runtime program selected by epistemic dependency
2544/// classification.
2545///
2546/// Admissible ordinary or modal dependency cycles resolve their modal edges into an
2547/// ordinary fixpoint program. Acyclic programs use the single-pass reduction; callers
2548/// must validate its explicit epistemic GPU contract before execution.
2549///
2550/// The augmenting positive-modal resolve is gated on INVARIANT targets only (see the
2551/// body comment): for an invariant `R`, `know R`/`possible R` ranges exactly over
2552/// `R`'s extension, so resolving the modal into an ordinary join binds the augmented
2553/// output column WITHOUT leaking — and the GPU membership filter re-gates
2554/// post hoc. A determined-but-not-invariant target (an epistemic-derived head like a
2555/// multi-column `r`) is NOT resolved here, so its augmenting output variable stays
2556/// unbound and the reduced program fails closed at this strict (execution) entry
2557/// point. See [`reduce_epistemic_program_to_ordinary_for_stratified_schema`] for the
2558/// schema-only relaxation used by the stratified driver.
2559pub fn reduce_epistemic_program_to_ordinary(program: &Program) -> Result<Program> {
2560    let prepared = prepare_root_authored_constraint_identity(program)?;
2561    if let Some(reduced) = try_reduce_case_a_recursive_epistemic_program(&prepared)? {
2562        return Ok(reduced);
2563    }
2564    reduce_epistemic_program_to_ordinary_inner(&prepared, &BTreeSet::new(), &BTreeMap::new())
2565}
2566
2567/// Schema-only reduction for the stratified epistemic driver.
2568///
2569/// Identical to [`reduce_epistemic_program_to_ordinary`] EXCEPT it also resolves an
2570/// augmenting positive modal whose target is epistemically DETERMINED (as classified
2571/// by the internal determined-predicate analysis) but not invariant — e.g. a
2572/// multi-column determined head `r` in `out(X) :- node(X), know r(X, Y)`, where the
2573/// modal binds the augmented output column `Y`. This is used SOLELY to compute the
2574/// plan-wide relation SCHEMAS (column types/arities) for an
2575/// [`EpistemicStratifiedPlan`]; the resolved positive atom over `r` supplies
2576/// `Y`'s declared column type so the schema compiler does not reject the augmented
2577/// `out(X, Y)` head as unsafe.
2578///
2579/// SOUNDNESS / NON-LEAK: a determined `r` IS gated into the store as a materialized
2580/// base relation by the LOWER stratum before the higher stratum runs (the stratified
2581/// executor's `materialize_epistemic_head_relation` at the STORE boundary), and the
2582/// higher stratum is compiled by `compile_stratum_plan` over a sub-program where
2583/// `r`'s defining rule is DROPPED — so there `r` is invariant and the EXISTING strict
2584/// resolve binds `Y` against the GATED `r` for execution. The determined-relaxed
2585/// resolve here therefore NEVER drives runtime data: it only types columns. It is not
2586/// used by the single/joint or Case-A EXECUTION reduce, so it cannot resolve a modal
2587/// into a join over an UN-gated candidate relation.
2588pub fn reduce_epistemic_program_to_ordinary_for_stratified_schema(
2589    program: &Program,
2590) -> Result<Program> {
2591    let prepared = prepare_root_authored_constraint_identity(program)?;
2592    let determined = EpistemicallyDeterminedPredicates::analyze(&prepared);
2593    let path_specific_rules = stratified_schema_reduction_overrides(&prepared)?;
2594    reduce_epistemic_program_to_ordinary_inner(
2595        &prepared,
2596        &determined.determined,
2597        &path_specific_rules,
2598    )
2599}
2600
2601fn prepare_root_authored_constraint_identity(program: &Program) -> Result<Program> {
2602    let mut prepared = program.clone();
2603    if prepared.authored_constraint_source_bound.is_some() {
2604        prepared.validate_prepared_authored_constraint_identity()?;
2605    } else {
2606        prepared.prepare_authored_constraint_identity_at_root()?;
2607    }
2608    Ok(prepared)
2609}
2610
2611/// Shared body of the epistemic-to-ordinary reduction.
2612///
2613/// `schema_only_determined_resolve` names predicates that are epistemically
2614/// DETERMINED and whose augmenting positive modal may additionally be resolved into a
2615/// positive ordinary atom for SCHEMA inference only (empty for the strict execution
2616/// reduce). The INVARIANT-target resolve is always active for both entry points.
2617fn reduce_epistemic_program_to_ordinary_inner(
2618    program: &Program,
2619    schema_only_determined_resolve: &BTreeSet<String>,
2620    path_specific_rules: &BTreeMap<usize, crate::ast::Rule>,
2621) -> Result<Program> {
2622    let path_specific_rule_indices = path_specific_rules.keys().copied().collect::<BTreeSet<_>>();
2623    validate_epistemic_relation_shapes(program, &path_specific_rule_indices)?;
2624
2625    // FAEEL FOUNDED-MODEL EXTENSION: a rule whose head is supported ONLY by circular
2626    // modal self-support (`possible p`/`know p` over its own head, with no independent
2627    // founded derivation) contributes nothing to the FAEEL founded model. Excluding the
2628    // rule from the reduced ordinary base is precisely the founded/equilibrium
2629    // semantics: the unfounded head is absent from the model rather than fabricated by
2630    // the stripped-modal `1=1` filler (which would wrongly found it, the G91 answer).
2631    //
2632    // This is the structural foundedness DECISION (compile-time, reusing the exact
2633    // `has_independent_founded_support` / `has_tuple_level_independent_founded_support`
2634    // structural support predicates) driving the EXTENSION COMPUTATION on the
2635    // GPU/runtime path: the dropped rule simply removes the unfounded head's founding
2636    // base, and the existing GPU world-view validation then accepts the empty/founded
2637    // candidate. G91 keeps the filler (no drop), so `possible p` stays accepted —
2638    // this drop IS the FAEEL-vs-G91 mode difference.
2639    //
2640    // SCOPE: the drop fires only for FAEEL mode. A rule whose head carries a variable
2641    // bound ONLY by the self-supporting modal is NOT dropped here; with the modal
2642    // stripped that variable is genuinely unbound (`UnsafeVariable`) in EVERY mode
2643    // (G91 included), so it must fall through to the existing safety path rather than
2644    // be silently elided. Dropping it would mask a mode-independent safety failure.
2645    let removed_rule_indices = faeel_unfounded_exact_tuple_self_support_rule_indices(program);
2646    let removed_rule_index_set = removed_rule_indices
2647        .iter()
2648        .copied()
2649        .collect::<BTreeSet<_>>();
2650    let active_original_rule_indices = (0..program.rules.len())
2651        .filter(|index| !removed_rule_index_set.contains(index))
2652        .collect::<Vec<_>>();
2653    let mut reduced = program_without_rule_indices(program, &removed_rule_indices);
2654
2655    // AUGMENTING positive modals over INVARIANT relations are resolved into positive
2656    // ordinary join atoms (instead of being stripped) so the augmented head columns
2657    // they introduce are range-restricted in the reduced ordinary candidate program.
2658    //
2659    // An AUGMENTING modal carries a variable that is appended to the head by
2660    // `append_body_local_tuple_key_variables_to_head` (a modal-local variable absent
2661    // from the user-visible head, e.g. `Y` in `one_hop(X) :- node(X), know edge(X,
2662    // Y)`). After the modal is stripped, that augmented `Y` column has no binding, so
2663    // the reduced rule would be unsafe (`UnsafeVariable`). Resolving the positive
2664    // modal over its (invariant) gated relation into a positive ordinary atom binds
2665    // the column. This mirrors the proven-sound Case-A invariant resolution
2666    // (`reduce_case_a_epistemic_program_to_ordinary`): for an INVARIANT relation `R`,
2667    // `know R`/`possible R` ranges exactly over `R`'s extension, so the reduced
2668    // candidate join over `R` enumerates the correct augmented tuples and the GPU
2669    // membership filter then re-gates them against the accepted world view.
2670    //
2671    // STRICTLY SCOPED to keep the prohibition on resolving over still-modal relations
2672    // machine-checked: only POSITIVE modals (negated `not know`/`not possible` is an
2673    // anti-join that does NOT range-restrict, so it is never resolved) over INVARIANT
2674    // targets (a still-modal / epistemic-derived target is NOT invariant, so it is
2675    // never resolved — its augmenting variable stays unbound and the reduced program
2676    // fails closed). Non-augmenting modals keep the existing single- and joint-solver
2677    // strip-and-gate path.
2678    let invariant = InvariantRelations::analyze(program);
2679
2680    // Every rule whose internal head gains tuple-key columns must use a widened
2681    // declaration. The recursive epistemic path has its own non-augmenting reducer;
2682    // this single-pass reduction records the actual head transformation, including
2683    // columns that an ordinary atom already binds. Rule indices retain the exact
2684    // source signature because the head arity changes before reconciliation.
2685    let mut augmented_rule_original_arities = BTreeMap::new();
2686
2687    for ((rule_index, rule), original_rule_index) in reduced
2688        .rules
2689        .iter_mut()
2690        .enumerate()
2691        .zip(active_original_rule_indices)
2692    {
2693        if let Some(path_specific_rule) = path_specific_rules.get(&original_rule_index) {
2694            *rule = path_specific_rule.clone();
2695            continue;
2696        }
2697        let original_head_arity = rule.head.arity();
2698        // Head variables that NO non-epistemic positive body literal binds. After the
2699        // modal is stripped, an output (head) variable bound ONLY by the modal would
2700        // be unsafe in the reduced ordinary program. Computed BEFORE the head is
2701        // mutated by augmentation. (`append_body_local_tuple_key_variables_to_head`
2702        // appends modal-local variables to the head, so both already-present head
2703        // variables like `Y` in `pair(X,Y) :- ..possible edge(X,Y)` AND augmented
2704        // variables like `Y` in `one_hop(X) :- ..know edge(X,Y)` are covered here.)
2705        let modal_only_output_variables = modal_only_bound_output_variables(rule);
2706        append_body_local_tuple_key_variables_to_head(rule);
2707        if rule.head.arity() > original_head_arity {
2708            augmented_rule_original_arities.insert(rule_index, original_head_arity);
2709        }
2710        let was_fact = rule.body.is_empty();
2711        let had_epistemic_body = rule
2712            .body
2713            .iter()
2714            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
2715        // Resolve a POSITIVE modal over an INVARIANT relation into a positive ordinary
2716        // join atom WHEN it is the sole binder of some output variable (so that output
2717        // variable is range-restricted in the reduced candidate program); strip every
2718        // other modal. For an invariant relation `R`, `know R`/`possible R` ranges
2719        // exactly over `R`'s extension, so the reduced join enumerates the correct
2720        // candidate tuples and the GPU filter re-gates against the accepted
2721        // world view. A NEGATED modal (anti-join) never binds and is never resolved; a
2722        // still-modal / epistemic-derived target is NOT invariant and is never
2723        // resolved, so its unbound output variable correctly fails closed downstream.
2724        for lit in &mut rule.body {
2725            if let BodyLiteral::Epistemic(modal) = lit {
2726                // The target is resolvable when it is INVARIANT (always — proven-sound
2727                // for both schema and execution), OR — for SCHEMA inference only — when
2728                // it is epistemically DETERMINED. The determined relaxation is empty for
2729                // the strict execution reduce, so an execution-path reduce never
2730                // resolves a modal over a still-derived (un-gated) relation.
2731                if resolves_augmented_head_variable(
2732                    modal,
2733                    &modal_only_output_variables,
2734                    &invariant,
2735                    schema_only_determined_resolve,
2736                ) {
2737                    *lit = BodyLiteral::Positive(modal.atom.clone());
2738                }
2739            }
2740        }
2741        rule.body
2742            .retain(|lit| !matches!(lit, BodyLiteral::Epistemic(_)));
2743        if !was_fact && had_epistemic_body && rule.body.is_empty() {
2744            rule.body.push(BodyLiteral::Comparison(Comparison {
2745                left: Term::Integer(1),
2746                op: CompOp::Eq,
2747                right: Term::Integer(1),
2748            }));
2749        }
2750    }
2751    // Head augmentation appends modal-local columns to a genuinely-augmented rule head
2752    // (e.g. `one_hop(X)` becomes `one_hop(X, Y)`), so the reduced relation carries the
2753    // augmented columns needed for the GPU tuple-key membership gate. The predicate
2754    // DECLARATION must be widened to the augmented arity, or the runtime would union
2755    // the augmented rule output against the narrow declared (empty) stub and fail with
2756    // a schema mismatch. Infer each appended column's type from the positive body
2757    // atom that binds it; modal-only columns use the resolved invariant atom.
2758    qualify_extensional_multi_arity_predicates(&mut reduced, program, &removed_rule_index_set);
2759
2760    let augmented_signatures =
2761        reconcile_augmented_head_declarations(&mut reduced, &augmented_rule_original_arities)?;
2762
2763    // Drop reduced-program queries that reference an AUGMENTED head: the reduced
2764    // relation is now arity-bumped, so an original arity-N query over it would union
2765    // the arity-N query projection against the augmented relation and fail with a
2766    // schema mismatch. The user-visible query results for epistemic heads are
2767    // surfaced separately from the GPU gated buffers (`epistemic_result_to_query_
2768    // results`, projected to public arity), and the surfacing gate
2769    // (`queried_predicates`) reads the ORIGINAL program's queries, so dropping the
2770    // redundant reduced query here is inert for display and only removes the crash.
2771    // Non-augmented epistemic heads keep their arity-matched reduced queries untouched.
2772    if !augmented_signatures.is_empty() {
2773        reduced.queries.retain(|query| {
2774            !augmented_signatures.contains_key(&(query.atom.predicate.clone(), query.atom.arity()))
2775        });
2776    }
2777
2778    // Constraints that contain epistemic literals are world-view integrity
2779    // constraints: they constrain accepted candidate world views and are
2780    // evaluated by the GPU world-view constraint kernel, NOT by the reduced
2781    // ordinary runtime. Stripping their epistemic literals would leave an
2782    // always-true ordinary constraint, so drop them from the reduced program
2783    // entirely. Purely relational constraints stay as ordinary constraints.
2784    reduced.constraints.retain(|constraint| {
2785        !constraint
2786            .body
2787            .iter()
2788            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
2789    });
2790
2791    Ok(reduced)
2792}
2793
2794/// Reduce an admitted recursive epistemic program to an ordinary program for the
2795/// existing fixpoint engine.
2796///
2797/// Unlike [`reduce_epistemic_program_to_ordinary`] (which strips modal literals and
2798/// gates the single-pass result post hoc), this RESOLVES each positive `know`/
2799/// `possible` literal to its gated relation by rewriting it into an ordinary positive
2800/// body atom over the same predicate. An invariant modal target becomes a fixed join;
2801/// a co-evolving FAEEL target becomes a recursive join whose least fixpoint is its
2802/// founded extension. Gelfond-1991 compatibility cycles are intercepted by
2803/// [`try_prepare_g91_compatibility_reduction`] and require their explicit descending
2804/// tuple fixpoint; this ordinary reducer never deletes those gates. Modal variables
2805/// become ordinary join variables, so tuple transitions remain inside the fixpoint
2806/// instead of being approximated by a post-hoc single-pass gate.
2807///
2808/// Callers MUST first admit the program through
2809/// [`classify_recursive_epistemic_program`]; this function assumes that contract for
2810/// every supported recursive class.
2811pub fn reduce_case_a_epistemic_program_to_ordinary(program: &Program) -> Program {
2812    let mut reduced = program.clone();
2813    for rule in &mut reduced.rules {
2814        resolve_recursive_epistemic_rule_modals(rule);
2815    }
2816    // World-view integrity constraints have no place in this ordinary recursive
2817    // program: the recursion already joins against the resolved relations. Drop any constraint that
2818    // still references a modal literal (purely relational constraints are retained).
2819    reduced.constraints.retain(|constraint| {
2820        !constraint
2821            .body
2822            .iter()
2823            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
2824    });
2825    qualify_extensional_multi_arity_predicates(&mut reduced, program, &BTreeSet::new());
2826    reduced
2827}
2828
2829/// Reduce the surviving acyclic portion of a FAEEL program after exact unfounded
2830/// self-support has been removed.
2831///
2832/// The removed cycle established that this source entered the founded recursive
2833/// route, but the remaining rules no longer need iteration through a modal edge.
2834/// Resolving their modal literals to ordinary atoms preserves the now-determined
2835/// founded extension and, unlike the single-pass candidate reducer, keeps every
2836/// surviving gate load-bearing. Modal integrity constraints are resolved as ordinary
2837/// constraints because the surviving program has a single determined model.
2838fn reduce_founded_epistemic_program_to_ordinary(program: &Program) -> Program {
2839    let mut reduced = program.clone();
2840    for rule in &mut reduced.rules {
2841        resolve_recursive_epistemic_rule_modals(rule);
2842    }
2843    for constraint in &mut reduced.constraints {
2844        for literal in &mut constraint.body {
2845            let BodyLiteral::Epistemic(modal) = literal else {
2846                continue;
2847            };
2848            *literal = if modal.negated {
2849                BodyLiteral::Negated(modal.atom.clone())
2850            } else {
2851                BodyLiteral::Positive(modal.atom.clone())
2852            };
2853        }
2854    }
2855    qualify_extensional_multi_arity_predicates(&mut reduced, program, &BTreeSet::new());
2856    reduced
2857}
2858
2859fn resolve_recursive_epistemic_rule_modals(rule: &mut crate::ast::Rule) {
2860    for literal in &mut rule.body {
2861        if let BodyLiteral::Epistemic(modal) = literal {
2862            *literal = if modal.negated {
2863                BodyLiteral::Negated(modal.atom.clone())
2864            } else {
2865                BodyLiteral::Positive(modal.atom.clone())
2866            };
2867        }
2868    }
2869}
2870
2871/// Output (head) variables of `rule` that are bound ONLY by epistemic literals, i.e.
2872/// no positive non-epistemic body literal binds them.
2873///
2874/// Includes BOTH variables already in the user-visible head (e.g. `Y` in
2875/// `pair(X,Y) :- color(X), possible edge(X,Y)`) AND modal-local variables that
2876/// augmentation will append to the head (e.g. `Y` in
2877/// `one_hop(X) :- node(X), know edge(X,Y)`). After the modal is stripped, each such
2878/// variable would be an unsafe head column unless a positive-invariant modal carrying
2879/// it is resolved into a positive ordinary atom. Computed from the ORIGINAL rule,
2880/// before the head is mutated by augmentation.
2881fn modal_only_bound_output_variables(rule: &crate::ast::Rule) -> BTreeSet<String> {
2882    let positively_bound = non_epistemic_bound_variables(&rule.body);
2883
2884    // Candidate output variables: every variable occurring in the user-visible head
2885    // plus every modal-local variable (which augmentation will append to the head).
2886    let mut modal_only = BTreeSet::new();
2887    let mut consider = |name: &str| {
2888        if name != "_" && !positively_bound.contains(name) {
2889            modal_only.insert(name.to_string());
2890        }
2891    };
2892    for term in &rule.head.terms {
2893        if let Term::Variable(name) = term {
2894            consider(name);
2895        }
2896    }
2897    for lit in &rule.body {
2898        if let BodyLiteral::Epistemic(lit) = lit {
2899            for term in &lit.atom.terms {
2900                if let Term::Variable(name) = term {
2901                    consider(name);
2902                }
2903            }
2904        }
2905    }
2906    modal_only
2907}
2908
2909/// Whether `modal`'s atom carries at least one output variable that no positive
2910/// non-epistemic body literal binds (so resolving this positive-invariant modal into a
2911/// positive ordinary atom range-restricts an otherwise-unbound head column).
2912fn modal_atom_binds_output_variable(
2913    modal: &EpistemicLiteral,
2914    modal_only_output_variables: &BTreeSet<String>,
2915) -> bool {
2916    modal.atom.terms.iter().any(
2917        |term| matches!(term, Term::Variable(name) if modal_only_output_variables.contains(name)),
2918    )
2919}
2920
2921fn resolves_augmented_head_variable(
2922    modal: &EpistemicLiteral,
2923    modal_only_output_variables: &BTreeSet<String>,
2924    invariant: &InvariantRelations,
2925    schema_only_determined_resolve: &BTreeSet<String>,
2926) -> bool {
2927    !modal.negated
2928        && (invariant.is_invariant(&modal.atom.predicate)
2929            || schema_only_determined_resolve.contains(&modal.atom.predicate))
2930        && modal_atom_binds_output_variable(modal, modal_only_output_variables)
2931}
2932
2933fn record_predicate_signature(
2934    signatures: &mut BTreeMap<String, BTreeSet<usize>>,
2935    atom: &crate::ast::Atom,
2936) {
2937    signatures
2938        .entry(atom.predicate.clone())
2939        .or_default()
2940        .insert(atom.arity());
2941}
2942
2943fn record_body_predicate_signatures(
2944    signatures: &mut BTreeMap<String, BTreeSet<usize>>,
2945    body: &[BodyLiteral],
2946) {
2947    for literal in body {
2948        match literal {
2949            BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
2950                record_predicate_signature(signatures, atom);
2951            }
2952            BodyLiteral::Epistemic(modal) => {
2953                let eir_terms = modal
2954                    .atom
2955                    .terms
2956                    .iter()
2957                    .map(convert_term)
2958                    .collect::<Vec<_>>();
2959                // Structured-key validation owns the typed error for unsupported
2960                // shapes. Keep this identity census total for reducers that inspect
2961                // the source before lowering; valid keys use the exact same
2962                // production flattener as planning, while an invalid key retains its
2963                // source arity until validation rejects it.
2964                let arity = flatten_structured_key_terms(&modal.atom.predicate, &eir_terms)
2965                    .map(|(arity, _, _)| arity)
2966                    .unwrap_or_else(|_| modal.atom.arity());
2967                signatures
2968                    .entry(modal.atom.predicate.clone())
2969                    .or_default()
2970                    .insert(arity);
2971            }
2972            BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {}
2973        }
2974    }
2975}
2976
2977fn collect_epistemic_relation_identities(
2978    program: &Program,
2979    removed_rules: &BTreeSet<usize>,
2980) -> (BTreeMap<String, BTreeSet<usize>>, BTreeSet<String>) {
2981    let mut source_arities: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
2982    for declaration in &program.predicates {
2983        source_arities
2984            .entry(declaration.name.clone())
2985            .or_default()
2986            .insert(declaration.arity());
2987    }
2988
2989    let mut derived_predicates = BTreeSet::new();
2990    for (index, rule) in program.rules.iter().enumerate() {
2991        if removed_rules.contains(&index) {
2992            continue;
2993        }
2994        record_predicate_signature(&mut source_arities, &rule.head);
2995        record_body_predicate_signatures(&mut source_arities, &rule.body);
2996        if !rule.body.is_empty() {
2997            derived_predicates.insert(rule.head.predicate.clone());
2998        }
2999    }
3000    for constraint in &program.constraints {
3001        record_body_predicate_signatures(&mut source_arities, &constraint.body);
3002    }
3003    for query in &program.queries {
3004        record_predicate_signature(&mut source_arities, &query.atom);
3005    }
3006    for fact in &program.prob_facts {
3007        record_predicate_signature(&mut source_arities, &fact.atom);
3008    }
3009    for disjunction in &program.annotated_disjunctions {
3010        for choice in &disjunction.choices {
3011            record_predicate_signature(&mut source_arities, &choice.atom);
3012        }
3013    }
3014    for evidence in &program.evidence {
3015        record_predicate_signature(&mut source_arities, &evidence.atom);
3016    }
3017    for query in &program.prob_queries {
3018        record_predicate_signature(&mut source_arities, &query.atom);
3019    }
3020    for declaration in &program.neural_predicates {
3021        record_predicate_signature(&mut source_arities, &declaration.predicate);
3022    }
3023    for rule in &program.learnable_rules {
3024        record_predicate_signature(&mut source_arities, &rule.head);
3025        record_body_predicate_signatures(&mut source_arities, &rule.body);
3026        if !rule.body.is_empty() {
3027            derived_predicates.insert(rule.head.predicate.clone());
3028        }
3029    }
3030
3031    (source_arities, derived_predicates)
3032}
3033
3034fn all_multi_arity_predicates(program: &Program) -> BTreeSet<String> {
3035    collect_epistemic_relation_identities(program, &BTreeSet::new())
3036        .0
3037        .into_iter()
3038        .filter_map(|(predicate, arities)| (arities.len() > 1).then_some(predicate))
3039        .collect()
3040}
3041
3042fn qualify_atom_for_extensional_multi_arity(
3043    atom: &mut crate::ast::Atom,
3044    predicates: &BTreeSet<String>,
3045) {
3046    if predicates.contains(&atom.predicate) {
3047        atom.predicate = format!("{}/{}", atom.predicate, atom.arity());
3048    }
3049}
3050
3051fn qualify_body_for_extensional_multi_arity(
3052    body: &mut [BodyLiteral],
3053    predicates: &BTreeSet<String>,
3054) {
3055    for literal in body {
3056        match literal {
3057            BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
3058                qualify_atom_for_extensional_multi_arity(atom, predicates);
3059            }
3060            BodyLiteral::Epistemic(modal) => {
3061                qualify_atom_for_extensional_multi_arity(&mut modal.atom, predicates);
3062            }
3063            BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {}
3064        }
3065    }
3066}
3067
3068/// Return extensional predicate names whose runtime identity must include arity.
3069///
3070/// The census covers every source surface that can name a relation, including
3071/// constraints, probabilistic constructs, neural declarations, and learnable
3072/// rules. FAEEL rules removed as unfounded support do not make a predicate derived
3073/// or add a live signature. Reducers and fact upload must use this same set so a
3074/// source fact and its compiled scan always receive the same runtime name.
3075pub fn epistemic_extensional_multi_arity_predicates(program: &Program) -> BTreeSet<String> {
3076    let removed_rules = faeel_unfounded_exact_tuple_self_support_rule_indices(program)
3077        .into_iter()
3078        .collect::<BTreeSet<_>>();
3079    extensional_multi_arity_predicates(program, &removed_rules)
3080}
3081
3082fn extensional_multi_arity_predicates(
3083    program: &Program,
3084    removed_rules: &BTreeSet<usize>,
3085) -> BTreeSet<String> {
3086    let (source_arities, derived_predicates) =
3087        collect_epistemic_relation_identities(program, removed_rules);
3088    source_arities
3089        .into_iter()
3090        .filter_map(|(predicate, arities)| {
3091            (arities.len() > 1 && !derived_predicates.contains(&predicate)).then_some(predicate)
3092        })
3093        .collect()
3094}
3095
3096/// Give each extensional source signature the same arity-qualified runtime name
3097/// used by the GPU fact loader.
3098///
3099/// This transformation is limited to predicates with no active defining rule.
3100/// Derived predicates are validated separately because their public output and
3101/// recursive relation identity remain name-keyed.
3102fn qualify_extensional_multi_arity_predicates(
3103    reduced: &mut Program,
3104    source: &Program,
3105    removed_rules: &BTreeSet<usize>,
3106) {
3107    let predicates = extensional_multi_arity_predicates(source, removed_rules);
3108    qualify_predicate_signatures(reduced, &predicates);
3109}
3110
3111/// Apply canonical name-and-arity identities to every AST surface naming one of
3112/// `predicates`. Runtime reduction calls this only for extensional multi-arity names;
3113/// source validation calls it for every multi-arity name so each authored signature
3114/// retains its own declarations and clauses while being checked.
3115fn qualify_predicate_signatures(reduced: &mut Program, predicates: &BTreeSet<String>) {
3116    if predicates.is_empty() {
3117        return;
3118    }
3119
3120    for declaration in &mut reduced.predicates {
3121        if predicates.contains(&declaration.name) {
3122            declaration.name = format!("{}/{}", declaration.name, declaration.arity());
3123        }
3124    }
3125    for rule in &mut reduced.rules {
3126        qualify_atom_for_extensional_multi_arity(&mut rule.head, predicates);
3127        qualify_body_for_extensional_multi_arity(&mut rule.body, predicates);
3128    }
3129    for constraint in &mut reduced.constraints {
3130        qualify_body_for_extensional_multi_arity(&mut constraint.body, predicates);
3131    }
3132    for query in &mut reduced.queries {
3133        qualify_atom_for_extensional_multi_arity(&mut query.atom, predicates);
3134    }
3135    for fact in &mut reduced.prob_facts {
3136        qualify_atom_for_extensional_multi_arity(&mut fact.atom, predicates);
3137    }
3138    for disjunction in &mut reduced.annotated_disjunctions {
3139        for choice in &mut disjunction.choices {
3140            qualify_atom_for_extensional_multi_arity(&mut choice.atom, predicates);
3141        }
3142    }
3143    for evidence in &mut reduced.evidence {
3144        qualify_atom_for_extensional_multi_arity(&mut evidence.atom, predicates);
3145    }
3146    for query in &mut reduced.prob_queries {
3147        qualify_atom_for_extensional_multi_arity(&mut query.atom, predicates);
3148    }
3149    for declaration in &mut reduced.neural_predicates {
3150        qualify_atom_for_extensional_multi_arity(&mut declaration.predicate, predicates);
3151    }
3152    for rule in &mut reduced.learnable_rules {
3153        qualify_atom_for_extensional_multi_arity(&mut rule.head, predicates);
3154        qualify_body_for_extensional_multi_arity(&mut rule.body, predicates);
3155    }
3156}
3157
3158/// Validate the name-keyed runtime identity used for derived epistemic
3159/// relations and return every authored predicate signature.
3160///
3161/// Pure extensional predicates may use the same name at multiple arities because
3162/// their reduced runtime identities are arity-qualified. Once a predicate is
3163/// derived, however, the ordinary compiler and output materializer assign one
3164/// relation identity to its name. Every occurrence is included here so a query,
3165/// body atom, declaration, or auxiliary probabilistic construct cannot alias a
3166/// derived relation at a different arity.
3167fn validate_epistemic_derived_relation_identity(
3168    program: &Program,
3169    removed_rules: &BTreeSet<usize>,
3170) -> Result<BTreeMap<String, BTreeSet<usize>>> {
3171    let (source_arities, derived_predicates) =
3172        collect_epistemic_relation_identities(program, removed_rules);
3173    for predicate in derived_predicates {
3174        let arities = source_arities
3175            .get(&predicate)
3176            .expect("derived predicate has a source signature");
3177        if arities.len() > 1 {
3178            return Err(XlogError::UnsupportedEpistemicConstruct {
3179                construct: "epistemic derived predicate schema".to_string(),
3180                context: format!(
3181                    "derived predicate `{predicate}` uses multiple source arities {arities:?}; \
3182                     epistemic derived relations require one source signature per predicate name"
3183                ),
3184            });
3185        }
3186    }
3187
3188    Ok(source_arities)
3189}
3190
3191/// Ensure every clause for an augmented predicate signature lowers to one internal
3192/// relation arity.
3193///
3194/// A modal-local output variable adds hidden tuple-key columns to a rule head. If a
3195/// sibling clause for the same original signature produces a different number of
3196/// columns, the clauses cannot be unioned into one relation without inventing values
3197/// that the shorter clause does not bind. Reject that unsupported shape before any
3198/// reduced program reaches schema inference.
3199fn validate_epistemic_relation_shapes(
3200    program: &Program,
3201    non_augmenting_rule_indices: &BTreeSet<usize>,
3202) -> Result<()> {
3203    let removed_rules = faeel_unfounded_exact_tuple_self_support_rule_indices(program)
3204        .into_iter()
3205        .collect::<BTreeSet<_>>();
3206    let active_rules = program
3207        .rules
3208        .iter()
3209        .enumerate()
3210        .filter(|(index, _)| {
3211            !removed_rules.contains(index) && !non_augmenting_rule_indices.contains(index)
3212        })
3213        .collect::<Vec<_>>();
3214
3215    validate_epistemic_derived_relation_identity(program, &removed_rules)?;
3216
3217    let mut reduced_rule_arities = Vec::with_capacity(active_rules.len());
3218    let mut augmented_targets: BTreeMap<(String, usize), usize> = BTreeMap::new();
3219
3220    for (_, rule) in &active_rules {
3221        let original_signature = (rule.head.predicate.clone(), rule.head.arity());
3222        let mut reduced_rule = (*rule).clone();
3223        append_body_local_tuple_key_variables_to_head(&mut reduced_rule);
3224        let reduced_arity = reduced_rule.head.arity();
3225
3226        if reduced_arity > original_signature.1 {
3227            augmented_targets
3228                .entry(original_signature.clone())
3229                .and_modify(|target| *target = (*target).max(reduced_arity))
3230                .or_insert(reduced_arity);
3231        }
3232        reduced_rule_arities.push((original_signature, reduced_arity));
3233    }
3234
3235    for ((predicate, original_arity), target_arity) in augmented_targets {
3236        let arities = reduced_rule_arities
3237            .iter()
3238            .filter(|((candidate, arity), _)| candidate == &predicate && *arity == original_arity)
3239            .map(|(_, arity)| *arity)
3240            .collect::<BTreeSet<_>>();
3241        if arities.len() != 1 || !arities.contains(&target_arity) {
3242            return Err(XlogError::UnsupportedEpistemicConstruct {
3243                construct: "epistemic augmented predicate schema".to_string(),
3244                context: format!(
3245                    "rules defining `{predicate}/{original_arity}` lower to incompatible \
3246                     internal arities {arities:?}; every clause for one predicate signature \
3247                     must bind the same augmented tuple shape"
3248                ),
3249            });
3250        }
3251
3252        for query in program.queries.iter().filter(|query| {
3253            query.atom.predicate == predicate && query.atom.arity() == original_arity
3254        }) {
3255            let mut variables = BTreeSet::new();
3256            let unconstrained = query.atom.terms.iter().all(|term| match term {
3257                Term::Variable(name) => name != "_" && variables.insert(name.as_str()),
3258                Term::Anonymous
3259                | Term::Integer(_)
3260                | Term::Float(_)
3261                | Term::String(_)
3262                | Term::Symbol(_)
3263                | Term::List(_)
3264                | Term::Cons { .. }
3265                | Term::Compound { .. }
3266                | Term::PredRef(_)
3267                | Term::Aggregate(_) => false,
3268            });
3269            if !unconstrained {
3270                return Err(XlogError::UnsupportedEpistemicConstruct {
3271                    construct: "epistemic augmented head query".to_string(),
3272                    context: format!(
3273                        "query `{predicate}/{original_arity}` is not a tuple of distinct named \
3274                         variables; an augmented epistemic head can currently surface only \
3275                         queries whose arguments are distinct named variables"
3276                    ),
3277                });
3278            }
3279        }
3280    }
3281
3282    let eir = build_eir(program)?;
3283    let mut clauses_by_signature: BTreeMap<(String, usize), Vec<(usize, &crate::ast::Rule)>> =
3284        BTreeMap::new();
3285    for (rule_index, rule) in active_rules {
3286        clauses_by_signature
3287            .entry((rule.head.predicate.clone(), rule.head.arity()))
3288            .or_default()
3289            .push((rule_index, rule));
3290    }
3291    for ((predicate, arity), clauses) in clauses_by_signature {
3292        if clauses.len() > 1
3293            && clauses.iter().any(|(_, rule)| {
3294                rule.body
3295                    .iter()
3296                    .any(|literal| matches!(literal, BodyLiteral::Epistemic(_)))
3297            })
3298            && !epistemic_rule_union_gates_are_redundant(program, &eir, &clauses)
3299        {
3300            return Err(XlogError::UnsupportedEpistemicConstruct {
3301                construct: "epistemic rule-union materialization".to_string(),
3302                context: format!(
3303                    "predicate `{predicate}/{arity}` has multiple defining clauses and at least \
3304                     one epistemic clause; single-pass materialization cannot preserve \
3305                     per-clause modal provenance, so it cannot safely filter the clause union"
3306                ),
3307            });
3308        }
3309    }
3310
3311    Ok(())
3312}
3313
3314/// Prove that applying one clause's modal filters to an already-unioned relation
3315/// cannot remove rows contributed by its ordinary sibling clauses.
3316///
3317/// The single-pass materializer has no per-clause provenance. A multi-clause head is
3318/// therefore admitted only when either every clause has the same normalized modal
3319/// conjunction relative to its output columns, or there is exactly one epistemic
3320/// clause and every one of its modal gates is positive and provably true for every
3321/// candidate row:
3322///
3323/// - a ground atom has unconditional ordinary support from explicit facts/rules; or
3324/// - the modal atom is exactly a bijective all-variable clause head tuple and that
3325///   tuple has independent founded support under the clause's positive relational
3326///   domain; or
3327/// - G91 admits that same exact tuple under a positive `possible` self-support gate.
3328///
3329/// Equal conjunctions distribute over a union. The exact-head proofs are safe because
3330/// ordinary sibling clauses derive each head tuple directly, while the epistemic
3331/// clause's own rows are founded or explicitly self-supported under G91. Repeated
3332/// variables, constants, and wildcards are not bijective: they can map a sibling row
3333/// to a different modal key and therefore remain unsupported unless every clause has
3334/// the same normalized filter.
3335fn epistemic_rule_union_gates_are_redundant(
3336    program: &Program,
3337    eir: &EirProgram,
3338    clauses: &[(usize, &crate::ast::Rule)],
3339) -> bool {
3340    let invariant = InvariantRelations::analyze(program);
3341    let mut normalized_conjunctions = clauses.iter().map(|(rule_index, _)| {
3342        eir.rules
3343            .get(*rule_index)
3344            .and_then(|rule| normalized_rule_union_gates(rule, &invariant))
3345    });
3346    if let Some(Some(first)) = normalized_conjunctions.next() {
3347        if !first.is_empty()
3348            && normalized_conjunctions.all(|candidate| {
3349                candidate.is_some_and(|candidate| rule_union_gate_sets_equal(&first, &candidate))
3350            })
3351        {
3352            return true;
3353        }
3354    }
3355
3356    let epistemic_clauses = clauses
3357        .iter()
3358        .filter(|(_, rule)| {
3359            rule.body
3360                .iter()
3361                .any(|literal| matches!(literal, BodyLiteral::Epistemic(_)))
3362        })
3363        .collect::<Vec<_>>();
3364    if epistemic_clauses.is_empty() {
3365        return true;
3366    }
3367
3368    // Every modal filter is redundant when it is a positive ground atom with an
3369    // unconditional founded proof. This remains distributive even when several
3370    // sibling clauses use different ground modal atoms: every gate is true before
3371    // the clause outputs are unioned, so no per-clause provenance is needed later.
3372    let every_gate_is_unconditionally_true = epistemic_clauses.iter().all(|(rule_index, _)| {
3373        eir.rules.get(*rule_index).is_some_and(|eir_rule| {
3374            let modal_literals = eir_rule
3375                .body
3376                .iter()
3377                .filter_map(|literal| match literal {
3378                    EirBodyLiteral::Epistemic(modal) => Some(modal),
3379                    _ => None,
3380                })
3381                .collect::<Vec<_>>();
3382            !modal_literals.is_empty()
3383                && modal_literals.iter().all(|modal| {
3384                    !modal.negated && has_unconditional_ground_founded_support(eir, &modal.atom)
3385                })
3386        })
3387    });
3388    if every_gate_is_unconditionally_true {
3389        return true;
3390    }
3391
3392    if epistemic_clauses.len() != 1 {
3393        return false;
3394    }
3395
3396    let (rule_index, _) = epistemic_clauses[0];
3397    let Some(eir_rule) = eir.rules.get(*rule_index) else {
3398        return false;
3399    };
3400    let modal_literals = eir_rule
3401        .body
3402        .iter()
3403        .filter_map(|literal| match literal {
3404            EirBodyLiteral::Epistemic(modal) => Some(modal),
3405            _ => None,
3406        })
3407        .collect::<Vec<_>>();
3408
3409    !modal_literals.is_empty()
3410        && modal_literals.iter().all(|modal| {
3411            !modal.negated
3412                && (has_unconditional_ground_founded_support(eir, &modal.atom)
3413                    || (modal.atom == eir_rule.head
3414                        && eir_head_is_bijective_variable_tuple(&eir_rule.head)
3415                        && ((eir.mode == EirEpistemicMode::G91
3416                            && modal.op == EirEpistemicOp::Possible)
3417                            || has_tuple_level_independent_founded_support(
3418                                eir,
3419                                eir_rule,
3420                                &modal.atom,
3421                            ))))
3422        })
3423}
3424
3425#[derive(Debug, Clone, PartialEq, Eq)]
3426enum RuleUnionGateTerm {
3427    OutputColumn(usize),
3428    Literal(EirTerm),
3429}
3430
3431#[derive(Debug, Clone, PartialEq, Eq)]
3432struct RuleUnionGate {
3433    predicate: String,
3434    arity: usize,
3435    terms: Vec<RuleUnionGateTerm>,
3436    op: Option<EirEpistemicOp>,
3437    negated: bool,
3438}
3439
3440/// Normalize a clause's modal conjunction to the exact tuple-key binding that the
3441/// materializer applies. Variable names are replaced by output-column positions, and
3442/// `know`/`possible` are identified over invariant relations because those relations
3443/// have one fixed extension in every accepted world.
3444fn normalized_rule_union_gates(
3445    rule: &xlog_ir::EirRule,
3446    invariant: &InvariantRelations<'_>,
3447) -> Option<Vec<RuleUnionGate>> {
3448    let output_terms = augmented_eir_head_terms(rule);
3449    let mut gates = Vec::new();
3450    for literal in &rule.body {
3451        let EirBodyLiteral::Epistemic(modal) = literal else {
3452            continue;
3453        };
3454        let bound_columns = bound_output_columns_for_terms(&modal.atom.terms, &output_terms);
3455        let terms = modal
3456            .atom
3457            .terms
3458            .iter()
3459            .zip(bound_columns)
3460            .map(|(term, output_column)| match (term, output_column) {
3461                (EirTerm::Variable(_), Some(column)) => {
3462                    Some(RuleUnionGateTerm::OutputColumn(column))
3463                }
3464                (
3465                    term @ (EirTerm::Anonymous
3466                    | EirTerm::Integer(_)
3467                    | EirTerm::FloatBits(_)
3468                    | EirTerm::String(_)
3469                    | EirTerm::Symbol(_)
3470                    | EirTerm::PredRef(_)),
3471                    None,
3472                ) => Some(RuleUnionGateTerm::Literal(term.clone())),
3473                _ => None,
3474            })
3475            .collect::<Option<Vec<_>>>()?;
3476        let gate = RuleUnionGate {
3477            predicate: modal.atom.predicate.clone(),
3478            arity: modal.atom.arity,
3479            terms,
3480            op: (!invariant.is_invariant(&modal.atom.predicate)).then_some(modal.op),
3481            negated: modal.negated,
3482        };
3483        if !gates.contains(&gate) {
3484            gates.push(gate);
3485        }
3486    }
3487    Some(gates)
3488}
3489
3490fn rule_union_gate_sets_equal(left: &[RuleUnionGate], right: &[RuleUnionGate]) -> bool {
3491    left.len() == right.len() && left.iter().all(|gate| right.contains(gate))
3492}
3493
3494fn eir_head_is_bijective_variable_tuple(head: &xlog_ir::EirAtom) -> bool {
3495    let mut variables = BTreeSet::new();
3496    head.terms.iter().all(|term| match term {
3497        EirTerm::Variable(name) => variables.insert(name),
3498        _ => false,
3499    })
3500}
3501
3502/// Widen each predicate's declaration to the maximum arity of its (now possibly
3503/// augmented) defining rule heads, inferring appended column types from the positive
3504/// body atoms that bind the augmented head variables.
3505///
3506/// Augmentation appends modal-local columns to a rule head; without widening the
3507/// matching `PredDecl`, the runtime would union the augmented rule output against the
3508/// narrow declared (empty) relation stub and fail with a schema mismatch.
3509///
3510/// Only rules in `augmented_rule_original_arities` are reconciled. Original arity is
3511/// retained separately because augmentation has already changed the rule head by the
3512/// time reconciliation runs.
3513///
3514/// Returns each original predicate signature that was augmented and its resulting
3515/// arity, whether or not that signature has an explicit declaration.
3516fn reconcile_augmented_head_declarations(
3517    reduced: &mut Program,
3518    augmented_rule_original_arities: &BTreeMap<usize, usize>,
3519) -> Result<BTreeMap<(String, usize), usize>> {
3520    use crate::ast::{PredColumn, TypeRef};
3521
3522    // Per original head signature: the maximum augmented rule-head arity and, per
3523    // column position, an inferred type from a positive body atom (the resolved modal
3524    // or any binder).
3525    let mut augmented_signatures: BTreeMap<(String, usize), usize> = BTreeMap::new();
3526    let mut inferred_types: BTreeMap<(String, usize), Vec<Option<TypeRef>>> = BTreeMap::new();
3527
3528    // Use the ordinary lowerer's fixed-point schema inference after extensional
3529    // multi-arity names have been qualified. This is the same source of truth used
3530    // by production compilation, so undeclared facts, transitive rule chains,
3531    // declarations, domains, and arithmetic bindings all contribute their real
3532    // scalar types instead of falling through to a guessed hidden-column type.
3533    let mut lowerer = Lowerer::new();
3534    lowerer.infer_schemas(reduced)?;
3535    let schemas = lowerer.schemas().clone();
3536
3537    for (rule_index, rule) in reduced.rules.iter().enumerate() {
3538        if rule.body.is_empty() {
3539            continue;
3540        }
3541        // Only rules where the invariant-resolve genuinely fired are reconciled.
3542        let Some(&original_arity) = augmented_rule_original_arities.get(&rule_index) else {
3543            continue;
3544        };
3545        let arity = rule.head.terms.len();
3546        if arity <= original_arity {
3547            continue;
3548        }
3549        let signature = (rule.head.predicate.clone(), original_arity);
3550        let entry = augmented_signatures.entry(signature.clone()).or_insert(0);
3551        if arity > *entry {
3552            *entry = arity;
3553        }
3554        let types = inferred_types
3555            .entry(signature)
3556            .or_insert_with(|| vec![None; arity]);
3557        if types.len() < arity {
3558            types.resize(arity, None);
3559        }
3560        let variable_types = lowerer.infer_rule_variable_types(rule, |atom, index| {
3561            schemas
3562                .get(&atom.predicate)
3563                .and_then(|schema| schema.column_type(index))
3564        })?;
3565
3566        // Infer each head variable's type from every binding form understood by
3567        // ordinary lowering, including body atoms and deterministic `is` results.
3568        for (col, term) in rule.head.terms.iter().enumerate() {
3569            if types[col].is_some() {
3570                continue;
3571            }
3572            let Term::Variable(head_var) = term else {
3573                continue;
3574            };
3575            if let Some((typ, _)) = variable_types.get(head_var) {
3576                types[col] = Some(TypeRef::Scalar(*typ));
3577            }
3578        }
3579    }
3580
3581    for decl in &mut reduced.predicates {
3582        let signature = (decl.name.clone(), decl.arity());
3583        let Some(&target_arity) = augmented_signatures.get(&signature) else {
3584            continue;
3585        };
3586        let mut columns = decl.schema_columns();
3587        if target_arity <= columns.len() {
3588            continue;
3589        }
3590        let inferred = inferred_types.get(&signature);
3591        for col in columns.len()..target_arity {
3592            let typ = inferred
3593                .and_then(|types| types.get(col))
3594                .and_then(|t| t.clone())
3595                // Default appended columns to U32 (the modal relation key column type).
3596                .unwrap_or(TypeRef::Scalar(xlog_core::ScalarType::U32));
3597            columns.push(PredColumn { name: None, typ });
3598        }
3599        decl.types = columns.iter().map(|column| column.typ.clone()).collect();
3600        decl.columns = columns;
3601    }
3602
3603    Ok(augmented_signatures)
3604}
3605
3606fn append_body_local_tuple_key_variables_to_head(rule: &mut crate::ast::Rule) {
3607    let mut hidden_variables = Vec::new();
3608    for lit in &rule.body {
3609        let BodyLiteral::Epistemic(lit) = lit else {
3610            continue;
3611        };
3612        for term in &lit.atom.terms {
3613            let Term::Variable(variable) = term else {
3614                continue;
3615            };
3616            if variable == "_" {
3617                continue;
3618            }
3619            let already_in_head = rule
3620                .head
3621                .terms
3622                .iter()
3623                .any(|head_term| matches!(head_term, Term::Variable(name) if name == variable));
3624            if !already_in_head && !hidden_variables.iter().any(|name| name == variable) {
3625                hidden_variables.push(variable.clone());
3626            }
3627        }
3628    }
3629    for variable in hidden_variables {
3630        rule.head.terms.push(Term::Variable(variable));
3631    }
3632}
3633
3634fn wcoj_status_for_reduction(
3635    positive_relational_atoms: &[xlog_ir::EirAtom],
3636    has_negated_relational_atom: bool,
3637) -> EpistemicWcojReductionStatus {
3638    if !has_negated_relational_atom
3639        && positive_relational_atoms_are_supported_wcoj_shape(positive_relational_atoms)
3640    {
3641        EpistemicWcojReductionStatus::RequiresPlannerEligibility
3642    } else {
3643        EpistemicWcojReductionStatus::NotWcojCandidate
3644    }
3645}
3646
3647fn positive_relational_atoms_are_supported_wcoj_shape(atoms: &[xlog_ir::EirAtom]) -> bool {
3648    let mut edges: BTreeSet<(String, String)> = BTreeSet::new();
3649    let mut degrees: BTreeMap<String, usize> = BTreeMap::new();
3650    for atom in atoms {
3651        if atom.arity != 2 || atom.terms.len() != 2 {
3652            return false;
3653        }
3654        let Some(left) = eir_variable_name(&atom.terms[0]) else {
3655            return false;
3656        };
3657        let Some(right) = eir_variable_name(&atom.terms[1]) else {
3658            return false;
3659        };
3660        if left == right {
3661            return false;
3662        }
3663        let edge = if left < right {
3664            (left.to_string(), right.to_string())
3665        } else {
3666            (right.to_string(), left.to_string())
3667        };
3668        if !edges.insert(edge.clone()) {
3669            return false;
3670        }
3671        *degrees.entry(edge.0).or_insert(0) += 1;
3672        *degrees.entry(edge.1).or_insert(0) += 1;
3673    }
3674
3675    match edges.len() {
3676        3 => degrees.len() == 3 && degrees.values().all(|degree| *degree == 2),
3677        4 => degrees.len() == 4 && degrees.values().all(|degree| *degree == 2),
3678        10 | 15 | 21 | 28 => {
3679            let variable_count = degrees.len();
3680            (5..=8).contains(&variable_count)
3681                && edges.len() == variable_count * (variable_count - 1) / 2
3682                && degrees.values().all(|degree| *degree == variable_count - 1)
3683        }
3684        _ => false,
3685    }
3686}
3687
3688fn eir_variable_name(term: &EirTerm) -> Option<&str> {
3689    match term {
3690        EirTerm::Variable(name) => Some(name.as_str()),
3691        _ => None,
3692    }
3693}
3694
3695/// Result of bounded FAEEL candidate evaluation.
3696#[derive(Debug, Clone, PartialEq, Eq)]
3697pub enum FaeelCandidateResult {
3698    /// Candidate satisfies the bounded FAEEL fixture semantics.
3699    Model,
3700    /// Candidate has no model for a typed reason.
3701    NoModel(FaeelNoModelReason),
3702}
3703
3704/// Typed no-model reason for bounded FAEEL fixtures.
3705#[derive(Debug, Clone, PartialEq, Eq)]
3706pub enum FaeelNoModelReason {
3707    /// Candidate uses possible-only support where FAEEL requires founded knowledge.
3708    UnfoundedPossible {
3709        /// Predicate name.
3710        predicate: String,
3711        /// Predicate arity.
3712        arity: usize,
3713    },
3714    /// Candidate marks the same atom known and rejected.
3715    Contradiction {
3716        /// Predicate name.
3717        predicate: String,
3718        /// Predicate arity.
3719        arity: usize,
3720    },
3721    /// An epistemic literal is unsatisfied by the candidate.
3722    UnsatisfiedLiteral {
3723        /// Predicate name.
3724        predicate: String,
3725        /// Predicate arity.
3726        arity: usize,
3727    },
3728}
3729
3730/// Configuration for bounded Generate-Propagate-Test fixture execution.
3731#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3732pub struct GeneratePropagateTestConfig {
3733    /// Maximum candidate count accepted by the generate phase.
3734    pub max_candidates: usize,
3735}
3736
3737/// Phase counters emitted by bounded Generate-Propagate-Test execution.
3738#[derive(Debug, Clone, Default, PartialEq, Eq)]
3739pub struct GeneratePropagateTestTrace {
3740    /// Number of generated candidates.
3741    pub generated: usize,
3742    /// Number of epistemic guesses generated.
3743    pub guesses: usize,
3744    /// Number of candidates that survived propagation.
3745    pub propagated: usize,
3746    /// Number of candidates pruned during propagation.
3747    pub pruned: usize,
3748    /// Number of reduced-program models inspected by the test phase.
3749    pub reduced_program_models: usize,
3750    /// Number of candidates tested.
3751    pub tested: usize,
3752    /// Number of accepted candidates.
3753    pub accepted: usize,
3754    /// Number of accepted world views.
3755    pub accepted_world_views: usize,
3756    /// Number of rejected candidates.
3757    pub rejected: usize,
3758    /// Rejection reasons observed during propagation and testing.
3759    pub rejection_reasons: Vec<FaeelNoModelReason>,
3760}
3761
3762/// Result of bounded Generate-Propagate-Test fixture execution.
3763#[derive(Debug, Clone, PartialEq, Eq)]
3764pub struct GeneratePropagateTestOutcome {
3765    /// Phase counts.
3766    pub trace: GeneratePropagateTestTrace,
3767    /// Original indices of accepted candidates.
3768    pub accepted_candidate_indices: Vec<usize>,
3769    /// Original indices of rejected candidates in rejection-reason order.
3770    pub rejected_candidate_indices: Vec<usize>,
3771}
3772
3773/// Reason that two source rules were coalesced into the same dependency component.
3774///
3775/// These reasons make the split planner's structural decisions explainable: a
3776/// caller can read, for every component, *why* its rules could not be solved
3777/// independently of one another (K3 split diagnostics).
3778#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
3779pub enum EpistemicComponentMergeReason {
3780    /// Two rules share the same head predicate, so they jointly define one
3781    /// derived relation and must be solved together.
3782    SharedHeadPredicate {
3783        /// Head predicate defined by both rules.
3784        predicate: String,
3785    },
3786    /// One rule's body consumes a predicate that another rule derives in its
3787    /// head (an ordinary/negated derived dependency).
3788    DerivedPredicate {
3789        /// Head predicate produced by the producer rule and consumed by the
3790        /// consumer rule body.
3791        predicate: String,
3792    },
3793    /// Two rules reference the same epistemic (modal) predicate, so their
3794    /// world-view acceptance is mutually dependent.
3795    SharedModalPredicate {
3796        /// Epistemic predicate referenced by both rules, with arity.
3797        predicate: String,
3798    },
3799    /// An integrity constraint mentions head predicates owned by both rules, so
3800    /// the constraint coalesces exactly those components.
3801    Constraint {
3802        /// Constraint-mentioned head predicates that forced the coalesce.
3803        predicates: Vec<String>,
3804    },
3805}
3806
3807/// One deterministic dependency component for epistemic splitting.
3808#[derive(Debug, Clone, PartialEq, Eq)]
3809pub struct EpistemicDependencyComponent {
3810    /// Sorted predicate names in the component.
3811    pub predicates: Vec<String>,
3812    /// Source rule indices owned by the component.
3813    pub rule_indices: Vec<usize>,
3814    /// Sorted, deduplicated reasons the component's rules were coalesced.
3815    ///
3816    /// Empty when the component is a single independent rule that no
3817    /// dependency forced together (it was split out on its own).
3818    pub merge_reasons: Vec<EpistemicComponentMergeReason>,
3819}
3820
3821/// Deterministic dependency graph used by bounded epistemic splitting.
3822#[derive(Debug, Clone, PartialEq, Eq)]
3823pub struct EpistemicDependencyGraph {
3824    /// Sorted components.
3825    pub components: Vec<EpistemicDependencyComponent>,
3826}
3827
3828/// Split plan for independently solvable epistemic components.
3829#[derive(Debug, Clone, PartialEq, Eq)]
3830pub struct EpistemicSplitPlan {
3831    /// Components to solve independently.
3832    pub components: Vec<EpistemicDependencyComponent>,
3833}
3834
3835impl EpistemicSplitPlan {
3836    /// Return the original rule order recovered from all components.
3837    pub fn recomposed_rule_indices(&self) -> Vec<usize> {
3838        let mut indices: Vec<usize> = self
3839            .components
3840            .iter()
3841            .flat_map(|component| component.rule_indices.iter().copied())
3842            .collect();
3843        indices.sort_unstable();
3844        indices
3845    }
3846}
3847
3848/// One split component lowered through the production epistemic GPU plan path.
3849#[derive(Debug, Clone)]
3850pub struct EpistemicSplitExecutableComponent {
3851    /// Source dependency component covered by this executable subplan.
3852    pub component: EpistemicDependencyComponent,
3853    /// GPU contract plus reduced runtime plan for this component.
3854    pub executable: EpistemicExecutablePlan,
3855}
3856
3857/// Executable split plan whose components reuse the normal epistemic GPU lowering.
3858#[derive(Debug, Clone)]
3859pub struct EpistemicSplitExecutablePlan {
3860    /// Original bounded split plan.
3861    pub split_plan: EpistemicSplitPlan,
3862    /// Epistemic components compiled into GPU executable subplans.
3863    pub components: Vec<EpistemicSplitExecutableComponent>,
3864}
3865
3866impl EpistemicSplitExecutablePlan {
3867    /// Return the source rule indices actually recomposed by GPU split execution.
3868    ///
3869    /// This reflects the rules the *executable* plan runs: epistemic-bearing
3870    /// components only. Pure-ordinary independent components carry no epistemic
3871    /// output buffer and are not part of the epistemic execution surface, so
3872    /// they are intentionally excluded here. The full dependency-graph view
3873    /// (including non-executed ordinary components) lives on
3874    /// [`EpistemicSplitPlan::recomposed_rule_indices`]; the two coincide exactly
3875    /// when every component is epistemic-bearing.
3876    pub fn recomposed_rule_indices(&self) -> Vec<usize> {
3877        let mut indices: Vec<usize> = self
3878            .components
3879            .iter()
3880            .flat_map(|component| component.component.rule_indices.iter().copied())
3881            .collect();
3882        indices.sort_unstable();
3883        indices
3884    }
3885
3886    /// Return the full dependency-graph recomposition view, including
3887    /// independent non-epistemic components that the executable plan does not run.
3888    pub fn planned_recomposed_rule_indices(&self) -> Vec<usize> {
3889        self.split_plan.recomposed_rule_indices()
3890    }
3891
3892    /// Return executable components ordered by the first source rule they cover.
3893    pub fn recomposed_components(&self) -> Vec<&EpistemicSplitExecutableComponent> {
3894        let mut components: Vec<_> = self.components.iter().collect();
3895        components.sort_by_key(|component| {
3896            component
3897                .component
3898                .rule_indices
3899                .iter()
3900                .copied()
3901                .min()
3902                .unwrap_or(usize::MAX)
3903        });
3904        components
3905    }
3906}
3907
3908/// Evaluate a single parsed epistemic literal against a bounded interpretation.
3909pub fn evaluate_epistemic_literal(
3910    mode: EpistemicMode,
3911    lit: &EpistemicLiteral,
3912    interpretation: &EpistemicInterpretation,
3913) -> TruthValue {
3914    let value = match lit.op {
3915        EpistemicOp::Know => interpretation.contains_known(&lit.atom),
3916        EpistemicOp::Possible => match mode {
3917            EpistemicMode::G91 => {
3918                interpretation.contains_known(&lit.atom)
3919                    || interpretation.contains_possible(&lit.atom)
3920            }
3921            EpistemicMode::Faeel => interpretation.contains_known(&lit.atom),
3922        },
3923    };
3924
3925    TruthValue::from_bool(if lit.negated { !value } else { value })
3926}
3927
3928/// Evaluate all epistemic literals in a program under bounded FAEEL fixture semantics.
3929pub fn evaluate_faeel_candidate(
3930    program: &Program,
3931    interpretation: &EpistemicInterpretation,
3932) -> Result<FaeelCandidateResult> {
3933    evaluate_epistemic_candidate(program, interpretation, EpistemicMode::Faeel)
3934}
3935
3936/// Evaluate all epistemic literals in a program under a bounded fixture semantics mode.
3937pub fn evaluate_epistemic_candidate(
3938    program: &Program,
3939    interpretation: &EpistemicInterpretation,
3940    mode: EpistemicMode,
3941) -> Result<FaeelCandidateResult> {
3942    reject_gpt_epistemic_constraints(program)?;
3943    if let Some((predicate, arity)) = interpretation.first_contradiction() {
3944        return Ok(FaeelCandidateResult::NoModel(
3945            FaeelNoModelReason::Contradiction { predicate, arity },
3946        ));
3947    }
3948
3949    for rule in &program.rules {
3950        for body_lit in &rule.body {
3951            let BodyLiteral::Epistemic(lit) = body_lit else {
3952                continue;
3953            };
3954            if interpretation.contains_known(&lit.atom)
3955                && interpretation.contains_rejected(&lit.atom)
3956            {
3957                return Ok(FaeelCandidateResult::NoModel(
3958                    FaeelNoModelReason::Contradiction {
3959                        predicate: lit.atom.predicate.clone(),
3960                        arity: lit.atom.arity(),
3961                    },
3962                ));
3963            }
3964            if mode == EpistemicMode::Faeel
3965                && lit.op == EpistemicOp::Possible
3966                && interpretation.contains_possible(&lit.atom)
3967                && !interpretation.contains_known(&lit.atom)
3968            {
3969                return Ok(FaeelCandidateResult::NoModel(
3970                    FaeelNoModelReason::UnfoundedPossible {
3971                        predicate: lit.atom.predicate.clone(),
3972                        arity: lit.atom.arity(),
3973                    },
3974                ));
3975            }
3976            if evaluate_epistemic_literal(mode, lit, interpretation) == TruthValue::False {
3977                return Ok(FaeelCandidateResult::NoModel(
3978                    FaeelNoModelReason::UnsatisfiedLiteral {
3979                        predicate: lit.atom.predicate.clone(),
3980                        arity: lit.atom.arity(),
3981                    },
3982                ));
3983            }
3984        }
3985    }
3986
3987    Ok(FaeelCandidateResult::Model)
3988}
3989
3990/// Run bounded Generate-Propagate-Test execution over explicit candidates.
3991pub fn run_generate_propagate_test(
3992    program: &Program,
3993    candidates: Vec<EpistemicInterpretation>,
3994    config: GeneratePropagateTestConfig,
3995) -> Result<GeneratePropagateTestOutcome> {
3996    run_generate_propagate_test_with_mode(
3997        program,
3998        candidates,
3999        config,
4000        program.directives.epistemic_mode_or_default(),
4001    )
4002}
4003
4004/// Run bounded Generate-Propagate-Test execution over explicit candidates and semantics mode.
4005pub fn run_generate_propagate_test_with_mode(
4006    program: &Program,
4007    candidates: Vec<EpistemicInterpretation>,
4008    config: GeneratePropagateTestConfig,
4009    mode: EpistemicMode,
4010) -> Result<GeneratePropagateTestOutcome> {
4011    reject_gpt_epistemic_constraints(program)?;
4012    if candidates.len() > config.max_candidates {
4013        return Err(xlog_core::XlogError::ResourceExhausted {
4014            context: "epistemic GPT candidate guard".to_string(),
4015            estimated_bytes: candidates.len() as u64,
4016            budget_bytes: config.max_candidates as u64,
4017        });
4018    }
4019
4020    let generated = candidates.len();
4021    let guesses = candidates
4022        .iter()
4023        .map(EpistemicInterpretation::epistemic_guess_count)
4024        .sum();
4025    let mut propagated_candidates = Vec::new();
4026    let mut rejection_reasons = Vec::new();
4027    let mut rejected_candidate_indices = Vec::new();
4028    for (idx, candidate) in candidates.into_iter().enumerate() {
4029        if let Some((predicate, arity)) = candidate.first_contradiction() {
4030            rejection_reasons.push(FaeelNoModelReason::Contradiction { predicate, arity });
4031            rejected_candidate_indices.push(idx);
4032        } else {
4033            propagated_candidates.push((idx, candidate));
4034        }
4035    }
4036
4037    let mut trace = GeneratePropagateTestTrace {
4038        generated,
4039        guesses,
4040        propagated: propagated_candidates.len(),
4041        pruned: generated.saturating_sub(propagated_candidates.len()),
4042        reduced_program_models: propagated_candidates.len(),
4043        rejection_reasons,
4044        ..GeneratePropagateTestTrace::default()
4045    };
4046    let mut accepted_candidate_indices = Vec::new();
4047
4048    for (idx, candidate) in &propagated_candidates {
4049        trace.tested += 1;
4050        match evaluate_epistemic_candidate(program, candidate, mode)? {
4051            FaeelCandidateResult::Model => {
4052                trace.accepted += 1;
4053                trace.accepted_world_views += 1;
4054                accepted_candidate_indices.push(*idx);
4055            }
4056            FaeelCandidateResult::NoModel(reason) => {
4057                trace.rejected += 1;
4058                trace.rejection_reasons.push(reason);
4059                rejected_candidate_indices.push(*idx);
4060            }
4061        }
4062    }
4063
4064    Ok(GeneratePropagateTestOutcome {
4065        trace,
4066        accepted_candidate_indices,
4067        rejected_candidate_indices,
4068    })
4069}
4070
4071/// Build a deterministic dependency graph for bounded epistemic splitting.
4072pub fn build_epistemic_dependency_graph(program: &Program) -> Result<EpistemicDependencyGraph> {
4073    if program.rules.is_empty() {
4074        return Ok(EpistemicDependencyGraph { components: vec![] });
4075    }
4076
4077    let mut parents: Vec<usize> = (0..program.rules.len()).collect();
4078    let mut rule_predicates = Vec::with_capacity(program.rules.len());
4079    let mut head_owner: BTreeMap<String, usize> = BTreeMap::new();
4080    // Each merge records (one source rule index touched by the merge, reason).
4081    // After roots collapse, reasons are attributed to the surviving root so the
4082    // emitted component carries an explainable account of why it was coalesced.
4083    let mut merge_log: Vec<(usize, EpistemicComponentMergeReason)> = Vec::new();
4084
4085    for (idx, rule) in program.rules.iter().enumerate() {
4086        if rule.body.is_empty() {
4087            continue;
4088        }
4089        if let Some(owner) = head_owner.get(&rule.head.predicate).copied() {
4090            union_components(&mut parents, owner, idx);
4091            merge_log.push((
4092                idx,
4093                EpistemicComponentMergeReason::SharedHeadPredicate {
4094                    predicate: rule.head.predicate.clone(),
4095                },
4096            ));
4097        } else {
4098            head_owner.insert(rule.head.predicate.clone(), idx);
4099        }
4100    }
4101
4102    let mut modal_owner: BTreeMap<EpistemicAtomKey, usize> = BTreeMap::new();
4103    for (idx, rule) in program.rules.iter().enumerate() {
4104        let mut predicates = BTreeSet::new();
4105        predicates.insert(rule.head.predicate.clone());
4106        for lit in &rule.body {
4107            if let BodyLiteral::Epistemic(lit) = lit {
4108                let key =
4109                    EpistemicAtomKey::from_arity(lit.atom.predicate.clone(), lit.atom.arity());
4110                if let Some(owner) = modal_owner.get(&key).copied() {
4111                    union_components(&mut parents, owner, idx);
4112                    merge_log.push((
4113                        idx,
4114                        EpistemicComponentMergeReason::SharedModalPredicate {
4115                            predicate: format!("{}/{}", lit.atom.predicate, lit.atom.arity()),
4116                        },
4117                    ));
4118                } else {
4119                    modal_owner.insert(key, idx);
4120                }
4121            }
4122            if let Some(atom) = lit.atom() {
4123                if let Some(owner) = head_owner.get(&atom.predicate).copied() {
4124                    if owner != idx {
4125                        union_components(&mut parents, owner, idx);
4126                        merge_log.push((
4127                            idx,
4128                            EpistemicComponentMergeReason::DerivedPredicate {
4129                                predicate: atom.predicate.clone(),
4130                            },
4131                        ));
4132                    }
4133                }
4134                predicates.insert(atom.predicate.clone());
4135            }
4136        }
4137
4138        rule_predicates.push(predicates);
4139    }
4140
4141    let mut constraint_predicates = Vec::with_capacity(program.constraints.len());
4142    for constraint in &program.constraints {
4143        let predicates = constraint_predicate_set(constraint);
4144        let mut owners = predicates
4145            .iter()
4146            .filter_map(|predicate| head_owner.get(predicate).copied());
4147        if let Some(first_owner) = owners.next() {
4148            let mut coalesced_any = false;
4149            for owner in owners {
4150                if find_component(&mut parents, first_owner) != find_component(&mut parents, owner)
4151                {
4152                    coalesced_any = true;
4153                }
4154                union_components(&mut parents, first_owner, owner);
4155            }
4156            if coalesced_any {
4157                let constraint_heads: Vec<String> = predicates
4158                    .iter()
4159                    .filter(|predicate| head_owner.contains_key(*predicate))
4160                    .cloned()
4161                    .collect();
4162                merge_log.push((
4163                    first_owner,
4164                    EpistemicComponentMergeReason::Constraint {
4165                        predicates: constraint_heads,
4166                    },
4167                ));
4168            }
4169        }
4170        constraint_predicates.push(predicates);
4171    }
4172
4173    let mut grouped: BTreeMap<usize, (BTreeSet<String>, Vec<usize>)> = BTreeMap::new();
4174    for (idx, predicates) in rule_predicates.into_iter().enumerate() {
4175        let root = find_component(&mut parents, idx);
4176        let entry = grouped
4177            .entry(root)
4178            .or_insert_with(|| (BTreeSet::new(), vec![]));
4179        entry.0.extend(predicates);
4180        entry.1.push(idx);
4181    }
4182    for predicates in constraint_predicates {
4183        let Some(root) = predicates
4184            .iter()
4185            .filter_map(|predicate| head_owner.get(predicate).copied())
4186            .map(|idx| find_component(&mut parents, idx))
4187            .next()
4188        else {
4189            continue;
4190        };
4191        grouped
4192            .entry(root)
4193            .or_insert_with(|| (BTreeSet::new(), vec![]))
4194            .0
4195            .extend(predicates);
4196    }
4197
4198    // Attribute every recorded merge reason to its surviving component root.
4199    let mut reasons_by_root: BTreeMap<usize, BTreeSet<EpistemicComponentMergeReason>> =
4200        BTreeMap::new();
4201    for (touched_idx, reason) in merge_log {
4202        let root = find_component(&mut parents, touched_idx);
4203        reasons_by_root.entry(root).or_default().insert(reason);
4204    }
4205
4206    let mut components: Vec<EpistemicDependencyComponent> = grouped
4207        .into_iter()
4208        .map(|(root, (predicates, mut rule_indices))| {
4209            rule_indices.sort_unstable();
4210            let merge_reasons = reasons_by_root
4211                .remove(&root)
4212                .map(|reasons| reasons.into_iter().collect())
4213                .unwrap_or_default();
4214            EpistemicDependencyComponent {
4215                predicates: predicates.into_iter().collect(),
4216                rule_indices,
4217                merge_reasons,
4218            }
4219        })
4220        .collect();
4221    components.sort_by(|a, b| a.predicates.cmp(&b.predicates));
4222    Ok(EpistemicDependencyGraph { components })
4223}
4224
4225fn constraint_predicate_set(constraint: &Constraint) -> BTreeSet<String> {
4226    constraint
4227        .body
4228        .iter()
4229        .filter_map(|lit| lit.atom().map(|atom| atom.predicate.clone()))
4230        .collect()
4231}
4232
4233fn find_component(parents: &mut [usize], idx: usize) -> usize {
4234    if parents[idx] != idx {
4235        let root = find_component(parents, parents[idx]);
4236        parents[idx] = root;
4237    }
4238    parents[idx]
4239}
4240
4241fn union_components(parents: &mut [usize], left: usize, right: usize) {
4242    let left_root = find_component(parents, left);
4243    let right_root = find_component(parents, right);
4244    if left_root != right_root {
4245        parents[right_root] = left_root;
4246    }
4247}
4248
4249/// Split an epistemic program into independently solvable bounded components.
4250/// One stratum of a stratified epistemic program: a self-contained sub-program
4251/// whose epistemic heads gate only over EDB/invariant relations OR over the
4252/// materialized (now-base) outputs of strictly-lower strata.
4253#[derive(Debug, Clone)]
4254pub struct EpistemicStratum {
4255    /// The epistemic output head predicate(s) this stratum materializes.
4256    pub head_predicates: Vec<String>,
4257    /// Source-rule indices owned by this stratum.
4258    pub rule_indices: Vec<usize>,
4259    /// The self-contained sub-program for this stratum (its own defining rules
4260    /// plus the facts/EDB it needs). Lower-stratum heads are NOT redefined here;
4261    /// at execution they are present in the store as materialized base relations.
4262    pub program: Program,
4263}
4264
4265/// A stratified epistemic execution plan: an ordered sequence of strata.
4266///
4267/// Stratum `i`'s epistemic heads are materialized (gated) into the relation store
4268/// BEFORE stratum `i+1` runs, so a higher stratum's `know`/`possible` over a
4269/// lower stratum's head reads the GATED extension through the EXISTING
4270/// membership filter (no resolve-into-body, no double-gating).
4271#[derive(Debug, Clone)]
4272pub struct EpistemicStratifiedPlan {
4273    /// Strata in execution (topological) order.
4274    pub strata: Vec<EpistemicStratum>,
4275    /// One prepared ordinary closure and constraint stage executed after every
4276    /// stratum has materialized its gated heads.
4277    pub ordinary_post_program: Program,
4278}
4279
4280/// Predicates whose epistemic extension is DETERMINED once lower strata are fixed.
4281///
4282/// A predicate is *epistemically determined* when every defining rule uses only
4283/// (a) positive `know`/`possible` modals and ordinary positive/negated literals,
4284/// (b) all ranging over predicates that are themselves invariant (EDB/lower
4285/// non-epistemic stratum) OR already epistemically determined, and (c) the
4286/// dependency is acyclic through BOTH modal and ordinary edges. Such a head's
4287/// materialized (gated) extension IS its truth, so it can be materialized into the
4288/// store as a base relation and a higher stratum can gate against it.
4289///
4290/// This is a STANDALONE analysis: it never feeds
4291/// [`reduce_case_a_epistemic_program_to_ordinary`] / `is_invariant`, so it cannot
4292/// trigger the resolve-into-body double-gating that the single-pass GPU filter
4293/// already performs.
4294struct EpistemicallyDeterminedPredicates {
4295    determined: BTreeSet<String>,
4296}
4297
4298impl EpistemicallyDeterminedPredicates {
4299    fn analyze(program: &Program) -> Self {
4300        let invariant = InvariantRelations::analyze(program);
4301
4302        // Heads defined by at least one rule.
4303        let mut derived_heads: BTreeSet<&str> = BTreeSet::new();
4304        for rule in &program.rules {
4305            if !rule.body.is_empty() {
4306                derived_heads.insert(rule.head.predicate.as_str());
4307            }
4308        }
4309
4310        // Least-fixpoint closure over ALL derived heads (epistemic AND ordinary): a
4311        // predicate becomes determined when EVERY rule defining it ranges (modal +
4312        // ordinary) only over invariant or already-determined predicates, with no
4313        // self-reference (acyclic).
4314        //
4315        // An ORDINARY head is determined transitively when every defining rule ranges
4316        // only over determined/invariant relations (e.g. `r :- a` with `a` a
4317        // determined epistemic head). Such an `r` is determined-in-principle: its
4318        // extension is fixed once the determined heads it derives from are fixed, so a
4319        // higher modal `know r`/`possible r` can stratify against the materialized
4320        // base `r` via the existing membership filter. The acyclicity guard in
4321        // `head_is_determined` (self-reference returns false) plus the fixpoint's
4322        // monotonicity keep every recursive predicate OUT of `determined`, so a
4323        // circular `know reach` in a recursive SCC is never determined
4324        // and stays fail-closed.
4325        let mut determined: BTreeSet<String> = BTreeSet::new();
4326        let mut changed = true;
4327        while changed {
4328            changed = false;
4329            for head in &derived_heads {
4330                if determined.contains(*head) {
4331                    continue;
4332                }
4333                if Self::head_is_determined(program, head, &invariant, &derived_heads, &determined)
4334                {
4335                    determined.insert((*head).to_string());
4336                    changed = true;
4337                }
4338            }
4339        }
4340
4341        Self { determined }
4342    }
4343
4344    /// Whether `head`'s every defining rule ranges only over invariant or
4345    /// already-determined predicates (acyclic — no reference to `head` itself).
4346    fn head_is_determined(
4347        program: &Program,
4348        head: &str,
4349        invariant: &InvariantRelations,
4350        derived_heads: &BTreeSet<&str>,
4351        determined: &BTreeSet<String>,
4352    ) -> bool {
4353        let mut defined = false;
4354        for rule in &program.rules {
4355            if rule.head.predicate != head || rule.body.is_empty() {
4356                continue;
4357            }
4358            defined = true;
4359            for lit in &rule.body {
4360                let referenced = match lit {
4361                    BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
4362                        atom.predicate.as_str()
4363                    }
4364                    BodyLiteral::Epistemic(modal) => modal.atom.predicate.as_str(),
4365                    BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {
4366                        continue
4367                    }
4368                };
4369                if referenced == head {
4370                    // Self-reference: not acyclically determined (recursion /
4371                    // circular modality). Hand back to the recursive/FAEEL paths.
4372                    return false;
4373                }
4374                let ok = invariant.is_invariant(referenced)
4375                    || determined.contains(referenced)
4376                    // A pure-EDB predicate not seen by `derived_heads` is invariant.
4377                    || !derived_heads.contains(referenced);
4378                if !ok {
4379                    return false;
4380                }
4381            }
4382        }
4383        defined
4384    }
4385
4386    fn contains(&self, predicate: &str) -> bool {
4387        self.determined.contains(predicate)
4388    }
4389}
4390
4391/// Plan a STRATIFIED epistemic execution when the program contains a modal literal
4392/// over an epistemic-derived head that is itself epistemically DETERMINED.
4393///
4394/// This intercepts exactly the chained/nested-epistemic coupling that the joint
4395/// single-enumeration path fails closed on (`b :- know a` where `a :- know p`, `p`
4396/// invariant). It partitions the program's epistemic heads into strata by modal
4397/// dependency, where a head whose modal ranges over a lower DETERMINED head sits in
4398/// a strictly-higher stratum. Each stratum is a self-contained sub-program compiled
4399/// through the EXISTING single/joint epistemic path; at runtime the executor
4400/// materializes each stratum's GATED head into the store before the next stratum
4401/// runs, so the higher stratum gates against the materialized (now-base) relation
4402/// via the existing membership filter — never via resolve-into-body.
4403///
4404/// Returns:
4405/// - `Ok(Some(plan))` when the program genuinely needs (and admits) stratification:
4406///   at least one modal literal ranges over an epistemically-determined derived
4407///   head, and a sound stratification exists.
4408/// - `Ok(None)` when no modal ranges over a determined derived head (the existing
4409///   joint/split/single paths own the program — for example, a shared modal whose
4410///   target is extensional data rather than a determined derived head), OR
4411///   when the nested target is NOT determined (circular modality / recursion /
4412///   unfounded self-support is handed back to the recursive + FAEEL/G91 guards,
4413///   which keep ownership and fail closed there).
4414pub fn try_plan_stratified_epistemic_program(
4415    program: &Program,
4416) -> Result<Option<EpistemicStratifiedPlan>> {
4417    let prepared = prepare_root_authored_constraint_identity(program)?;
4418    let program = &prepared;
4419    let determined = EpistemicallyDeterminedPredicates::analyze(program);
4420
4421    // A stratification is needed only when some modal literal ranges over a
4422    // DETERMINED epistemic-derived head. (A modal over a base/EDB predicate is the
4423    // ordinary single/joint path and must NOT be intercepted.)
4424    let mut needs_stratification = false;
4425    for rule in &program.rules {
4426        for lit in &rule.body {
4427            if let BodyLiteral::Epistemic(modal) = lit {
4428                if determined.contains(modal.atom.predicate.as_str())
4429                    && modal.atom.predicate != rule.head.predicate
4430                {
4431                    needs_stratification = true;
4432                }
4433            }
4434        }
4435    }
4436    if !needs_stratification {
4437        return Ok(None);
4438    }
4439    let removed_rules = faeel_unfounded_exact_tuple_self_support_rule_indices(program)
4440        .into_iter()
4441        .collect::<BTreeSet<_>>();
4442    validate_epistemic_derived_relation_identity(program, &removed_rules)?;
4443
4444    // Assign each epistemic-derived head a stratum level = longest modal-dependency
4445    // chain to a determined head it gates over. Heads not determined cannot be
4446    // stratified soundly here; if any modal ranges over a non-determined derived
4447    // epistemic head, hand back to the joint path's fail-closed diagnostic.
4448    let stratum_level = assign_epistemic_strata(program, &determined)?;
4449    let Some(stratum_level) = stratum_level else {
4450        return Ok(None);
4451    };
4452
4453    // Group epistemic-bearing rules by their head's stratum level.
4454    let mut levels: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
4455    for (idx, rule) in program.rules.iter().enumerate() {
4456        let has_epistemic = rule
4457            .body
4458            .iter()
4459            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
4460        if !has_epistemic {
4461            continue;
4462        }
4463        let Some(level) = stratum_level.get(rule.head.predicate.as_str()) else {
4464            // An epistemic head with no assigned level means the analysis could not
4465            // place it soundly; hand back.
4466            return Ok(None);
4467        };
4468        levels.entry(*level).or_default().push(idx);
4469    }
4470
4471    if levels.len() < 2 {
4472        // Only one stratum: there is no lower stratum to materialize, so this is
4473        // not a genuine stratification (the existing paths own it).
4474        return Ok(None);
4475    }
4476
4477    let mut strata = Vec::with_capacity(levels.len());
4478    for (_level, rule_indices) in levels {
4479        let head_predicates: Vec<String> = rule_indices
4480            .iter()
4481            .filter_map(|idx| program.rules.get(*idx))
4482            .map(|rule| rule.head.predicate.clone())
4483            .collect::<BTreeSet<_>>()
4484            .into_iter()
4485            .collect();
4486        let stratum_program =
4487            build_stratum_subprogram(program, &rule_indices, &head_predicates, &stratum_level)?;
4488        strata.push(EpistemicStratum {
4489            head_predicates,
4490            rule_indices,
4491            program: stratum_program,
4492        });
4493    }
4494
4495    // Validate each stratum according to the reducer that will execute it. An
4496    // admissible recursive stratum resolves modal literals into ordinary rule bodies
4497    // and never appends hidden head columns; non-recursive strata use the single-pass
4498    // materializer and therefore require its union/shape checks.
4499    for stratum in &strata {
4500        if try_reduce_case_a_recursive_epistemic_program(&stratum.program)?.is_none() {
4501            validate_epistemic_relation_shapes(&stratum.program, &BTreeSet::new())?;
4502        }
4503    }
4504
4505    let ordinary_post_program = build_post_stratification_ordinary_program(program);
4506    ordinary_post_program.validate_prepared_authored_constraint_identity()?;
4507
4508    Ok(Some(EpistemicStratifiedPlan {
4509        strata,
4510        ordinary_post_program,
4511    }))
4512}
4513
4514/// Build the single ordinary epilogue for a stratified epistemic execution.
4515///
4516/// Modal rules belong to their ordered strata. Every non-modal rule is replayed once
4517/// after all gated heads are materialized so deferred transitive closure (for example
4518/// `c :- b` where `b` is a top-stratum head) reaches its final extension before the
4519/// authored ordinary constraints are evaluated. Queries stay attached so the
4520/// high-level executor can surface relations derived only by this final stage.
4521fn build_post_stratification_ordinary_program(program: &Program) -> Program {
4522    let mut post = program.clone();
4523    post.rules.retain(|rule| {
4524        rule.body
4525            .iter()
4526            .all(|literal| !matches!(literal, BodyLiteral::Epistemic(_)))
4527    });
4528    post.constraints.retain(|constraint| {
4529        constraint
4530            .body
4531            .iter()
4532            .all(|literal| !matches!(literal, BodyLiteral::Epistemic(_)))
4533    });
4534    post
4535}
4536
4537/// Build the rule rewrites used only for plan-wide schema inference in a
4538/// stratified program.
4539///
4540/// Each recursive stratum must contribute schemas from the same recursive epistemic
4541/// reducer selected for its executable plan. Applying the generic single-pass reducer
4542/// to those rules would append hidden tuple-key columns that their actual recursive
4543/// plan never produces.
4544fn stratified_schema_reduction_overrides(
4545    program: &Program,
4546) -> Result<BTreeMap<usize, crate::ast::Rule>> {
4547    let Some(plan) = try_plan_stratified_epistemic_program(program)? else {
4548        return Ok(BTreeMap::new());
4549    };
4550
4551    let mut overrides = BTreeMap::new();
4552    for stratum in plan.strata {
4553        if try_reduce_case_a_recursive_epistemic_program(&stratum.program)?.is_none() {
4554            continue;
4555        }
4556        for rule_index in stratum.rule_indices {
4557            let mut rule = program.rules.get(rule_index).cloned().ok_or_else(|| {
4558                XlogError::Compilation(format!(
4559                    "stratified epistemic rule index {rule_index} is outside the source program"
4560                ))
4561            })?;
4562            resolve_recursive_epistemic_rule_modals(&mut rule);
4563            overrides.insert(rule_index, rule);
4564        }
4565    }
4566    Ok(overrides)
4567}
4568
4569/// Assign each epistemic-derived head an integer stratum level.
4570///
4571/// Level 0 heads gate only over invariant/EDB relations. A head whose modal ranges
4572/// over a determined head at level `k` is at level `>= k + 1`. Returns `Ok(None)`
4573/// if any modal ranges over a derived-epistemic head that is NOT determined (those
4574/// genuinely-undefined / fail-closed shapes are owned by the joint/recursive
4575/// guards, which already produce typed diagnostics).
4576fn assign_epistemic_strata(
4577    program: &Program,
4578    determined: &EpistemicallyDeterminedPredicates,
4579) -> Result<Option<BTreeMap<String, usize>>> {
4580    // Epistemic-derived heads.
4581    let mut epistemic_heads: BTreeSet<&str> = BTreeSet::new();
4582    for rule in &program.rules {
4583        if rule
4584            .body
4585            .iter()
4586            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
4587        {
4588            epistemic_heads.insert(rule.head.predicate.as_str());
4589        }
4590    }
4591
4592    // Modal-over-derived-epistemic-head edges: head -> set of derived-epistemic
4593    // predicates its modals range over.
4594    //
4595    // A modal can target either a determined EPISTEMIC head directly (`b :- know a`),
4596    // or an ORDINARY predicate transitively derived from determined epistemic heads
4597    // (`b :- know r` with `r :- a`, `a` epistemic-determined). For the ordinary case,
4598    // the modal's head must sit strictly ABOVE the epistemic head(s) in the ordinary
4599    // target's transitive determined support, so those epistemic heads are materialized
4600    // (gated) into the store first and the ordinary `r :- a` is then computed over the
4601    // materialized base (making `r` locally invariant). We therefore route an edge from
4602    // the modal's head to EACH epistemic determined head in the target's support.
4603    let mut modal_edges: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
4604    for rule in &program.rules {
4605        let head = rule.head.predicate.as_str();
4606        for lit in &rule.body {
4607            if let BodyLiteral::Epistemic(modal) = lit {
4608                let target = modal.atom.predicate.as_str();
4609                if epistemic_heads.contains(target) {
4610                    if !determined.contains(target) {
4611                        // Modal over a non-determined epistemic head: not soundly
4612                        // stratifiable here. Hand back to the joint/recursive guard.
4613                        return Ok(None);
4614                    }
4615                    modal_edges.entry(head).or_default().insert(target);
4616                } else if determined.contains(target) {
4617                    // Modal over an ORDINARY determined predicate: route edges to the
4618                    // epistemic determined heads in its transitive support so the
4619                    // modal's head sits above them.
4620                    let support =
4621                        epistemic_support_of_determined_ordinary(program, target, &epistemic_heads);
4622                    if support.is_empty() {
4623                        // No epistemic head in the support means the target is fully
4624                        // invariant (pure-ordinary over EDB) — that is the ordinary
4625                        // single/joint path, not a stratification. Hand back.
4626                        return Ok(None);
4627                    }
4628                    let entry = modal_edges.entry(head).or_default();
4629                    for support_head in support {
4630                        entry.insert(support_head);
4631                    }
4632                }
4633            }
4634        }
4635    }
4636
4637    // Longest-path level via memoized DFS over modal_edges (acyclicity guaranteed
4638    // by `EpistemicallyDeterminedPredicates`, which rejects self-reference).
4639    let mut level: BTreeMap<String, usize> = BTreeMap::new();
4640    fn visit<'a>(
4641        head: &'a str,
4642        modal_edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
4643        level: &mut BTreeMap<String, usize>,
4644        active: &mut BTreeSet<&'a str>,
4645    ) -> Result<usize> {
4646        if let Some(l) = level.get(head) {
4647            return Ok(*l);
4648        }
4649        if !active.insert(head) {
4650            // A cycle through modal edges should have been excluded upstream; be
4651            // defensive and refuse to stratify.
4652            return Err(recursive_epistemic_rejection(
4653                "stratified epistemic planning encountered a modal dependency cycle",
4654            ));
4655        }
4656        let mut l = 0;
4657        if let Some(targets) = modal_edges.get(head) {
4658            for target in targets {
4659                let tl = visit(target, modal_edges, level, active)?;
4660                l = l.max(tl + 1);
4661            }
4662        }
4663        active.remove(head);
4664        level.insert(head.to_string(), l);
4665        Ok(l)
4666    }
4667
4668    for head in &epistemic_heads {
4669        visit(head, &modal_edges, &mut level, &mut BTreeSet::new())?;
4670    }
4671
4672    Ok(Some(level))
4673}
4674
4675/// The epistemic determined heads in the transitive ordinary support of a determined
4676/// ORDINARY predicate.
4677///
4678/// For `r :- a` with `a` an epistemic-determined head, `support_of("r") = {"a"}`. The
4679/// search follows positive/negated ordinary body atoms (the ordinary derivation), and
4680/// collects any referenced predicate that is itself an epistemic head. Bounded by the
4681/// (acyclic) determined-closure, so a simple visited-set DFS terminates.
4682fn epistemic_support_of_determined_ordinary<'a>(
4683    program: &'a Program,
4684    predicate: &'a str,
4685    epistemic_heads: &BTreeSet<&'a str>,
4686) -> BTreeSet<&'a str> {
4687    let mut support: BTreeSet<&'a str> = BTreeSet::new();
4688    let mut seen: BTreeSet<&'a str> = BTreeSet::new();
4689    let mut stack: Vec<&'a str> = vec![predicate];
4690    while let Some(current) = stack.pop() {
4691        if !seen.insert(current) {
4692            continue;
4693        }
4694        for rule in &program.rules {
4695            if rule.head.predicate != current || rule.body.is_empty() {
4696                continue;
4697            }
4698            for lit in &rule.body {
4699                let referenced = match lit {
4700                    BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
4701                        atom.predicate.as_str()
4702                    }
4703                    // An epistemic literal in the support means `current` is itself an
4704                    // epistemic head; record it and do not descend through the modal.
4705                    BodyLiteral::Epistemic(_)
4706                    | BodyLiteral::Comparison(_)
4707                    | BodyLiteral::IsExpr(_)
4708                    | BodyLiteral::Univ(_) => continue,
4709                };
4710                if epistemic_heads.contains(referenced) {
4711                    support.insert(referenced);
4712                } else {
4713                    // Descend through ordinary derivations toward their epistemic roots.
4714                    stack.push(referenced);
4715                }
4716            }
4717        }
4718        // If `current` itself is an epistemic head, it is its own support root.
4719        if epistemic_heads.contains(current) && current != predicate {
4720            support.insert(current);
4721        }
4722    }
4723    support
4724}
4725
4726/// Build a self-contained sub-program for one stratum.
4727///
4728/// Includes this stratum's epistemic-defining rules plus every fact and every
4729/// ordinary (non-epistemic) supporting rule whose head is NOT a lower-stratum
4730/// epistemic head. Lower-stratum epistemic heads are intentionally OMITTED: at
4731/// execution they are present in the store as materialized base relations, and
4732/// including their (modal-stripped, ungated) defining rules would overwrite the
4733/// gated extension. Their `pred` declarations are retained so the reduced compiler
4734/// sees a schema for the materialized base relation.
4735fn build_stratum_subprogram(
4736    program: &Program,
4737    rule_indices: &[usize],
4738    head_predicates: &[String],
4739    stratum_level: &BTreeMap<String, usize>,
4740) -> Result<Program> {
4741    let this_level = head_predicates
4742        .iter()
4743        .filter_map(|h| stratum_level.get(h))
4744        .copied()
4745        .max()
4746        .unwrap_or(0);
4747
4748    // Lower-stratum epistemic heads: present as materialized base relations at
4749    // runtime; their defining rules must NOT appear in this sub-program.
4750    let lower_epistemic_heads: BTreeSet<&str> = stratum_level
4751        .iter()
4752        .filter(|(_, level)| **level < this_level)
4753        .map(|(head, _)| head.as_str())
4754        .collect();
4755
4756    // All epistemic-derived heads (used to compute an ordinary rule's epistemic
4757    // support for deferral of determined-ordinary supporting rules).
4758    let all_epistemic_heads: BTreeSet<&str> = program
4759        .rules
4760        .iter()
4761        .filter(|rule| {
4762            rule.body
4763                .iter()
4764                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
4765        })
4766        .map(|rule| rule.head.predicate.as_str())
4767        .collect();
4768
4769    let own_rule_indices: BTreeSet<usize> = rule_indices.iter().copied().collect();
4770
4771    let mut stratum = program.clone();
4772    stratum.rules = program
4773        .rules
4774        .iter()
4775        .enumerate()
4776        .filter_map(|(idx, rule)| {
4777            if own_rule_indices.contains(&idx) {
4778                return Some(rule.clone());
4779            }
4780            // Drop any rule that (re)defines a lower-stratum epistemic head.
4781            if lower_epistemic_heads.contains(rule.head.predicate.as_str()) {
4782                return None;
4783            }
4784            // Keep facts and ordinary supporting rules (EDB + non-epistemic
4785            // derivations the stratum's bodies may reference).
4786            let has_epistemic = rule
4787                .body
4788                .iter()
4789                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
4790            if has_epistemic && !own_rule_indices.contains(&idx) {
4791                // Another stratum's epistemic rule: exclude.
4792                return None;
4793            }
4794            // An ORDINARY supporting rule whose transitive epistemic support includes a
4795            // head NOT yet materialized (gated) at this level must NOT run here — it
4796            // would compute over the UNGATED candidate extension of that head and leak
4797            // the wrong tuples into the store (which the higher stratum then gates
4798            // against). Defer it to the lowest stratum where ALL its epistemic support
4799            // is already a materialized gated base relation. E.g. `r :- a` (a an
4800            // epistemic-determined head) is dropped from `a`'s own stratum (level 0) and
4801            // kept only in the strictly-higher stratum where `a` is materialized base,
4802            // so `r` is computed once from the gated `a`. Pure-ordinary rules over EDB
4803            // (empty epistemic support) are never deferred.
4804            let support = epistemic_support_of_determined_ordinary(
4805                program,
4806                rule.head.predicate.as_str(),
4807                &all_epistemic_heads,
4808            );
4809            if support
4810                .iter()
4811                .any(|h| stratum_level.get(*h).copied().unwrap_or(0) >= this_level)
4812            {
4813                return None;
4814            }
4815            Some(rule.clone())
4816        })
4817        .collect();
4818
4819    // Authored queries are compiled exactly once by the post-stratification stage.
4820    // Keeping compiler-local `__xlog_query_N` relations in modal strata would reuse
4821    // the same local names across strata and can collide when query projections have
4822    // different schemas.
4823    let head_set: BTreeSet<&str> = head_predicates.iter().map(String::as_str).collect();
4824    stratum.queries.clear();
4825
4826    // Ordinary constraints are global postconditions and belong only to the single
4827    // post-stratification ordinary stage. Modal constraints retain predicate-local
4828    // ownership because they participate in candidate/world-view semantics here.
4829    stratum.constraints = program
4830        .constraints
4831        .iter()
4832        .filter(|constraint| {
4833            let is_ordinary = constraint
4834                .body
4835                .iter()
4836                .all(|literal| !matches!(literal, BodyLiteral::Epistemic(_)));
4837            if is_ordinary {
4838                return false;
4839            }
4840            constraint_predicate_set(constraint)
4841                .iter()
4842                .all(|p| head_set.contains(p.as_str()) || !is_program_head(program, p))
4843        })
4844        .cloned()
4845        .collect();
4846
4847    Ok(stratum)
4848}
4849
4850fn is_program_head(program: &Program, predicate: &str) -> bool {
4851    program
4852        .rules
4853        .iter()
4854        .any(|rule| !rule.body.is_empty() && rule.head.predicate == predicate)
4855}
4856
4857/// Partition an epistemic program into independently-evaluable components.
4858///
4859/// Builds the epistemic dependency graph (coalescing rules that couple distinct
4860/// epistemic body predicates into one component) and returns an
4861/// [`EpistemicSplitPlan`] describing which output heads evaluate together versus
4862/// in isolation. This is the entry point for the safe-split / joint-solving and
4863/// stratified-execution routing decisions in the GPU driver.
4864pub fn split_epistemic_program(program: &Program) -> Result<EpistemicSplitPlan> {
4865    // rules that couple more than one distinct epistemic body predicate
4866    // are NOT rejected here. The dependency graph already unions every such rule
4867    // into a single component (each epistemic predicate occurrence routes through
4868    // `modal_owner` in `build_epistemic_dependency_graph`), and that component is
4869    // recompiled through the unsplit joint path
4870    // (`compile_epistemic_gpu_execution`), which enumerates the full candidate
4871    // lattice and validates the FULL modal conjunction jointly on device. Any
4872    // genuinely out-of-fragment coupling (unsafe variables, unsupported
4873    // tuple-key/nested-modal semantics) stays fail-closed via the downstream
4874    // joint-path guards (`build_eir` safety analysis,
4875    // `validate_tuple_membership_bindings`, `validate_solver_contract`) with their
4876    // own typed source-contextualized diagnostics, so no blanket coupling
4877    // rejection is needed at the split boundary.
4878    Ok(EpistemicSplitPlan {
4879        components: build_epistemic_dependency_graph(program)?.components,
4880    })
4881}
4882
4883/// Compile valid epistemic split components through the production GPU executable path.
4884pub fn compile_epistemic_gpu_split_execution(
4885    program: &Program,
4886) -> Result<EpistemicSplitExecutablePlan> {
4887    compile_epistemic_gpu_split_execution_with_stats_snapshot(program, None)
4888}
4889
4890/// Compile valid epistemic split components with an optional production stats snapshot.
4891///
4892/// Each component subprogram is lowered through
4893/// [`compile_epistemic_gpu_execution_with_stats_snapshot`], so split execution
4894/// reuses the same GPU contract, reduced compiler pipeline, WCOJ promotion, and
4895/// helper-splitting surfaces as unsplit epistemic execution.
4896pub fn compile_epistemic_gpu_split_execution_with_stats_snapshot(
4897    program: &Program,
4898    stats_snapshot: Option<&StatsSnapshot>,
4899) -> Result<EpistemicSplitExecutablePlan> {
4900    let mut prepared = program.clone();
4901    if prepared.authored_constraint_source_bound.is_some() {
4902        prepared.validate_prepared_authored_constraint_identity()?;
4903    } else {
4904        prepared.prepare_authored_constraint_identity_at_root()?;
4905    }
4906    let program = &prepared;
4907    validate_epistemic_relation_shapes(program, &BTreeSet::new())?;
4908    reject_epistemic_constraints(program)?;
4909    let split_plan = split_epistemic_program(program)?;
4910    let mut components = Vec::new();
4911
4912    for component in &split_plan.components {
4913        if !component_has_epistemic_rule(program, component) {
4914            continue;
4915        }
4916
4917        // Cross-component coupling carrying >1 epistemic output head is either
4918        // JOINT-SOLVED (a coalesced component whose modal literals all range over
4919        // base/invariant predicates -- a shared accepted world view materializes
4920        // every head) or fails closed with a precise typed diagnostic (a modal
4921        // literal ranges over an epistemic-derived head of the same component, so
4922        // the heads' world-view acceptance is genuinely interdependent and the
4923        // independent split would be unsound). A single epistemic head is always
4924        // the existing single-output joint path.
4925        let coupling = classify_cross_component_modal_coupling(program, component)?;
4926
4927        let component_program = split_component_program(program, component)?;
4928        let executable = compile_epistemic_gpu_execution_inner(
4929            &component_program,
4930            stats_snapshot,
4931            coupling.allows_multiple_output_heads(),
4932        )?;
4933        components.push(EpistemicSplitExecutableComponent {
4934            component: component.clone(),
4935            executable,
4936        });
4937    }
4938
4939    if components.is_empty() {
4940        return Err(XlogError::UnsupportedEpistemicConstruct {
4941            construct: "epistemic GPU split execution".to_string(),
4942            context: "requires at least one epistemic split component".to_string(),
4943        });
4944    }
4945
4946    Ok(EpistemicSplitExecutablePlan {
4947        split_plan,
4948        components,
4949    })
4950}
4951
4952fn component_has_epistemic_rule(
4953    program: &Program,
4954    component: &EpistemicDependencyComponent,
4955) -> bool {
4956    component
4957        .rule_indices
4958        .iter()
4959        .filter_map(|idx| program.rules.get(*idx))
4960        .any(|rule| {
4961            rule.body
4962                .iter()
4963                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
4964        })
4965}
4966
4967/// Distinct head predicates of the component's epistemic-bearing rules, sorted.
4968///
4969/// Each such head is a final epistemic output relation the joint single-pass GPU
4970/// path would have to materialize. The single-output-buffer contract
4971/// ([`require_single_epistemic_output_relation`]) admits exactly one, so a count
4972/// above one means the component is genuinely *coupled* across what local
4973/// analysis would otherwise split — its epistemic outputs cannot be solved
4974/// independently AND cannot be jointly materialized into one buffer.
4975fn component_epistemic_output_heads(
4976    program: &Program,
4977    component: &EpistemicDependencyComponent,
4978) -> Vec<String> {
4979    let mut heads: BTreeSet<String> = BTreeSet::new();
4980    for idx in &component.rule_indices {
4981        let Some(rule) = program.rules.get(*idx) else {
4982            continue;
4983        };
4984        let has_epistemic_body = rule
4985            .body
4986            .iter()
4987            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
4988        if has_epistemic_body {
4989            heads.insert(rule.head.predicate.clone());
4990        }
4991    }
4992    heads.into_iter().collect()
4993}
4994
4995/// Render a coalesced component's merge reasons into a stable, human-readable list
4996/// for the cross-component coupling diagnostic.
4997///
4998/// These reasons (`DerivedPredicate`, `SharedModalPredicate`, `SharedHeadPredicate`,
4999/// `Constraint`) are exactly *why* the dependency graph could not split the
5000/// component's epistemic outputs, so naming them tells the caller which structural
5001/// coupling forced the fail-closed.
5002fn format_component_merge_reasons(component: &EpistemicDependencyComponent) -> String {
5003    if component.merge_reasons.is_empty() {
5004        return "no recorded coalesce reason".to_string();
5005    }
5006    component
5007        .merge_reasons
5008        .iter()
5009        .map(|reason| match reason {
5010            EpistemicComponentMergeReason::SharedHeadPredicate { predicate } => {
5011                format!("SharedHeadPredicate({predicate})")
5012            }
5013            EpistemicComponentMergeReason::DerivedPredicate { predicate } => {
5014                format!("DerivedPredicate({predicate})")
5015            }
5016            EpistemicComponentMergeReason::SharedModalPredicate { predicate } => {
5017                format!("SharedModalPredicate({predicate})")
5018            }
5019            EpistemicComponentMergeReason::Constraint { predicates } => {
5020                format!("Constraint({})", predicates.join(", "))
5021            }
5022        })
5023        .collect::<Vec<_>>()
5024        .join(", ")
5025}
5026
5027/// Classification of a coalesced epistemic component's cross-component coupling.
5028#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5029enum CrossComponentCoupling {
5030    /// At most one epistemic output head, or a multi-head component whose modal
5031    /// literals all range over base/invariant predicates. The shared accepted
5032    /// world view materializes every head, so the component is JOINT-SOLVED.
5033    JointSolvable,
5034}
5035
5036impl CrossComponentCoupling {
5037    /// True when the component's GPU plan is permitted to carry more than one
5038    /// epistemic output head (joint multi-head materialization).
5039    fn allows_multiple_output_heads(self) -> bool {
5040        match self {
5041            CrossComponentCoupling::JointSolvable => true,
5042        }
5043    }
5044}
5045
5046/// Classify a coalesced component's cross-component modal coupling, JOINT-SOLVING
5047/// the canonical shared-base-modal case and failing closed (with a precise typed
5048/// diagnostic) on genuinely interdependent nested-epistemic coupling.
5049///
5050/// A coalesced component carrying more than one epistemic output head is either:
5051///
5052/// - **Joint-solvable** — every modal literal in the component ranges over a
5053///   predicate that is NOT an epistemic-derived head of the component (a
5054///   base/invariant relation or an ordinary-derived relation). The accepted
5055///   world-view set is then determined independently of which head is being
5056///   materialized, so one joint candidate enumeration + world-view validation
5057///   over the combined modal literals yields a single accepted world view, and
5058///   each head materialized against THAT world view equals its per-head
5059///   reduced-program evaluation. This is the canonical `SharedModalPredicate`
5060///   joint-solving target (`a(X):-know q(X). b(X):-possible q(X).` over base `q`).
5061///
5062/// - **Genuinely interdependent (fail closed)** — some modal literal ranges over
5063///   an EPISTEMIC-DERIVED head of the same component (`flagged():-know trusted()`
5064///   where `trusted` is itself `know`-derived). The modal truth of that predicate
5065///   depends on a DIFFERENT head's accepted world view, so the heads' acceptance
5066///   is mutually entangled (nested/stratified epistemic dependency). Solving it
5067///   would require stratified world-view nesting that the single joint enumeration
5068///   does not provide, so it stays FAIL-CLOSED with a typed diagnostic naming the
5069///   coupled heads, the modal predicate, and the merge reason -- never silently
5070///   mis-evaluated.
5071///
5072/// SAFE single-epistemic-head coupling (an ordinary body consuming an epistemic
5073/// head, `b():-a()` over `a():-know p()`) and EDB-only sharing are both
5074/// `JointSolvable` (one or zero coupled heads), so they stay accepted.
5075/// Compute the predicates whose extension depends, directly or transitively
5076/// through ordinary rules in the component, on an epistemic-derived head.
5077///
5078/// Seeded with the component's epistemic output heads (each is "tainted" because
5079/// its extension is gated by a modal literal), then closed under the rule
5080/// dependency relation: a head becomes tainted when ANY rule defining it (within
5081/// the component) references an already-tainted predicate in its body. A modal
5082/// literal over a tainted predicate is a nested/stratified epistemic dependency.
5083fn epistemic_tainted_predicates<'a>(
5084    program: &'a Program,
5085    component: &EpistemicDependencyComponent,
5086    epistemic_heads: &'a [String],
5087) -> BTreeSet<&'a str> {
5088    let mut tainted: BTreeSet<&str> = epistemic_heads.iter().map(String::as_str).collect();
5089    // Iterate the component's rules to a least fixpoint: a rule's head is tainted
5090    // if any body atom references a tainted predicate.
5091    let mut changed = true;
5092    while changed {
5093        changed = false;
5094        for idx in &component.rule_indices {
5095            let Some(rule) = program.rules.get(*idx) else {
5096                continue;
5097            };
5098            if tainted.contains(rule.head.predicate.as_str()) {
5099                continue;
5100            }
5101            // `BodyLiteral::atom()` covers relational AND epistemic literals
5102            // (the modal predicate), so this taints a head whether it depends on a
5103            // tainted predicate ordinarily or through a modal literal.
5104            let body_touches_tainted = rule.body.iter().any(|lit| {
5105                lit.atom()
5106                    .map(|atom| tainted.contains(atom.predicate.as_str()))
5107                    .unwrap_or(false)
5108            });
5109            if body_touches_tainted {
5110                tainted.insert(rule.head.predicate.as_str());
5111                changed = true;
5112            }
5113        }
5114    }
5115    tainted
5116}
5117
5118fn classify_cross_component_modal_coupling(
5119    program: &Program,
5120    component: &EpistemicDependencyComponent,
5121) -> Result<CrossComponentCoupling> {
5122    let epistemic_heads = component_epistemic_output_heads(program, component);
5123    if epistemic_heads.len() <= 1 {
5124        return Ok(CrossComponentCoupling::JointSolvable);
5125    }
5126
5127    // A modal literal ranging over a predicate whose extension DEPENDS (directly
5128    // OR TRANSITIVELY, through ordinary rules in this component) on an
5129    // epistemic-derived head is a nested/stratified epistemic dependency that the
5130    // single joint enumeration cannot solve soundly: that modal's truth would have
5131    // to be re-evaluated under EACH candidate world view chosen for the head it
5132    // depends on, which one shared world-view enumeration does not provide.
5133    //
5134    // "Epistemic-tainted" predicates = epistemic-derived heads, closed under the
5135    // ordinary rule dependency relation within the component (least fixpoint). A
5136    // modal over any tainted predicate fails closed. A modal over a purely
5137    // base/invariant or epistemic-INDEPENDENT predicate is joint-solvable.
5138    let tainted = epistemic_tainted_predicates(program, component, &epistemic_heads);
5139
5140    let mut nested_modal_predicates: BTreeSet<String> = BTreeSet::new();
5141    for idx in &component.rule_indices {
5142        let Some(rule) = program.rules.get(*idx) else {
5143            continue;
5144        };
5145        for lit in &rule.body {
5146            if let BodyLiteral::Epistemic(modal) = lit {
5147                if tainted.contains(modal.atom.predicate.as_str()) {
5148                    nested_modal_predicates.insert(format!(
5149                        "{}/{}",
5150                        modal.atom.predicate,
5151                        modal.atom.arity()
5152                    ));
5153                }
5154            }
5155        }
5156    }
5157
5158    if nested_modal_predicates.is_empty() {
5159        // Every modal literal ranges over a predicate that is independent of every
5160        // epistemic-derived head, so the accepted world view is determined solely
5161        // by base/invariant relations and the component is joint-solvable over one
5162        // shared accepted world view.
5163        return Ok(CrossComponentCoupling::JointSolvable);
5164    }
5165
5166    Err(XlogError::UnsupportedEpistemicConstruct {
5167        construct: "cross-component epistemic coupling".to_string(),
5168        context: format!(
5169            "epistemic output heads {:?} are coupled into a single dependency \
5170             component (reasons: {}) through nested modal literals over \
5171             epistemic-derived predicates {:?}; the modal truth of an \
5172             epistemic-derived head depends on another head's accepted world view, \
5173             so a single joint world-view enumeration would mis-evaluate the \
5174             nested modality and an independent split would be unsound, so this \
5175             fails closed",
5176            epistemic_heads,
5177            format_component_merge_reasons(component),
5178            nested_modal_predicates.into_iter().collect::<Vec<_>>(),
5179        ),
5180    })
5181}
5182
5183fn split_component_program(
5184    program: &Program,
5185    component: &EpistemicDependencyComponent,
5186) -> Result<Program> {
5187    let mut component_program = program.clone();
5188    let component_predicates: BTreeSet<&str> =
5189        component.predicates.iter().map(String::as_str).collect();
5190    let component_rule_indices: BTreeSet<usize> = component.rule_indices.iter().copied().collect();
5191    let head_predicates: BTreeSet<&str> = program
5192        .rules
5193        .iter()
5194        .map(|rule| rule.head.predicate.as_str())
5195        .collect();
5196    component_program.rules = program
5197        .rules
5198        .iter()
5199        .enumerate()
5200        .filter_map(|(idx, rule)| {
5201            (component_rule_indices.contains(&idx)
5202                || (rule.body.is_empty()
5203                    && component_predicates.contains(rule.head.predicate.as_str())))
5204            .then_some(rule.clone())
5205        })
5206        .collect();
5207    component_program.constraints = program
5208        .constraints
5209        .iter()
5210        .filter(|constraint| {
5211            let predicates = constraint_predicate_set(constraint);
5212            let has_component_owned_predicate = predicates
5213                .iter()
5214                .any(|predicate| head_predicates.contains(predicate.as_str()));
5215            !has_component_owned_predicate
5216                || predicates
5217                    .iter()
5218                    .all(|predicate| component_predicates.contains(predicate.as_str()))
5219        })
5220        .cloned()
5221        .collect();
5222    Ok(component_program)
5223}
5224
5225#[cfg(test)]
5226mod tests {
5227    use super::*;
5228    use crate::ast::{PredColumn, PredDecl, TypeRef};
5229    use crate::parse_program;
5230
5231    #[test]
5232    fn augmented_head_reconciliation_preserves_columns_only_declarations() {
5233        let symbol = TypeRef::Scalar(xlog_core::ScalarType::Symbol);
5234        let wide_integer = TypeRef::Scalar(xlog_core::ScalarType::I64);
5235        let mut program = Program::new();
5236        program.predicates = vec![
5237            PredDecl {
5238                name: "result".to_string(),
5239                types: Vec::new(),
5240                columns: vec![PredColumn {
5241                    name: Some("key".to_string()),
5242                    typ: symbol.clone(),
5243                }],
5244                is_private: false,
5245            },
5246            PredDecl {
5247                name: "source".to_string(),
5248                types: Vec::new(),
5249                columns: vec![
5250                    PredColumn {
5251                        name: Some("key".to_string()),
5252                        typ: symbol.clone(),
5253                    },
5254                    PredColumn {
5255                        name: Some("value".to_string()),
5256                        typ: wide_integer.clone(),
5257                    },
5258                ],
5259                is_private: false,
5260            },
5261        ];
5262        program.rules.push(crate::ast::Rule {
5263            head: Atom {
5264                predicate: "result".to_string(),
5265                terms: vec![
5266                    Term::Variable("Key".to_string()),
5267                    Term::Variable("Value".to_string()),
5268                ],
5269            },
5270            body: vec![BodyLiteral::Positive(Atom {
5271                predicate: "source".to_string(),
5272                terms: vec![
5273                    Term::Variable("Key".to_string()),
5274                    Term::Variable("Value".to_string()),
5275                ],
5276            })],
5277        });
5278        let resolved = BTreeMap::from([(0, 1)]);
5279
5280        let widened = reconcile_augmented_head_declarations(&mut program, &resolved)
5281            .expect("reconcile augmented declaration");
5282        let declaration = program
5283            .predicates
5284            .iter()
5285            .find(|declaration| declaration.name == "result")
5286            .unwrap();
5287
5288        assert_eq!(widened.get(&("result".to_string(), 1)), Some(&2));
5289        assert_eq!(declaration.arity(), 2);
5290        assert_eq!(
5291            declaration.types,
5292            vec![symbol.clone(), wide_integer.clone()]
5293        );
5294        assert_eq!(
5295            declaration
5296                .columns
5297                .iter()
5298                .map(|column| column.typ.clone())
5299                .collect::<Vec<_>>(),
5300            vec![symbol, wide_integer]
5301        );
5302    }
5303
5304    #[test]
5305    fn augmented_head_reconciliation_uses_body_declarations_by_name_and_arity() {
5306        let program = parse_program(
5307            r#"
5308            #pragma epistemic_mode = faeel
5309            pred node(symbol).
5310            pred source(symbol, i64).
5311            pred source(u32).
5312            pred result(symbol).
5313            node(key).
5314            source(key, 5000000000).
5315            source(1).
5316            result(X) :- node(X), know source(X, Y).
5317            "#,
5318        )
5319        .expect("parse same-name multi-arity schema fixture");
5320
5321        let reduced = reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
5322            .expect("same-name body declarations should reduce");
5323        let result = reduced
5324            .predicates
5325            .iter()
5326            .find(|declaration| declaration.name == "result")
5327            .expect("missing result declaration");
5328        let types = result
5329            .schema_columns()
5330            .into_iter()
5331            .map(|column| column.typ)
5332            .collect::<Vec<_>>();
5333
5334        assert_eq!(
5335            types,
5336            vec![
5337                TypeRef::Scalar(xlog_core::ScalarType::Symbol),
5338                TypeRef::Scalar(xlog_core::ScalarType::I64),
5339            ]
5340        );
5341        assert!(reduced
5342            .predicates
5343            .iter()
5344            .any(|declaration| declaration.name == "source/2"));
5345        assert!(reduced
5346            .predicates
5347            .iter()
5348            .any(|declaration| declaration.name == "source/1"));
5349        crate::compile::Compiler::new()
5350            .compile_program(&reduced)
5351            .expect("arity-qualified reduced program should compile");
5352        compile_epistemic_gpu_execution(&program)
5353            .expect("production epistemic compilation should use the exact source signature");
5354    }
5355
5356    #[test]
5357    fn augmented_head_reconciliation_uses_undeclared_fixed_point_schemas() {
5358        let sources = [
5359            r#"
5360                #pragma epistemic_mode = faeel
5361                pred node(symbol).
5362                pred result(symbol).
5363                node(key).
5364                edge(key, 5000000000).
5365                result(X) :- node(X), know edge(X, Y).
5366            "#,
5367            r#"
5368                #pragma epistemic_mode = faeel
5369                pred node(symbol).
5370                pred result(symbol).
5371                node(key).
5372                raw(key, 5000000000).
5373                edge(X, Y) :- raw(X, Y).
5374                result(X) :- node(X), know edge(X, Y).
5375            "#,
5376        ];
5377
5378        for source in sources {
5379            let program = parse_program(source).expect("parse inferred-schema fixture");
5380            let reduced = reduce_epistemic_program_to_ordinary(&program)
5381                .expect("inferred modal source should reduce");
5382            let result = reduced
5383                .predicates
5384                .iter()
5385                .find(|declaration| declaration.name == "result")
5386                .expect("missing result declaration");
5387            assert_eq!(
5388                result
5389                    .schema_columns()
5390                    .into_iter()
5391                    .map(|column| column.typ)
5392                    .collect::<Vec<_>>(),
5393                vec![
5394                    TypeRef::Scalar(xlog_core::ScalarType::Symbol),
5395                    TypeRef::Scalar(xlog_core::ScalarType::I64),
5396                ]
5397            );
5398            crate::compile::Compiler::new()
5399                .compile_program(&reduced)
5400                .expect("fixed-point inferred hidden-column type should compile");
5401        }
5402    }
5403
5404    #[test]
5405    fn augmented_head_reconciliation_uses_arithmetic_binding_type() {
5406        let program = parse_program(
5407            r#"
5408                #pragma epistemic_mode = faeel
5409                pred node(symbol).
5410                pred allowed(u64).
5411                pred result(symbol).
5412                node(key).
5413                allowed(1).
5414                result(X) :- node(X), Y is cast(1, u64), not know allowed(Y).
5415            "#,
5416        )
5417        .expect("parse arithmetic-binding fixture");
5418
5419        let reduced = reduce_epistemic_program_to_ordinary(&program)
5420            .expect("arithmetic-bound hidden column should reduce");
5421        let result = reduced
5422            .predicates
5423            .iter()
5424            .find(|declaration| declaration.name == "result")
5425            .expect("missing result declaration");
5426        assert_eq!(
5427            result
5428                .schema_columns()
5429                .into_iter()
5430                .map(|column| column.typ)
5431                .collect::<Vec<_>>(),
5432            vec![
5433                TypeRef::Scalar(xlog_core::ScalarType::Symbol),
5434                TypeRef::Scalar(xlog_core::ScalarType::U64),
5435            ]
5436        );
5437        crate::compile::Compiler::new()
5438            .compile_program(&reduced)
5439            .expect("arithmetic-bound hidden-column type should compile");
5440    }
5441
5442    #[test]
5443    fn augmented_head_reconciliation_widens_only_the_original_signature() {
5444        let mut reduced = parse_program(
5445            r#"
5446            pred edge(symbol, i64).
5447            pred triple(symbol, i64, u32).
5448            pred result(symbol, i64, u32).
5449            pred result(symbol).
5450            result(X, Y, Z) :- triple(X, Y, Z).
5451            result(X, Y) :- edge(X, Y).
5452            ?- result(A, B, C).
5453            ?- result(X).
5454            "#,
5455        )
5456        .expect("parse exact-signature reconciliation fixture");
5457
5458        let augmented =
5459            reconcile_augmented_head_declarations(&mut reduced, &BTreeMap::from([(1, 1)]))
5460                .expect("reconcile exact augmented signature");
5461        let declaration_arities = reduced
5462            .predicates
5463            .iter()
5464            .filter(|declaration| declaration.name == "result")
5465            .map(PredDecl::arity)
5466            .collect::<Vec<_>>();
5467        let rule_arities = reduced
5468            .rules
5469            .iter()
5470            .filter(|rule| rule.head.predicate == "result")
5471            .map(|rule| rule.head.arity())
5472            .collect::<Vec<_>>();
5473        let query_arities = reduced
5474            .queries
5475            .iter()
5476            .filter(|query| query.atom.predicate == "result")
5477            .map(|query| query.atom.arity())
5478            .collect::<Vec<_>>();
5479
5480        assert_eq!(augmented.get(&("result".to_string(), 1)), Some(&2));
5481        assert_eq!(declaration_arities, vec![3, 2]);
5482        assert_eq!(rule_arities, vec![3, 2]);
5483        assert_eq!(query_arities, vec![3, 1]);
5484    }
5485
5486    #[test]
5487    fn augmented_undeclared_head_removes_its_original_query() {
5488        let program = parse_program(
5489            r#"
5490            #pragma epistemic_mode = faeel
5491            node(key).
5492            edge(key, 5000000000).
5493            result(X) :- node(X), know edge(X, Y).
5494            ?- result(X).
5495            "#,
5496        )
5497        .expect("parse undeclared augmented-head fixture");
5498
5499        let reduced = reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
5500            .expect("undeclared augmented head should reduce");
5501
5502        assert!(reduced
5503            .rules
5504            .iter()
5505            .any(|rule| rule.head.predicate == "result" && rule.head.arity() == 2));
5506        assert!(!reduced
5507            .queries
5508            .iter()
5509            .any(|query| query.atom.predicate == "result" && query.atom.arity() == 1));
5510    }
5511
5512    #[test]
5513    fn divergent_augmented_head_arities_are_rejected_before_reduction() {
5514        let different_modal_widths = r#"
5515            #pragma epistemic_mode = faeel
5516            pred node(symbol).
5517            pred edge(symbol, i64).
5518            pred triple(symbol, i64, u32).
5519            pred result(symbol).
5520            node(key).
5521            edge(key, 5000000000).
5522            triple(key, 5000000000, 1).
5523            result(X) :- node(X), know edge(X, Y).
5524            result(X) :- node(X), know triple(X, Y, Z).
5525        "#;
5526        let ordinary_sibling = r#"
5527            #pragma epistemic_mode = faeel
5528            pred base(symbol).
5529            pred node(symbol).
5530            pred edge(symbol, i64).
5531            pred result(symbol).
5532            base(key).
5533            node(key).
5534            edge(key, 5000000000).
5535            result(X) :- base(X).
5536            result(X) :- node(X), know edge(X, Y).
5537        "#;
5538
5539        for source in [different_modal_widths, ordinary_sibling] {
5540            let program = parse_program(source).expect("parse divergent augmented-head fixture");
5541            let errors = [
5542                plan_epistemic_gpu_execution(&program)
5543                    .expect_err("GPU planning must reject divergent reduced arities"),
5544                reduce_epistemic_program_to_ordinary(&program)
5545                    .expect_err("execution reduction must reject divergent arities"),
5546                reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
5547                    .expect_err("schema reduction must reject divergent arities"),
5548            ];
5549
5550            for error in errors {
5551                let message = error.to_string();
5552                assert!(message.contains("epistemic augmented predicate schema"));
5553                assert!(message.contains("result/1"), "{message}");
5554                assert!(
5555                    message.contains("incompatible internal arities"),
5556                    "{message}"
5557                );
5558            }
5559        }
5560    }
5561
5562    #[test]
5563    fn single_pass_epistemic_rule_unions_without_clause_provenance_are_rejected() {
5564        let fixtures = [
5565            r#"
5566                #pragma epistemic_mode = faeel
5567                pred p().
5568                pred q().
5569                pred result(symbol).
5570                q().
5571                result(a) :- know p().
5572                result(b) :- know q().
5573                ?- result(X).
5574            "#,
5575            r#"
5576                #pragma epistemic_mode = faeel
5577                pred q().
5578                pred result(symbol).
5579                result(a).
5580                result(b) :- know q().
5581                ?- result(X).
5582            "#,
5583        ];
5584
5585        for source in fixtures {
5586            let program = parse_program(source).expect("parse epistemic rule-union fixture");
5587            let errors = [
5588                plan_epistemic_gpu_execution(&program)
5589                    .expect_err("GPU planning must reject a provenance-free rule union"),
5590                reduce_epistemic_program_to_ordinary(&program)
5591                    .expect_err("execution reduction must reject a provenance-free rule union"),
5592                reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
5593                    .expect_err("schema reduction must reject a provenance-free rule union"),
5594            ];
5595            for error in errors {
5596                let message = error.to_string();
5597                assert!(message.contains("epistemic rule-union materialization"));
5598                assert!(message.contains("result/1"), "{message}");
5599                assert!(message.contains("per-clause modal provenance"), "{message}");
5600            }
5601        }
5602    }
5603
5604    #[test]
5605    fn equivalent_epistemic_rule_union_filters_are_distributive() {
5606        let program = parse_program(
5607            r#"
5608                #pragma epistemic_mode = faeel
5609                pred target(u32).
5610                pred left(u32).
5611                pred right(u32).
5612                pred result(u32).
5613                target(1).
5614                target(2).
5615                left(1).
5616                right(2).
5617                result(X) :- left(X), know target(X).
5618                result(Y) :- right(Y), possible target(Y).
5619                ?- result(Value).
5620            "#,
5621        )
5622        .expect("parse equivalent-filter rule union");
5623
5624        plan_epistemic_gpu_execution(&program)
5625            .expect("equivalent invariant modal filters must distribute over the rule union");
5626        reduce_epistemic_program_to_ordinary(&program)
5627            .expect("execution reduction must preserve an equivalent-filter rule union");
5628        reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
5629            .expect("schema reduction must preserve an equivalent-filter rule union");
5630    }
5631
5632    #[test]
5633    fn independently_founded_ground_gates_are_distributive_across_rule_union() {
5634        let program = parse_program(
5635            r#"
5636                #pragma epistemic_mode = g91
5637                pred left(u32).
5638                pred right(u32).
5639                pred result(u32).
5640                left(1).
5641                right(1).
5642                result(1) :- possible left(1).
5643                result(2) :- possible right(1).
5644                ?- result(Value).
5645            "#,
5646        )
5647        .expect("parse independently founded ground-gate rule union");
5648
5649        plan_epistemic_gpu_execution(&program)
5650            .expect("independently founded ground gates must distribute over the rule union");
5651        reduce_epistemic_program_to_ordinary(&program)
5652            .expect("execution reduction must preserve independently founded ground gates");
5653    }
5654
5655    #[test]
5656    fn g91_exact_head_possible_union_preserves_compatibility_self_support() {
5657        let program = parse_program(
5658            r#"
5659                #pragma epistemic_mode = g91
5660                pred seed(u32).
5661                pred node(u32).
5662                pred p(u32).
5663                seed(1).
5664                node(2).
5665                p(X) :- seed(X).
5666                p(X) :- node(X), possible p(X).
5667                ?- p(X).
5668            "#,
5669        )
5670        .expect("parse G91 exact-head possibility union");
5671
5672        assert_eq!(
5673            classify_recursive_epistemic_program(&program).unwrap(),
5674            RecursiveEpistemicClass::ModalCycle
5675        );
5676        let prepared = prepare_epistemic_program(&program).expect("validate G91 source");
5677        let compatibility = try_prepare_g91_compatibility_reduction(&prepared)
5678            .expect("G91 modal cycle must be admitted")
5679            .expect("G91 modal cycle must use an explicit compatibility fixpoint");
5680        Compiler::new()
5681            .compile_program(compatibility.upper_bound_program())
5682            .expect("G91 upper-bound program must compile");
5683        Compiler::new()
5684            .compile_program(compatibility.refinement_program())
5685            .expect("declared G91 snapshot program must compile");
5686        assert_eq!(compatibility.snapshot_relations().len(), 1);
5687    }
5688
5689    #[test]
5690    fn g91_compatibility_applies_to_exact_tuples_across_one_recursive_component() {
5691        let program = parse_program(
5692            r#"
5693                #pragma epistemic_mode = g91
5694                pred domain(u32).
5695                pred p(u32).
5696                pred q(u32).
5697                domain(1).
5698                p(X) :- domain(X), possible q(X).
5699                q(X) :- domain(X), possible p(X).
5700                ?- p(X).
5701                ?- q(X).
5702            "#,
5703        )
5704        .expect("parse mutual G91 modal component");
5705
5706        let prepared = prepare_epistemic_program(&program).expect("validate mutual G91 source");
5707        let compatibility = try_prepare_g91_compatibility_reduction(&prepared)
5708            .expect("mutual G91 modal component must be admitted")
5709            .expect("mutual G91 modal component must use compatibility iteration");
5710        for rule in compatibility
5711            .upper_bound_program()
5712            .rules
5713            .iter()
5714            .filter(|rule| matches!(rule.head.predicate.as_str(), "p" | "q"))
5715        {
5716            assert!(
5717                rule.body
5718                    .iter()
5719                    .any(|literal| matches!(literal, BodyLiteral::Comparison(_))),
5720                "the exact tuple compatibility edge must become a tautological conjunct: {rule:?}"
5721            );
5722            assert!(
5723                !rule.body.iter().any(|literal| {
5724                    matches!(literal, BodyLiteral::Positive(atom) if atom.predicate == "p" || atom.predicate == "q")
5725                }),
5726                "a compatibility edge must not become an ordinary recursive join: {rule:?}"
5727            );
5728        }
5729        for rule in compatibility
5730            .refinement_program()
5731            .rules
5732            .iter()
5733            .filter(|rule| matches!(rule.head.predicate.as_str(), "p" | "q"))
5734        {
5735            assert!(rule.body.iter().any(|literal| {
5736                matches!(literal, BodyLiteral::Positive(atom) if atom.predicate.starts_with("__xlog_g91_snapshot_"))
5737            }));
5738            assert!(!rule
5739                .body
5740                .iter()
5741                .any(|literal| matches!(literal, BodyLiteral::Epistemic(_))));
5742        }
5743    }
5744
5745    #[test]
5746    fn g91_snapshot_names_avoid_programmatic_relation_collisions() {
5747        let mut program = parse_program("pred p(u32).").expect("parse source relation");
5748        program.predicates.push(PredDecl {
5749            name: "__xlog_g91_snapshot_p".to_string(),
5750            types: vec![TypeRef::Scalar(xlog_core::ScalarType::U32)],
5751            columns: vec![PredColumn {
5752                name: None,
5753                typ: TypeRef::Scalar(xlog_core::ScalarType::U32),
5754            }],
5755            is_private: false,
5756        });
5757        let target = "p".to_string();
5758        let names = g91_snapshot_relation_names(&program, std::iter::once(&target));
5759        assert_eq!(
5760            names.get("p").map(String::as_str),
5761            Some("__xlog_g91_snapshot_p_0")
5762        );
5763    }
5764
5765    #[test]
5766    fn g91_compatibility_rejects_recursive_aggregation_in_the_selected_component() {
5767        let program = parse_program(
5768            r#"
5769                #pragma epistemic_mode = g91
5770                pred seed(u32).
5771                pred p(u32).
5772                pred totals(u64).
5773                seed(1).
5774                p(X) :- seed(X), possible p(X).
5775                p(X) :- seed(X), totals(_).
5776                totals(count(X)) :- p(X).
5777                ?- p(X).
5778            "#,
5779        )
5780        .expect("parse recursive aggregate compatibility fixture");
5781
5782        let prepared = prepare_epistemic_program(&program).expect("validate G91 source");
5783        let error = try_prepare_g91_compatibility_reduction(&prepared)
5784            .expect_err("recursive aggregation makes compatibility refinement non-monotone");
5785        let message = error.to_string();
5786        assert!(message.contains("Gelfond-1991 compatibility"), "{message}");
5787        assert!(message.contains("aggregate"), "{message}");
5788        assert!(message.contains("totals"), "{message}");
5789    }
5790
5791    #[test]
5792    fn g91_compatibility_rejects_recursive_negation_in_the_selected_component() {
5793        for negated_dependency in [
5794            "not blocked(X)",
5795            "not possible blocked(X)",
5796            "not know blocked(X)",
5797        ] {
5798            let program = parse_program(&format!(
5799                r#"
5800                    #pragma epistemic_mode = g91
5801                    pred seed(u32).
5802                    pred p(u32).
5803                    pred blocked(u32).
5804                    seed(1).
5805                    p(X) :- seed(X), possible p(X).
5806                    p(X) :- seed(X), {negated_dependency}.
5807                    blocked(X) :- p(X).
5808                    ?- p(X).
5809                "#,
5810            ))
5811            .expect("parse recursive negation compatibility fixture");
5812
5813            let prepared = prepare_epistemic_program(&program).expect("validate G91 source");
5814            let error = match try_prepare_g91_compatibility_reduction(&prepared) {
5815                Err(error) => error,
5816                Ok(_) => panic!(
5817                    "recursive dependency `{negated_dependency}` must make compatibility \
5818                     refinement non-monotone"
5819                ),
5820            };
5821            let message = error.to_string();
5822            assert!(message.contains("Gelfond-1991 compatibility"), "{message}");
5823            assert!(message.contains("negation"), "{message}");
5824            assert!(message.contains("p"), "{message}");
5825        }
5826    }
5827
5828    #[test]
5829    fn source_validation_combines_modal_and_arithmetic_type_evidence_before_elision() {
5830        let program = parse_program(
5831            r#"
5832                #pragma epistemic_mode = faeel
5833                pred p(u32).
5834                p(X) :- X is cast(1, u64), possible p(X).
5835                ?- p(X).
5836            "#,
5837        )
5838        .expect("parse arithmetic and modal type-conflict fixture");
5839
5840        let error = prepare_epistemic_program(&program)
5841            .expect_err("foundedness must not hide the authored type conflict");
5842        let message = error.to_string();
5843        assert!(message.contains("Type mismatch"), "{message}");
5844        assert!(
5845            message.contains("U32") && message.contains("U64"),
5846            "{message}"
5847        );
5848    }
5849
5850    #[test]
5851    fn source_validation_uses_lowerer_arithmetic_order_before_elision() {
5852        let program = parse_program(
5853            r#"
5854                #pragma epistemic_mode = faeel
5855                pred p(i64).
5856                p(X) :- X is Y + 1, Y is 1, possible p(X).
5857            "#,
5858        )
5859        .expect("parse reversed arithmetic dependency fixture");
5860
5861        let error = prepare_epistemic_program(&program)
5862            .expect_err("a later arithmetic binding cannot retroactively validate an earlier one");
5863        assert!(
5864            error.to_string().contains("variable X not bound"),
5865            "{error}"
5866        );
5867    }
5868
5869    #[test]
5870    fn source_validation_rejects_structured_modal_arity_before_rule_elision() {
5871        for mode in ["faeel", "g91"] {
5872            let program = parse_program(&format!(
5873                r#"
5874                    #pragma epistemic_mode = {mode}
5875                    pred p(list<symbol>).
5876                    p([a, b]) :- possible p([a, b]).
5877                    ?- p(X).
5878                "#
5879            ))
5880            .expect("parse structured exact-self-support fixture");
5881
5882            let error = prepare_epistemic_program(&program)
5883                .expect_err("a flattened modal key must match its authored target arity");
5884            let message = error.to_string();
5885            assert!(message.contains("epistemic modal tuple key"), "{message}");
5886            assert!(message.contains("target arity 1"), "{message}");
5887            assert!(message.contains("binding arity 2"), "{message}");
5888        }
5889    }
5890
5891    #[test]
5892    fn source_validation_accepts_structured_key_matching_flat_target_arity() {
5893        let program = parse_program(
5894            r#"
5895                #pragma epistemic_mode = faeel
5896                pred host(u32, u32).
5897                pred watched(u32, u32).
5898                pred out(u32, u32).
5899                host(1, 2).
5900                watched(1, 2).
5901                out(X, Y) :- host(X, Y), know watched([X, Y]).
5902                ?- out(X, Y).
5903            "#,
5904        )
5905        .expect("parse matching structured modal key fixture");
5906
5907        validate_epistemic_source_program(&program)
5908            .expect("a two-element structured key must address a binary target");
5909
5910        let multi_arity = epistemic_extensional_multi_arity_predicates(&program);
5911        assert!(
5912            !multi_arity.contains("watched"),
5913            "a two-column structured modal key and watched/2 are one signature"
5914        );
5915    }
5916
5917    #[test]
5918    fn invariant_analysis_treats_shared_acyclic_dependencies_as_a_diamond() {
5919        let program = parse_program(
5920            r#"
5921                pred base(u32).
5922                pred left(u32).
5923                pred right(u32).
5924                pred joined(u32).
5925                pred out(u32).
5926                base(1).
5927                left(X) :- base(X).
5928                right(X) :- base(X).
5929                joined(X) :- left(X), right(X).
5930                out(X) :- possible joined(X).
5931                ?- out(X).
5932            "#,
5933        )
5934        .expect("parse invariant diamond fixture");
5935
5936        let invariant = InvariantRelations::analyze(&program);
5937        assert!(invariant.is_invariant("joined"));
5938        validate_epistemic_source_program(&program)
5939            .expect("the positive modal over an acyclic diamond must bind its output");
5940        reduce_epistemic_program_to_ordinary(&program)
5941            .expect("the invariant modal binder must reduce to an ordinary join");
5942    }
5943
5944    #[test]
5945    fn positive_modal_binders_are_independent_of_conjunct_order() {
5946        for mode in ["faeel", "g91"] {
5947            for modal_body in [
5948                "possible p(X), possible base(X)",
5949                "possible base(X), possible p(X)",
5950            ] {
5951                let program = parse_program(&format!(
5952                    r#"
5953                        #pragma epistemic_mode = {mode}
5954                        pred base(u32).
5955                        pred p(u32).
5956                        base(1).
5957                        p(X) :- {modal_body}.
5958                        ?- p(X).
5959                    "#
5960                ))
5961                .expect("parse modal binder ordering fixture");
5962
5963                validate_epistemic_source_program(&program).unwrap_or_else(|error| {
5964                    panic!("{mode} body `{modal_body}` must be range-restricted: {error}")
5965                });
5966            }
5967        }
5968    }
5969
5970    #[test]
5971    fn negated_exact_modal_cycles_are_never_removed_as_foundedness_elision() {
5972        let program = parse_program(
5973            r#"
5974                #pragma epistemic_mode = faeel
5975                pred p().
5976                p() :- not possible p().
5977                ?- p().
5978            "#,
5979        )
5980        .expect("parse negated exact modal cycle");
5981
5982        let prepared = prepare_epistemic_program(&program)
5983            .expect("negated modal cycle must survive source preparation");
5984        assert!(!prepared.removed_unfounded_rules());
5985        assert!(prepared.active_program().rules.iter().any(|rule| {
5986            rule.body
5987                .iter()
5988                .any(|literal| matches!(literal, BodyLiteral::Epistemic(modal) if modal.negated))
5989        }));
5990    }
5991
5992    #[test]
5993    fn modal_fixpoint_reduction_preserves_non_bijective_sibling_unions() {
5994        let fixtures = [
5995            r#"
5996                #pragma epistemic_mode = faeel
5997                pred domain(symbol).
5998                pred other(symbol, symbol).
5999                pred p(symbol, symbol).
6000                domain(a).
6001                other(c, d).
6002                p(X, X) :- domain(X).
6003                p(A, B) :- other(A, B).
6004                p(X, X) :- domain(X), know p(X, X).
6005                ?- p(A, B).
6006            "#,
6007            r#"
6008                #pragma epistemic_mode = faeel
6009                pred left(symbol).
6010                pred right(symbol).
6011                pred p(symbol, symbol).
6012                left(x).
6013                right(y).
6014                p(a, X) :- left(X).
6015                p(b, Y) :- right(Y).
6016                p(a, X) :- left(X), know p(a, X).
6017                ?- p(A, B).
6018            "#,
6019        ];
6020
6021        for source in fixtures {
6022            let program = parse_program(source).expect("parse non-bijective rule union");
6023            assert_eq!(
6024                classify_recursive_epistemic_program(&program).unwrap(),
6025                RecursiveEpistemicClass::ModalCycle
6026            );
6027            let reduced = try_reduce_case_a_recursive_epistemic_program(&program)
6028                .expect("modal-cycle sibling union must be admitted")
6029                .expect("modal-cycle sibling union must reduce to a fixpoint");
6030            Compiler::new()
6031                .compile_program(&reduced)
6032                .expect("ordinary fixpoint preserves per-clause sibling rows");
6033        }
6034    }
6035
6036    #[test]
6037    fn constrained_support_does_not_found_a_wider_modal_domain() {
6038        let program = parse_program(
6039            r#"
6040                #pragma epistemic_mode = faeel
6041                pred seed(u32).
6042                pred p(u32).
6043                seed(1).
6044                seed(2).
6045                p(X) :- seed(X), X = 1.
6046                p(X) :- seed(X), possible p(X).
6047                ?- p(X).
6048            "#,
6049        )
6050        .expect("parse constrained foundedness program");
6051
6052        let reduced = reduce_epistemic_program_to_ordinary(&program)
6053            .expect("the unfounded modal clause must be removed, not rejected");
6054        assert_eq!(
6055            reduced
6056                .rules
6057                .iter()
6058                .filter(|rule| rule.head.predicate == "p" && !rule.body.is_empty())
6059                .count(),
6060            1,
6061            "a constrained support clause cannot found the wider self-support domain"
6062        );
6063    }
6064
6065    #[test]
6066    fn derived_predicate_source_arity_collisions_are_rejected() {
6067        let fixtures = [
6068            r#"
6069                #pragma epistemic_mode = faeel
6070                unary(a).
6071                binary(a, b).
6072                result(X) :- unary(X), know unary(X).
6073                result(X, Y) :- binary(X, Y), know binary(X, Y).
6074            "#,
6075            r#"
6076                #pragma epistemic_mode = faeel
6077                node(key).
6078                edge(key, 5000000000).
6079                result(X) :- node(X), know edge(X, Y).
6080                ?- result(A, B).
6081            "#,
6082            r#"
6083                #pragma epistemic_mode = faeel
6084                node(key).
6085                edge(key, 5000000000).
6086                result(X) :- node(X), know edge(X, Y).
6087                observer(X) :- result(X, Y).
6088            "#,
6089        ];
6090
6091        for source in fixtures {
6092            let program = parse_program(source).expect("parse source-arity collision fixture");
6093            let errors = [
6094                plan_epistemic_gpu_execution(&program)
6095                    .expect_err("GPU planning must reject derived source-arity collisions"),
6096                reduce_epistemic_program_to_ordinary(&program)
6097                    .expect_err("execution reduction must reject source-arity collisions"),
6098                reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
6099                    .expect_err("schema reduction must reject source-arity collisions"),
6100            ];
6101            for error in errors {
6102                let message = error.to_string();
6103                assert!(
6104                    message.contains("epistemic derived predicate schema"),
6105                    "{message}"
6106                );
6107                assert!(message.contains("result"), "{message}");
6108                assert!(message.contains("{1, 2}"), "{message}");
6109            }
6110        }
6111    }
6112
6113    #[test]
6114    fn constrained_augmented_head_queries_are_rejected() {
6115        let fixtures = [
6116            r#"
6117                #pragma epistemic_mode = faeel
6118                node(key).
6119                edge(key, 5000000000).
6120                result(X) :- node(X), know edge(X, Y).
6121                ?- result(other).
6122            "#,
6123            r#"
6124                #pragma epistemic_mode = faeel
6125                pair(left, right).
6126                edge(left, 5000000000).
6127                result(X, Y) :- pair(X, Y), know edge(X, Z).
6128                ?- result(Value, Value).
6129            "#,
6130            r#"
6131                #pragma epistemic_mode = faeel
6132                node(key).
6133                edge(key, 5000000000).
6134                allowed(5000000000).
6135                result(X) :- node(X), edge(X, Y), know allowed(Y).
6136                ?- result(key).
6137            "#,
6138        ];
6139
6140        for source in fixtures {
6141            let program = parse_program(source).expect("parse constrained-query fixture");
6142            let errors = [
6143                plan_epistemic_gpu_execution(&program)
6144                    .expect_err("GPU planning must reject a constrained augmented query"),
6145                reduce_epistemic_program_to_ordinary(&program)
6146                    .expect_err("execution reduction must reject a constrained augmented query"),
6147                reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
6148                    .expect_err("schema reduction must reject a constrained augmented query"),
6149            ];
6150            for error in errors {
6151                let message = error.to_string();
6152                assert!(
6153                    message.contains("epistemic augmented head query"),
6154                    "{message}"
6155                );
6156                assert!(message.contains("distinct named variables"), "{message}");
6157            }
6158        }
6159    }
6160
6161    #[test]
6162    fn removed_unfounded_sibling_does_not_create_an_augmented_arity_conflict() {
6163        let program = parse_program(
6164            r#"
6165                #pragma epistemic_mode = faeel
6166                pred node(symbol).
6167                pred edge(symbol, i64).
6168                pred result(symbol).
6169                node(key).
6170                edge(key, 5000000000).
6171                result(X) :- node(X), know edge(X, Y).
6172                result(key) :- possible result(key).
6173            "#,
6174        )
6175        .expect("parse foundedness fixture");
6176
6177        let reduced = reduce_epistemic_program_to_ordinary(&program)
6178            .expect("removed unfounded support must not affect the surviving relation shape");
6179        let result_rules = reduced
6180            .rules
6181            .iter()
6182            .filter(|rule| rule.head.predicate == "result")
6183            .collect::<Vec<_>>();
6184        assert_eq!(result_rules.len(), 1);
6185        assert_eq!(result_rules[0].head.arity(), 1);
6186    }
6187
6188    #[test]
6189    fn ordinary_bound_appended_columns_participate_in_shape_validation() {
6190        let program = parse_program(
6191            r#"
6192                #pragma epistemic_mode = faeel
6193                pred node(symbol).
6194                pred edge(symbol, i64).
6195                pred allowed(i64).
6196                pred result(symbol).
6197                node(key).
6198                edge(key, 5000000000).
6199                allowed(5000000000).
6200                result(X) :- node(X).
6201                result(X) :- node(X), edge(X, Y), know allowed(Y).
6202                ?- result(X).
6203            "#,
6204        )
6205        .expect("parse ordinary-bound augmentation fixture");
6206
6207        let errors = [
6208            plan_epistemic_gpu_execution(&program)
6209                .expect_err("GPU planning must reject divergent internal arities"),
6210            reduce_epistemic_program_to_ordinary(&program)
6211                .expect_err("execution reduction must reject divergent internal arities"),
6212            reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
6213                .expect_err("schema reduction must reject divergent internal arities"),
6214        ];
6215        for error in errors {
6216            let message = error.to_string();
6217            assert!(
6218                message.contains("epistemic augmented predicate schema"),
6219                "{message}"
6220            );
6221            assert!(message.contains("result/1"), "{message}");
6222            assert!(message.contains("{1, 2}"), "{message}");
6223        }
6224    }
6225
6226    #[test]
6227    fn ordinary_bound_appended_columns_widen_the_internal_declaration() {
6228        let program = parse_program(
6229            r#"
6230                #pragma epistemic_mode = faeel
6231                pred node(symbol).
6232                pred edge(symbol, i64).
6233                pred allowed(i64).
6234                pred result(symbol).
6235                node(key).
6236                edge(key, 5000000000).
6237                allowed(5000000000).
6238                result(X) :- node(X), edge(X, Y), know allowed(Y).
6239                ?- result(X).
6240            "#,
6241        )
6242        .expect("parse ordinary-bound declaration fixture");
6243
6244        let reduced = reduce_epistemic_program_to_ordinary(&program)
6245            .expect("uniform ordinary-bound augmentation should reduce");
6246        let declaration = reduced
6247            .predicates
6248            .iter()
6249            .find(|declaration| declaration.name == "result")
6250            .expect("missing result declaration");
6251        assert_eq!(declaration.arity(), 2);
6252        crate::compile::Compiler::new()
6253            .compile_program(&reduced)
6254            .expect("reconciled ordinary-bound augmentation should compile");
6255    }
6256
6257    #[test]
6258    fn stratified_schema_reduction_uses_the_recursive_stratum_reducer() {
6259        let program = parse_program(
6260            r#"
6261                #pragma epistemic_mode = faeel
6262                pred node(u32).
6263                pred edge(u32, u32).
6264                pred accepted_edge(u32, u32).
6265                pred reach(u32, u32).
6266                node(1).
6267                node(2).
6268                node(3).
6269                edge(1, 2).
6270                edge(2, 3).
6271                accepted_edge(X, Y) :- node(X), node(Y), know edge(X, Y).
6272                reach(X, Y) :- node(X), node(Y), know accepted_edge(X, Y).
6273                reach(X, Z) :- reach(X, Y), node(Z), know accepted_edge(Y, Z).
6274                ?- reach(X, Z).
6275            "#,
6276        )
6277        .expect("parse stratified recursive fixture");
6278
6279        let plan = try_plan_stratified_epistemic_program(&program)
6280            .expect("stratified planning should succeed")
6281            .expect("fixture requires stratified execution");
6282        assert_eq!(plan.strata.len(), 2);
6283
6284        let reduced = reduce_epistemic_program_to_ordinary_for_stratified_schema(&program)
6285            .expect("schema reduction should follow each stratum's executable reducer");
6286        assert!(reduced
6287            .rules
6288            .iter()
6289            .filter(|rule| rule.head.predicate == "reach")
6290            .all(|rule| rule.head.arity() == 2));
6291        assert_eq!(
6292            reduced
6293                .predicates
6294                .iter()
6295                .find(|declaration| declaration.name == "reach")
6296                .expect("missing reach declaration")
6297                .arity(),
6298            2
6299        );
6300        crate::compile::Compiler::new()
6301            .compile_program(&reduced)
6302            .expect("path-aligned stratified schema program should compile");
6303    }
6304
6305    #[test]
6306    fn high_arity_epistemic_adapter_reduction_is_not_wcoj_required() {
6307        let program = parse_program(
6308            r#"
6309            pred case_variant(u32, u32).
6310            pred case_domain_variant(u32, u32, u32).
6311            pred domain_adapter_root(u32, u32, u32).
6312            pred domain_adapter_intervention(u32, u32, u32).
6313            pred domain_candidate_seed(u32, u32, u32, u32).
6314            pred heldout_label_seed(u32, u32).
6315            pred blocked_candidate(u32, u32, u32).
6316            pred generated_candidate(u32, u32, u32, u32, u32).
6317
6318            generated_candidate(Case, Variant, Candidate, Root, Intervention) :-
6319                case_domain_variant(Case, Variant, Domain),
6320                domain_adapter_root(Domain, Candidate, Root),
6321                domain_adapter_intervention(Domain, Candidate, Intervention),
6322                domain_candidate_seed(Domain, Candidate, Root, Intervention),
6323                know domain_candidate_seed(Domain, Candidate, Root, Intervention),
6324                possible case_variant(Case, Variant),
6325                not know heldout_label_seed(Case, Candidate),
6326                not possible blocked_candidate(Case, Variant, Candidate).
6327            "#,
6328        )
6329        .expect("parse high-arity adapter epistemic program");
6330
6331        let plan = plan_epistemic_gpu_execution(&program)
6332            .expect("plan high-arity adapter epistemic program");
6333
6334        assert_eq!(plan.reductions.len(), 1);
6335        assert_eq!(
6336            plan.reductions[0].wcoj_status,
6337            EpistemicWcojReductionStatus::NotWcojCandidate
6338        );
6339    }
6340
6341    #[test]
6342    fn binary_triangle_epistemic_reduction_still_requires_wcoj() {
6343        let program = parse_program(
6344            r#"
6345            pred xy(u32, u32).
6346            pred yz(u32, u32).
6347            pred xz(u32, u32).
6348            pred tri(u32, u32, u32).
6349
6350            tri(X, Y, Z) :-
6351                xy(X, Y),
6352                yz(Y, Z),
6353                xz(X, Z),
6354                know xy(X, Y).
6355            "#,
6356        )
6357        .expect("parse binary triangle epistemic program");
6358
6359        let plan =
6360            plan_epistemic_gpu_execution(&program).expect("plan binary triangle epistemic program");
6361
6362        assert_eq!(plan.reductions.len(), 1);
6363        assert_eq!(
6364            plan.reductions[0].wcoj_status,
6365            EpistemicWcojReductionStatus::RequiresPlannerEligibility
6366        );
6367    }
6368
6369    #[test]
6370    fn binary_eight_clique_epistemic_reduction_requires_wcoj() {
6371        let program = parse_program(
6372            r#"
6373            pred edge(u32, u32).
6374            pred clique8(u32, u32, u32, u32, u32, u32, u32, u32).
6375
6376            clique8(A, B, C, D, E, F, G, H) :-
6377                edge(A, B),
6378                edge(A, C),
6379                edge(A, D),
6380                edge(A, E),
6381                edge(A, F),
6382                edge(A, G),
6383                edge(A, H),
6384                edge(B, C),
6385                edge(B, D),
6386                edge(B, E),
6387                edge(B, F),
6388                edge(B, G),
6389                edge(B, H),
6390                edge(C, D),
6391                edge(C, E),
6392                edge(C, F),
6393                edge(C, G),
6394                edge(C, H),
6395                edge(D, E),
6396                edge(D, F),
6397                edge(D, G),
6398                edge(D, H),
6399                edge(E, F),
6400                edge(E, G),
6401                edge(E, H),
6402                edge(F, G),
6403                edge(F, H),
6404                edge(G, H),
6405                know edge(A, B).
6406            "#,
6407        )
6408        .expect("parse binary eight-clique epistemic program");
6409
6410        let plan = plan_epistemic_gpu_execution(&program)
6411            .expect("plan binary eight-clique epistemic program");
6412
6413        assert_eq!(plan.reductions.len(), 1);
6414        assert_eq!(
6415            plan.reductions[0].wcoj_status,
6416            EpistemicWcojReductionStatus::RequiresPlannerEligibility
6417        );
6418    }
6419}