Skip to main content

xlog_logic/
epistemic.rs

1//! Epistemic mode helpers for compatibility fixtures.
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;
20
21/// Boolean truth value for bounded epistemic fixture evaluation.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum TruthValue {
24    /// The literal is true.
25    True,
26    /// The literal is false.
27    False,
28}
29
30impl TruthValue {
31    fn from_bool(value: bool) -> Self {
32        if value {
33            TruthValue::True
34        } else {
35            TruthValue::False
36        }
37    }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
41enum EpistemicTermKey {
42    Integer(i64),
43    FloatBits(u64),
44    String(String),
45    Symbol(u32),
46    List(Vec<EpistemicTermKey>),
47    Cons {
48        head: Box<EpistemicTermKey>,
49        tail: Box<EpistemicTermKey>,
50    },
51    Compound {
52        functor: String,
53        args: Vec<EpistemicTermKey>,
54    },
55    PredRef(String),
56}
57
58impl EpistemicTermKey {
59    fn from_term(term: &Term) -> Result<Self> {
60        Ok(match term {
61            Term::Integer(value) => Self::Integer(*value),
62            Term::Float(value) => Self::FloatBits(value.to_bits()),
63            Term::String(value) => Self::String(value.clone()),
64            Term::Symbol(value) => Self::Symbol(*value),
65            Term::List(items) => Self::List(
66                items
67                    .iter()
68                    .map(Self::from_term)
69                    .collect::<Result<Vec<_>>>()?,
70            ),
71            Term::Cons { head, tail } => Self::Cons {
72                head: Box::new(Self::from_term(head)?),
73                tail: Box::new(Self::from_term(tail)?),
74            },
75            Term::Compound { functor, args } => Self::Compound {
76                functor: functor.clone(),
77                args: args
78                    .iter()
79                    .map(Self::from_term)
80                    .collect::<Result<Vec<_>>>()?,
81            },
82            Term::PredRef(value) => Self::PredRef(value.clone()),
83            Term::Variable(_) | Term::Anonymous | Term::Aggregate(_) => {
84                return Err(XlogError::UnsupportedEpistemicConstruct {
85                    construct: "epistemic tuple key".to_string(),
86                    context: "tuple-key epistemic facts require ground terms".to_string(),
87                });
88            }
89        })
90    }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
94enum EpistemicAtomKey {
95    Arity {
96        predicate: String,
97        arity: usize,
98    },
99    Ground {
100        predicate: String,
101        terms: Vec<EpistemicTermKey>,
102    },
103}
104
105impl EpistemicAtomKey {
106    fn from_arity(predicate: impl Into<String>, arity: usize) -> Self {
107        Self::Arity {
108            predicate: predicate.into(),
109            arity,
110        }
111    }
112
113    fn from_terms(predicate: impl Into<String>, terms: &[Term]) -> Result<Self> {
114        Ok(Self::Ground {
115            predicate: predicate.into(),
116            terms: terms
117                .iter()
118                .map(EpistemicTermKey::from_term)
119                .collect::<Result<Vec<_>>>()?,
120        })
121    }
122
123    fn predicate(&self) -> &str {
124        match self {
125            Self::Arity { predicate, .. } | Self::Ground { predicate, .. } => predicate,
126        }
127    }
128
129    fn arity(&self) -> usize {
130        match self {
131            Self::Arity { arity, .. } => *arity,
132            Self::Ground { terms, .. } => terms.len(),
133        }
134    }
135
136    fn matches_atom(&self, atom: &Atom) -> bool {
137        if self.predicate() != atom.predicate || self.arity() != atom.arity() {
138            return false;
139        }
140        match self {
141            Self::Arity { .. } => true,
142            Self::Ground { terms, .. } => atom
143                .terms
144                .iter()
145                .map(EpistemicTermKey::from_term)
146                .collect::<Result<Vec<_>>>()
147                .is_ok_and(|atom_terms| atom_terms == *terms),
148        }
149    }
150
151    fn overlaps(&self, other: &Self) -> bool {
152        if self.predicate() != other.predicate() || self.arity() != other.arity() {
153            return false;
154        }
155        matches!(self, Self::Arity { .. }) || matches!(other, Self::Arity { .. }) || self == other
156    }
157}
158
159/// Minimal interpretation used by G91/FAEEL distinction fixtures.
160#[derive(Debug, Clone, Default, PartialEq, Eq)]
161pub struct EpistemicInterpretation {
162    known: BTreeSet<EpistemicAtomKey>,
163    possible: BTreeSet<EpistemicAtomKey>,
164    rejected: BTreeSet<EpistemicAtomKey>,
165}
166
167impl EpistemicInterpretation {
168    /// Create an empty interpretation.
169    pub fn new() -> Self {
170        Self::default()
171    }
172
173    /// Mark a predicate/arity pair as known.
174    pub fn with_known(mut self, predicate: impl Into<String>, arity: usize) -> Self {
175        self.known
176            .insert(EpistemicAtomKey::from_arity(predicate, arity));
177        self
178    }
179
180    /// Mark a concrete tuple key as known.
181    pub fn with_known_terms(
182        mut self,
183        predicate: impl Into<String>,
184        terms: Vec<Term>,
185    ) -> Result<Self> {
186        self.known
187            .insert(EpistemicAtomKey::from_terms(predicate, &terms)?);
188        Ok(self)
189    }
190
191    /// Mark a predicate/arity pair as possible under G91 compatibility semantics.
192    pub fn with_possible(mut self, predicate: impl Into<String>, arity: usize) -> Self {
193        self.possible
194            .insert(EpistemicAtomKey::from_arity(predicate, arity));
195        self
196    }
197
198    /// Mark a concrete tuple key as possible under G91 compatibility semantics.
199    pub fn with_possible_terms(
200        mut self,
201        predicate: impl Into<String>,
202        terms: Vec<Term>,
203    ) -> Result<Self> {
204        self.possible
205            .insert(EpistemicAtomKey::from_terms(predicate, &terms)?);
206        Ok(self)
207    }
208
209    /// Mark a predicate/arity pair as rejected by the candidate.
210    pub fn with_rejected(mut self, predicate: impl Into<String>, arity: usize) -> Self {
211        self.rejected
212            .insert(EpistemicAtomKey::from_arity(predicate, arity));
213        self
214    }
215
216    /// Mark a concrete tuple key as rejected by the candidate.
217    pub fn with_rejected_terms(
218        mut self,
219        predicate: impl Into<String>,
220        terms: Vec<Term>,
221    ) -> Result<Self> {
222        self.rejected
223            .insert(EpistemicAtomKey::from_terms(predicate, &terms)?);
224        Ok(self)
225    }
226
227    fn first_contradiction(&self) -> Option<(String, usize)> {
228        self.known
229            .iter()
230            .find(|key| self.rejected.iter().any(|rejected| key.overlaps(rejected)))
231            .map(|key| (key.predicate().to_string(), key.arity()))
232    }
233
234    fn contains_known(&self, atom: &Atom) -> bool {
235        self.known.iter().any(|key| key.matches_atom(atom))
236    }
237
238    fn contains_possible(&self, atom: &Atom) -> bool {
239        self.possible.iter().any(|key| key.matches_atom(atom))
240    }
241
242    fn contains_rejected(&self, atom: &Atom) -> bool {
243        self.rejected.iter().any(|key| key.matches_atom(atom))
244    }
245
246    fn epistemic_guess_count(&self) -> usize {
247        self.known.len() + self.possible.len() + self.rejected.len()
248    }
249}
250
251/// One stable model in a bounded epistemic world-view fixture.
252#[derive(Debug, Clone, Default, PartialEq, Eq)]
253pub struct EpistemicWorld {
254    facts: BTreeSet<EpistemicAtomKey>,
255}
256
257impl EpistemicWorld {
258    /// Create an empty world.
259    pub fn new() -> Self {
260        Self::default()
261    }
262
263    /// Add a predicate/arity fact to this world.
264    pub fn with_fact(mut self, predicate: impl Into<String>, arity: usize) -> Self {
265        self.facts
266            .insert(EpistemicAtomKey::from_arity(predicate, arity));
267        self
268    }
269
270    /// Add a concrete tuple fact to this world.
271    pub fn with_fact_terms(
272        mut self,
273        predicate: impl Into<String>,
274        terms: Vec<Term>,
275    ) -> Result<Self> {
276        self.facts
277            .insert(EpistemicAtomKey::from_terms(predicate, &terms)?);
278        Ok(self)
279    }
280
281    fn contains(&self, atom: &Atom) -> bool {
282        self.facts.iter().any(|fact| fact.matches_atom(atom))
283    }
284}
285
286/// Non-empty set of accepted stable models used as the epistemic boundary.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct EpistemicWorldView {
289    worlds: Vec<EpistemicWorld>,
290}
291
292impl EpistemicWorldView {
293    /// Construct a non-empty world view.
294    pub fn from_worlds(worlds: Vec<EpistemicWorld>) -> Result<Self> {
295        if worlds.is_empty() {
296            return Err(xlog_core::XlogError::UnsupportedEpistemicConstruct {
297                construct: "world view boundary".to_string(),
298                context: "world view requires at least one stable model".to_string(),
299            });
300        }
301        Ok(Self { worlds })
302    }
303
304    /// Return the number of worlds in this view.
305    pub fn world_count(&self) -> usize {
306        self.worlds.len()
307    }
308
309    /// Evaluate an epistemic literal over this world view.
310    pub fn evaluate(&self, lit: &EpistemicLiteral) -> TruthValue {
311        let value = match lit.op {
312            EpistemicOp::Know => self.worlds.iter().all(|world| world.contains(&lit.atom)),
313            EpistemicOp::Possible => self.worlds.iter().any(|world| world.contains(&lit.atom)),
314        };
315
316        TruthValue::from_bool(if lit.negated { !value } else { value })
317    }
318}
319
320/// Build the production-facing GPU execution contract for an epistemic program.
321///
322/// This does not launch kernels. It proves that the semantic boundary can be
323/// represented as a GPU-native execution plan with explicit hot-path phases,
324/// required device buffers, WCOJ planning obligations, and zero CPU fallback
325/// counters.
326pub fn plan_epistemic_gpu_execution(program: &Program) -> Result<EpistemicGpuPlan> {
327    reject_recursive_epistemic_program(program)?;
328    let eir = build_eir(program)?;
329    // FAEEL unfounded modal self-support is NOT rejected here: it is a defined FAEEL
330    // result (the unfounded head is simply absent from the founded model). The
331    // structural foundedness decision drives the reduced-base drop in
332    // `faeel_unfounded_self_support_rule_indices`; the founded extension is then
333    // computed by the GPU world-view validation over the reduced base. See
334    // `reduce_epistemic_program_to_ordinary_inner`.
335    let mut epistemic_literals = Vec::new();
336    let mut reductions = Vec::new();
337    let mut tuple_membership_bindings = Vec::new();
338    let mut solver_assumption_bindings = Vec::new();
339
340    for (rule_index, rule) in eir.rules.iter().enumerate() {
341        let mut rule_epistemic_literals = Vec::new();
342        let mut relational_body_atoms = 0usize;
343        let mut has_negated_relational_atom = false;
344
345        for lit in &rule.body {
346            match lit {
347                EirBodyLiteral::Relational { negated, .. } => {
348                    if *negated {
349                        has_negated_relational_atom = true;
350                    } else {
351                        relational_body_atoms += 1;
352                    }
353                }
354                EirBodyLiteral::Epistemic(lit) => {
355                    rule_epistemic_literals.push(lit.clone());
356                }
357                EirBodyLiteral::Constraint | EirBodyLiteral::Binding => {}
358            }
359        }
360
361        if rule_epistemic_literals.is_empty() {
362            continue;
363        }
364
365        let reduction_index = reductions.len();
366        for lit in rule_epistemic_literals {
367            // Flatten any STRUCTURED finite+typed key term (`[a, b]`, `f(a, b)`)
368            // element-wise into scalar GPU key columns so the existing device
369            // tuple-key matcher binds/matches each element directly, and store the
370            // FLATTENED literal so its atom arity/terms equal the modal relation's
371            // (the plan validators and runtime read the same flattened shape).
372            // Scalar keys pass through unchanged; unbounded/untyped structured
373            // forms fail closed here with a precise finiteness diagnostic.
374            let lit = flatten_epistemic_literal(&lit)?;
375            let literal_index = epistemic_literals.len();
376            let augmented_head_terms = augmented_eir_head_terms(rule);
377            tuple_membership_bindings.push(EpistemicTupleMembershipBinding {
378                literal_index,
379                reduction_index,
380                predicate: lit.atom.predicate.clone(),
381                arity: lit.atom.arity,
382                key_columns: (0..lit.atom.arity).collect(),
383                bound_output_columns: bound_output_columns_for_terms(
384                    &lit.atom.terms,
385                    &augmented_head_terms,
386                ),
387                key_terms: lit.atom.terms.clone(),
388                op: lit.op,
389                negated: lit.negated,
390            });
391            solver_assumption_bindings.push(EpistemicSolverAssumptionBinding {
392                literal_index,
393                reduction_index,
394                predicate: lit.atom.predicate.clone(),
395                arity: lit.atom.arity,
396                terms: lit.atom.terms.clone(),
397                op: lit.op,
398                negated: lit.negated,
399            });
400            epistemic_literals.push(lit);
401        }
402        reductions.push(EpistemicReductionPlan {
403            rule_index,
404            head_predicate: rule.head.predicate.clone(),
405            public_head_arity: rule.head.terms.len(),
406            relational_body_atoms,
407            wcoj_status: wcoj_status_for_reduction(
408                relational_body_atoms,
409                has_negated_relational_atom,
410            ),
411        });
412    }
413
414    if epistemic_literals.is_empty() {
415        return Err(xlog_core::XlogError::UnsupportedEpistemicConstruct {
416            construct: "epistemic GPU execution plan".to_string(),
417            context: "requires at least one epistemic literal".to_string(),
418        });
419    }
420
421    // World-view integrity constraints constrain accepted candidate world views.
422    // Each in-fragment constraint epistemic literal becomes a first-class
423    // epistemic literal sharing an existing reduction's active-model context, so
424    // its modal value is evaluated by the same GPU world-view validation path as
425    // rule-body modal literals. Out-of-fragment constraint shapes fail closed.
426    let constraints = lower_epistemic_constraints(
427        &eir,
428        &mut epistemic_literals,
429        &reductions,
430        &mut tuple_membership_bindings,
431        &mut solver_assumption_bindings,
432    )?;
433
434    let final_output_columns = final_output_columns_for_eir(&eir);
435    let gpu_plan = EpistemicGpuPlan::new(eir.mode, epistemic_literals, reductions)
436        .with_tuple_membership_bindings(tuple_membership_bindings)
437        .with_constraints(constraints)
438        .with_final_output_columns(final_output_columns)
439        .with_solver_contract(EpistemicSolverServiceContract::production_default(
440            solver_assumption_bindings,
441        ));
442    gpu_plan.validate_tuple_membership_bindings()?;
443    gpu_plan.validate_solver_contract()?;
444    gpu_plan.validate_constraints()?;
445    Ok(gpu_plan)
446}
447
448/// Lower in-fragment epistemic integrity constraints into first-class epistemic
449/// literals and return the per-constraint world-view constraint plans.
450///
451/// Each constraint epistemic literal is appended to `epistemic_literals` and
452/// given a tuple-membership binding plus solver assumption binding attached to
453/// the final rule reduction's active-model context. The constraint body's
454/// conjunction (over the appended literal indices) is what the device kernel
455/// rejects when it holds in an accepted world view.
456///
457/// Fail-closed (typed, with source context) when:
458/// - no rule reduction exists to host the constraint's modal evaluation;
459/// - a constraint body mixes relational/comparison/binding literals with the
460///   epistemic literals (only pure-modal constraint bodies are in fragment);
461/// - a constraint epistemic atom carries a non-ground tuple key (headless
462///   constraints have no reduced output column to bind variables against).
463fn lower_epistemic_constraints(
464    eir: &EirProgram,
465    epistemic_literals: &mut Vec<EirEpistemicLiteral>,
466    reductions: &[EpistemicReductionPlan],
467    tuple_membership_bindings: &mut Vec<EpistemicTupleMembershipBinding>,
468    solver_assumption_bindings: &mut Vec<EpistemicSolverAssumptionBinding>,
469) -> Result<Vec<EpistemicConstraintPlan>> {
470    let mut constraint_plans = Vec::new();
471    for (constraint_index, constraint) in eir.constraints.iter().enumerate() {
472        let has_epistemic = constraint
473            .body
474            .iter()
475            .any(|lit| matches!(lit, EirBodyLiteral::Epistemic(_)));
476        if !has_epistemic {
477            // Purely relational constraints are handled by the reduced ordinary
478            // runtime plan; they are not world-view constraints.
479            continue;
480        }
481
482        if reductions.is_empty() {
483            return Err(XlogError::UnsupportedEpistemicConstruct {
484                construct: "epistemic GPU world-view constraint".to_string(),
485                context: format!(
486                    "constraint[{constraint_index}] is an epistemic integrity constraint but the \
487                     program has no epistemic rule to host its world-view evaluation; add an \
488                     epistemic rule whose reduced model provides the accepted world view, or \
489                     express the constraint over an existing epistemic rule"
490                ),
491            });
492        }
493        // Attach constraint modal evaluation to the final rule reduction's
494        // active-model context. The reduction's reduced output drives the
495        // `has_reduced_output` active-model gate used by world-view validation.
496        let reduction_index = reductions.len() - 1;
497
498        // First pass: flatten every epistemic literal (structured finite+typed
499        // keys reduce element-wise to scalar GPU key columns) and reject any
500        // non-epistemic body literal up front, so variable-multiplicity counting
501        // below sees the final flattened key shape. A non-epistemic literal makes
502        // the whole constraint out of fragment.
503        let mut flattened_literals = Vec::new();
504        for lit in &constraint.body {
505            match lit {
506                EirBodyLiteral::Epistemic(lit) => {
507                    flattened_literals.push(flatten_epistemic_literal(lit)?);
508                }
509                EirBodyLiteral::Relational { .. }
510                | EirBodyLiteral::Constraint
511                | EirBodyLiteral::Binding => {
512                    return Err(XlogError::UnsupportedEpistemicConstruct {
513                        construct: "epistemic GPU world-view constraint".to_string(),
514                        context: format!(
515                            "constraint[{constraint_index}] mixes non-epistemic body literals with \
516                             modal literals; world-view integrity constraints currently support \
517                             pure know/possible conjunctions so the constraint can be evaluated \
518                             against accepted world views without an ordinary-RIR rewrite"
519                        ),
520                    });
521                }
522            }
523        }
524
525        // Variable-keyed world-view constraints (`:- know p(X).`) range the key
526        // variable EXISTENTIALLY over the modal relation's tuple-key domain: the
527        // world view is pruned iff there EXISTS a binding for which the body
528        // holds. A constraint-local variable that occurs EXACTLY ONCE across the
529        // whole constraint body carries no join obligation, so it lowers to an
530        // ANONYMOUS wildcard key column — the existing GPU wildcard tuple-key
531        // matcher then ranges it over every accepted tuple, giving exact
532        // existential semantics with no host scan and no reduced head column.
533        //
534        // A variable that occurs MORE THAN ONCE (shared across literals as a join
535        // key `:- know p(X), possible q(X).`, or repeated within one literal as a
536        // diagonal `:- know p(X, X).`) cannot collapse to independent wildcards
537        // without weakening the constraint, so it fails closed here as unimplemented
538        // scope. This is finite+typed, NOT a finiteness/resource bound: the
539        // diagnostic stays a plain UnsupportedEpistemicConstruct, never a
540        // ResourceExhausted, so it is not mistaken for an unbounded-domain wall.
541        let mut variable_occurrences: std::collections::BTreeMap<String, usize> =
542            std::collections::BTreeMap::new();
543        for lit in &flattened_literals {
544            for term in &lit.atom.terms {
545                if let EirTerm::Variable(name) = term {
546                    *variable_occurrences.entry(name.clone()).or_insert(0) += 1;
547                }
548            }
549        }
550
551        let mut literal_indices = Vec::new();
552        for lit in flattened_literals {
553            // Anonymize single-occurrence constraint-local variables into wildcard
554            // key columns; reject shared/repeated variables (multiplicity > 1).
555            let mut anonymized_terms = Vec::with_capacity(lit.atom.terms.len());
556            for term in &lit.atom.terms {
557                match term {
558                    EirTerm::Integer(_) | EirTerm::Symbol(_) | EirTerm::Anonymous => {
559                        anonymized_terms.push(term.clone());
560                    }
561                    EirTerm::Variable(name) => {
562                        if variable_occurrences.get(name).copied().unwrap_or(0) > 1 {
563                            return Err(XlogError::UnsupportedEpistemicConstruct {
564                                construct: "epistemic GPU world-view constraint".to_string(),
565                                context: format!(
566                                    "constraint[{constraint_index}] reuses tuple-key variable \
567                                     {name} across literals/positions; shared-variable epistemic \
568                                     constraint joins (`:- know p(X), q(X).` / diagonal \
569                                     `:- know p(X, X).`) are not yet implemented for GPU world-view \
570                                     pruning. Single-occurrence variable keys (`:- know p(X).`) are \
571                                     supported and range existentially over the modal relation"
572                                ),
573                            });
574                        }
575                        // A NEGATED variable-keyed literal cannot collapse to a
576                        // wildcard: the wildcard computes `not (EXISTS X: know p(X))`
577                        // = `forall X: not know p(X)`, but a constraint variable is
578                        // EXISTENTIAL, so the body should fire on `EXISTS X: not
579                        // know p(X)`. forall-not != exists-not, so the wildcard would
580                        // mis-prune (it would prune iff p is EMPTY). Fail closed —
581                        // finite+typed UNIMPLEMENTED scope, NOT a finiteness bound, so
582                        // a plain UnsupportedEpistemicConstruct (never ResourceExhausted).
583                        // Negated ALL-GROUND constraint literals are unaffected (they
584                        // bind no variable, no quantifier flip — the EGB-04 path).
585                        //
586                        // Reaching here, `name` is SINGLE-occurrence (the multiplicity > 1
587                        // arm above already returned) AND appears under negation — so it has
588                        // NO positive binder and is NOT range-restricted. This is exactly the
589                        // unsafe shape ordinary Datalog rejects (`:- not r(X).`), so emit the
590                        // analogous NAF safety error rather than implying a missing feature.
591                        // The meaningful negated form `:- q(X), not know p(X).` binds X with a
592                        // positive literal (multiplicity > 1) and exits via the shared-variable
593                        // path above, so it never reaches this branch.
594                        if lit.negated {
595                            return Err(XlogError::Compilation(format!(
596                                "v0.8.5 naf error: unbound variable {name} in negated modal atom \
597                                 {}/{} in constraint[{constraint_index}]; bind it before not with \
598                                 a positive atom, or use '_' for existential positions",
599                                lit.atom.predicate, lit.atom.arity
600                            )));
601                        }
602                        // Single occurrence, POSITIVE: existential over the relation
603                        // domain == wildcard. Drop the variable identity (no join, no
604                        // head column to bind), routing this column through the GPU
605                        // wildcard tuple-key matcher.
606                        anonymized_terms.push(EirTerm::Anonymous);
607                    }
608                    other => {
609                        return Err(XlogError::UnsupportedEpistemicConstruct {
610                            construct: "epistemic GPU world-view constraint".to_string(),
611                            context: format!(
612                                "constraint[{constraint_index}] uses {} {}/{} with an unsupported \
613                                 tuple-key term {other:?}; headless world-view constraints support \
614                                 ground (integer/symbol) and single-occurrence variable/anonymous \
615                                 modal atoms",
616                                eir_epistemic_literal_label(&lit),
617                                lit.atom.predicate,
618                                lit.atom.arity
619                            ),
620                        });
621                    }
622                }
623            }
624            // Rebuild the literal with anonymized terms so the stored literal, its
625            // tuple-membership binding key_terms, and its solver assumption binding
626            // terms all carry the SAME shape (the plan validator requires
627            // binding.key_terms == literal.atom.terms).
628            let mut lit = lit;
629            lit.atom.terms = anonymized_terms;
630
631            let literal_index = epistemic_literals.len();
632            let bound_output_columns = vec![None; lit.atom.arity];
633            tuple_membership_bindings.push(EpistemicTupleMembershipBinding {
634                literal_index,
635                reduction_index,
636                predicate: lit.atom.predicate.clone(),
637                arity: lit.atom.arity,
638                key_columns: (0..lit.atom.arity).collect(),
639                key_terms: lit.atom.terms.clone(),
640                bound_output_columns,
641                op: lit.op,
642                negated: lit.negated,
643            });
644            solver_assumption_bindings.push(EpistemicSolverAssumptionBinding {
645                literal_index,
646                reduction_index,
647                predicate: lit.atom.predicate.clone(),
648                arity: lit.atom.arity,
649                terms: lit.atom.terms.clone(),
650                op: lit.op,
651                negated: lit.negated,
652            });
653            epistemic_literals.push(lit);
654            literal_indices.push(literal_index);
655        }
656
657        constraint_plans.push(EpistemicConstraintPlan {
658            constraint_index,
659            literal_indices,
660        });
661    }
662    Ok(constraint_plans)
663}
664
665/// Structural classification of an epistemic program with respect to ordinary
666/// (non-modal) recursion.
667///
668/// Recursion through positive/negated body literals normally fails closed in an
669/// epistemic program because the single-pass world-view executor cannot iterate a
670/// fixpoint. The well-defined sub-fragment "Case A" — recursion lives in the
671/// ordinary predicate while every modal atom in a recursion-participating rule is a
672/// positive `know`/`possible` over an *invariant* relation (an EDB or a lower
673/// non-recursive, non-epistemic stratum) — is admitted instead: the modal atom's
674/// extension is fixed independent of the recursion, so it can be resolved to its
675/// gated relation and the reduced ordinary program iterated by the existing
676/// recursive/semi-naive engine.
677#[derive(Debug, Clone, PartialEq, Eq)]
678pub enum RecursiveEpistemicClass {
679    /// The program has no ordinary recursion among epistemic rules; the existing
680    /// single-pass epistemic world-view executor handles it.
681    NonRecursive,
682    /// Case A: ordinary recursion with every recursion-participating modal atom over
683    /// an invariant relation. Routed to the ordinary recursive engine after a
684    /// Case-A reduction (see [`reduce_case_a_epistemic_program_to_ordinary`]).
685    CaseA,
686    /// Case B: ordinary recursion where at least one POSITIVE `know`/`possible` modal
687    /// ranges over a NON-invariant relation that CO-EVOLVES with the recursion (the
688    /// modal target sits in the recursive SCC, or transitively depends on it). The
689    /// modal truth and the ordinary derivation are a single co-evolving founded least
690    /// fixpoint: resolving each positive modal to its (now recursive) ordinary atom and
691    /// iterating the existing semi-naive engine computes the FAEEL founded least
692    /// fixpoint directly — unfounded self-support is excluded by construction (the
693    /// least model of a positive program IS its founded model), so no separate
694    /// foundedness drop is needed. Routed exactly like Case A through
695    /// [`reduce_case_a_epistemic_program_to_ordinary`] and the ordinary recursive
696    /// engine.
697    ///
698    /// ADMISSION IS POLARITY/MODE-SCOPED (proved in
699    /// [`classify_recursive_epistemic_program`]): a NEGATED modal over a non-invariant
700    /// target is admitted when the reduced ordinary program is stratified; a genuine
701    /// negation cycle is delegated to the high-level GPU-backed WFS alternating-fixpoint
702    /// executor. A `possible` modal over a co-evolving target is
703    /// admitted under FAEEL as the founded least fixpoint and under G91 as the
704    /// compatibility self-support assumption.
705    CaseB,
706}
707
708/// Reject epistemic programs that contain ordinary (non-modal) recursion before the
709/// SINGLE-PASS GPU world-view planner.
710///
711/// [`plan_epistemic_gpu_execution`] builds a single-pass plan that evaluates each
712/// candidate world view exactly once; it cannot iterate a recursive fixpoint, so ANY
713/// ordinary recursion fails closed here — including the admissible Case-A fragment,
714/// which is handled by a SEPARATE path
715/// ([`try_reduce_case_a_recursive_epistemic_program`]) that delegates to the ordinary
716/// recursive engine and intercepts Case-A programs before this planner is reached. In
717/// production this guard therefore only ever sees non-recursive programs; it remains
718/// defense-in-depth for direct callers of the single-pass planner.
719///
720/// Self-support THROUGH a modal literal (e.g. `p() :- possible p().`) is NOT ordinary
721/// recursion: the modal edge is excluded from the dependency walk, so FAEEL/G91
722/// foundedness still governs those cases. Under FAEEL the unfounded head is excluded
723/// from the founded model by [`faeel_unfounded_self_support_rule_indices`] (the reduced
724/// base drops the circular self-support rule); under G91 the circular form is accepted.
725fn reject_recursive_epistemic_program(program: &Program) -> Result<()> {
726    match classify_recursive_epistemic_program(program) {
727        Ok(RecursiveEpistemicClass::NonRecursive) => Ok(()),
728        Ok(RecursiveEpistemicClass::CaseA | RecursiveEpistemicClass::CaseB) => {
729            Err(recursive_epistemic_rejection(
730                "an epistemic program contains ordinary recursion; the single-pass epistemic GPU \
731                 planner cannot iterate a recursive fixpoint. Case-A/Case-B recursive epistemic \
732                 programs are executed through the ordinary recursive engine via \
733                 `try_reduce_case_a_recursive_epistemic_program`, not this planner.",
734            ))
735        }
736        // Recursive shapes outside the admissible fragment already carry a specific
737        // typed diagnostic.
738        Err(err) => Err(err),
739    }
740}
741
742/// Classify an epistemic program's ordinary recursion as non-recursive or Case A.
743///
744/// Returns a typed [`XlogError::UnsupportedEpistemicConstruct`] for any recursive
745/// shape outside Case A (recursion through a derived/recursive or epistemic relation,
746/// a negated modal literal in a recursion-participating rule, etc.).
747pub fn classify_recursive_epistemic_program(program: &Program) -> Result<RecursiveEpistemicClass> {
748    let has_epistemic = program.rules.iter().any(|rule| {
749        rule.body
750            .iter()
751            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
752    });
753    if !has_epistemic {
754        // No epistemic literals: the ordinary recursive engine handles this program.
755        return Ok(RecursiveEpistemicClass::NonRecursive);
756    }
757
758    // Dependency edges from ORDINARY (positive/negated) body literals only; modal,
759    // comparison, and arithmetic literals do not contribute recursion edges here.
760    let mut deps: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
761    for rule in &program.rules {
762        let entry = deps.entry(rule.head.predicate.as_str()).or_default();
763        for lit in &rule.body {
764            if let BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) = lit {
765                entry.insert(atom.predicate.as_str());
766            }
767        }
768    }
769
770    fn reaches<'a>(
771        start: &'a str,
772        target: &str,
773        deps: &BTreeMap<&'a str, BTreeSet<&'a str>>,
774        seen: &mut BTreeSet<&'a str>,
775    ) -> bool {
776        let Some(next) = deps.get(start) else {
777            return false;
778        };
779        for &pred in next {
780            if pred == target {
781                return true;
782            }
783            if seen.insert(pred) && reaches(pred, target, deps, seen) {
784                return true;
785            }
786        }
787        false
788    }
789
790    // Collect the set of ordinary-recursive predicates (predicates that ordinarily
791    // depend on themselves through positive/negated body literals).
792    let recursive_predicates: BTreeSet<&str> = deps
793        .keys()
794        .copied()
795        .filter(|pred| reaches(pred, pred, &deps, &mut BTreeSet::new()))
796        .collect();
797
798    if recursive_predicates.is_empty() {
799        return Ok(RecursiveEpistemicClass::NonRecursive);
800    }
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    // Both classes use the SAME reduction (positive modal → positive ordinary atom,
819    // `reduce_case_a_epistemic_program_to_ordinary`), so the only structural difference
820    // is whether the resolved relation is fixed (A) or part of the SCC (B). The whole
821    // program is scanned (not only recursion-participating rules) because that blanket
822    // reduction rewrites EVERY modal literal.
823    //
824    // SOUNDNESS FLOOR:
825    //   * a NEGATED modal over a non-invariant target is admitted as Case B. If the
826    //     reduced program is stratified, ordinary stratified negation is enough; if it
827    //     contains a reduced cycle through negation, the high-level executor routes it
828    //     to GPU-backed WFS rather than host WFS.
829    //   * a `possible` modal over a co-evolving target under G91 is admitted as the
830    //     compatibility self-support assumption. FAEEL `possible` remains the founded
831    //     least fixpoint. (A non-recursive `possible` self-support stays NonRecursive
832    //     and is handled by the single-pass founded-extension path — item B.)
833    let invariant = InvariantRelations::analyze(program);
834    let mut saw_case_b = false;
835    // A NEGATED modal over a NON-invariant target is admissible after reduction. The
836    // high-level executor chooses ordinary stratified execution or GPU-backed WFS based
837    // on the reduced program's monotonicity.
838    let mut saw_negated_non_invariant_modal = false;
839    for rule in &program.rules {
840        for lit in &rule.body {
841            let BodyLiteral::Epistemic(modal) = lit else {
842                continue;
843            };
844            if invariant.is_invariant(&modal.atom.predicate) {
845                // Modal over an INVARIANT relation: admissible Case-A. A positive
846                // `know`/`possible` resolves to a positive ordinary join over the gated
847                // relation; a NEGATED `not know`/`not possible` over an invariant
848                // relation equals ordinary `not R` (the world view agrees with R on an
849                // invariant relation), an anti-join with NO modal gating.
850                continue;
851            }
852
853            // NON-invariant modal target: the gated relation co-evolves with the
854            // recursion.
855            if modal.negated {
856                // A NEGATED modal over a NON-invariant relation is the WALL A1 case.
857                // SOUNDNESS ARGUMENT (why stratification decides it): when the reduced
858                // ordinary program (`not know R` -> `not R`, `know R` -> `R`) is
859                // STRATIFIED, its perfect model is TOTAL and 2-valued. A total
860                // 2-valued model makes every modal target R 2-valued, so under FAEEL
861                // `know R == possible R == R` and `not know R == not possible R == not
862                // R` (the modal op stops mattering once R is determined -- the same
863                // equivalence example 29 proves for DETERMINED targets, generalized
864                // here to STRATIFIED targets). Replacing each modal by its ordinary
865                // atom therefore preserves truth values, so the stratified perfect
866                // model of the reduced program IS the FAEEL model. The 2-valued
867                // (stratified) property is the linchpin.
868                //
869                // When the reduced program is NOT stratified (a cycle through the
870                // negation), the sound semantics is the 3-valued WELL-FOUNDED model
871                // (R partly UNDEFINED). Host-side WFS / stable-model solving remains
872                // precluded by the no-host-solver lock, so the high-level executor
873                // delegates that reduced program to the GPU-backed WFS path.
874                saw_negated_non_invariant_modal = true;
875                saw_case_b = true;
876                continue;
877            }
878
879            // POSITIVE `know` (any mode), FAEEL `possible`, or G91 `possible` over a
880            // co-evolving target: admissible Case B. FAEEL/know resolve to the
881            // ordinary atom; G91 non-invariant `possible` is handled in the reduction
882            // as the compatibility self-support assumption.
883            saw_case_b = true;
884        }
885    }
886
887    // WALL A1 DISCRIMINATOR: a deferred negated-modal-over-non-invariant is accepted
888    // as Case B. The high-level executor inspects the reduced ordinary program: no
889    // negation cycle routes to ordinary stratified execution; a negation cycle routes
890    // to the GPU-backed WFS alternating-fixpoint plan.
891    if saw_negated_non_invariant_modal {
892        // Stratified reduced programs continue through the ordinary semi-naive path.
893        // Non-monotone reduced programs are handled by the high-level GPU compiler's
894        // WFS plan; host WFS is not an accepted execution fallback.
895        let _reduced = reduce_case_a_epistemic_program_to_ordinary(program);
896    }
897
898    // SOUNDNESS GUARD: a recursive epistemic program (Case A/B) routes through the PURE
899    // ordinary semi-naive engine (`LogicExecutionPlan::Ordinary`), which never runs the
900    // world-view integrity-constraint kernel; the Case-A/B reduction DROPS every
901    // constraint that contains a modal literal. For a NON-recursive program the
902    // single-pass world-view path evaluates those constraints, but on the recursive
903    // route a co-occurring epistemic constraint (`:- know X` / `:- not know X`) would be
904    // SILENTLY IGNORED, yielding a result that includes rows a valid world view forbids.
905    // That is an UNSOUND admission (worse than a rejection), so fail closed when an
906    // epistemic constraint co-occurs with recursion. (Non-recursive epistemic-constraint
907    // programs -- examples 10/34/35/36 -- never reach here; they classify NonRecursive
908    // and run the constraint kernel on the single-pass path.)
909    let has_epistemic_constraint = program.constraints.iter().any(|constraint| {
910        constraint
911            .body
912            .iter()
913            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
914    });
915    if has_epistemic_constraint {
916        return Err(recursive_epistemic_rejection(
917            "a recursive epistemic program carries an epistemic integrity constraint \
918             (`:- know ...` / `:- not know ...`). Recursive epistemic programs execute \
919             through the ordinary semi-naive engine, which does not run the world-view \
920             constraint kernel, and the recursive reduction would silently DROP the \
921             modal constraint -- yielding a result that ignores it. To keep results \
922             sound this fails closed rather than silently dropping the constraint. \
923             Remove the recursion or express the integrity constraint over a \
924             non-recursive (single-pass) epistemic relation.",
925        ));
926    }
927
928    if saw_case_b {
929        Ok(RecursiveEpistemicClass::CaseB)
930    } else {
931        Ok(RecursiveEpistemicClass::CaseA)
932    }
933}
934
935fn recursive_epistemic_rejection(context: &str) -> XlogError {
936    XlogError::UnsupportedEpistemicConstruct {
937        construct: "recursive epistemic program".to_string(),
938        context: context.to_string(),
939    }
940}
941
942/// Predicates whose extension is fixed independent of any ordinary recursion or
943/// epistemic literal in the program.
944///
945/// A predicate is invariant when it is EDB (defined only by ground facts) or its
946/// entire transitive ordinary-definition closure is free of epistemic literals and of
947/// ordinary recursion. Such a relation is computed once in a lower stratum, so a
948/// positive `know`/`possible` over it has a fixed gated extension that a recursive
949/// fixpoint can join against.
950struct InvariantRelations<'a> {
951    /// Ordinary (positive/negated) body-predicate edges per head predicate.
952    ordinary_deps: BTreeMap<&'a str, BTreeSet<&'a str>>,
953    /// Predicates whose definition (any defining non-fact rule) contains an epistemic
954    /// body literal.
955    epistemic_heads: BTreeSet<&'a str>,
956    /// Predicates defined by at least one non-fact rule (i.e. not pure EDB).
957    derived_heads: BTreeSet<&'a str>,
958}
959
960impl<'a> InvariantRelations<'a> {
961    fn analyze(program: &'a Program) -> Self {
962        let mut ordinary_deps: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
963        let mut epistemic_heads: BTreeSet<&str> = BTreeSet::new();
964        let mut derived_heads: BTreeSet<&str> = BTreeSet::new();
965        for rule in &program.rules {
966            if rule.body.is_empty() {
967                continue;
968            }
969            let head = rule.head.predicate.as_str();
970            derived_heads.insert(head);
971            let entry = ordinary_deps.entry(head).or_default();
972            for lit in &rule.body {
973                match lit {
974                    BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
975                        entry.insert(atom.predicate.as_str());
976                    }
977                    BodyLiteral::Epistemic(_) => {
978                        epistemic_heads.insert(head);
979                    }
980                    BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {}
981                }
982            }
983        }
984        Self {
985            ordinary_deps,
986            epistemic_heads,
987            derived_heads,
988        }
989    }
990
991    /// Whether `predicate`'s extension is fixed independent of the recursion.
992    fn is_invariant(&self, predicate: &str) -> bool {
993        let mut seen = BTreeSet::new();
994        self.is_invariant_inner(predicate, &mut seen)
995    }
996
997    fn is_invariant_inner<'b>(&'b self, predicate: &'b str, seen: &mut BTreeSet<&'b str>) -> bool {
998        if !seen.insert(predicate) {
999            // A cycle reaching `predicate` means recursion: not invariant.
1000            return false;
1001        }
1002        if !self.derived_heads.contains(predicate) {
1003            // Pure EDB relation: invariant by construction.
1004            return true;
1005        }
1006        if self.epistemic_heads.contains(predicate) {
1007            // Definition itself uses a modal literal: not a fixed lower stratum.
1008            return false;
1009        }
1010        match self.ordinary_deps.get(predicate) {
1011            None => true,
1012            Some(deps) => deps.iter().all(|dep| self.is_invariant_inner(dep, seen)),
1013        }
1014    }
1015}
1016
1017fn eir_epistemic_literal_label(lit: &xlog_ir::EirEpistemicLiteral) -> &'static str {
1018    match (lit.negated, lit.op) {
1019        (false, EirEpistemicOp::Know) => "know",
1020        (false, EirEpistemicOp::Possible) => "possible",
1021        (true, EirEpistemicOp::Know) => "not know",
1022        (true, EirEpistemicOp::Possible) => "not possible",
1023    }
1024}
1025
1026fn has_independent_founded_support(eir: &EirProgram, atom: &xlog_ir::EirAtom) -> bool {
1027    if atom.arity > 0 && !atom.terms.iter().all(eir_term_is_ground) {
1028        return false;
1029    }
1030
1031    let mut support_stack = Vec::new();
1032    has_independent_founded_support_inner(eir, atom, &mut support_stack)
1033}
1034
1035fn has_tuple_level_independent_founded_support(
1036    eir: &EirProgram,
1037    modal_rule: &xlog_ir::EirRule,
1038    atom: &xlog_ir::EirAtom,
1039) -> bool {
1040    if atom.arity == 0 {
1041        return false;
1042    }
1043
1044    let modal_domain = positive_relational_body_atoms(modal_rule);
1045    eir.rules.iter().any(|support_rule| {
1046        if !support_rule_head_matches_modal_atom(support_rule, atom) {
1047            return false;
1048        }
1049        let mut support_stack = vec![(atom.predicate.clone(), atom.arity)];
1050        if !eir_rule_has_independent_founded_body(eir, support_rule, &mut support_stack) {
1051            return false;
1052        }
1053        let Some(substitution) = head_substitution_to_atom(&support_rule.head, atom) else {
1054            return false;
1055        };
1056        let support_domain = positive_relational_body_atoms(support_rule);
1057        if support_domain.is_empty() {
1058            return false;
1059        }
1060        let Some(substituted_support_domain) = support_domain
1061            .iter()
1062            .map(|atom| substitute_eir_atom(atom, &substitution))
1063            .collect::<Option<Vec<_>>>()
1064        else {
1065            return false;
1066        };
1067        substituted_support_domain.iter().all(|support_atom| {
1068            modal_domain
1069                .iter()
1070                .any(|modal_atom| modal_atom == support_atom)
1071        })
1072    })
1073}
1074
1075fn positive_relational_body_atoms(rule: &xlog_ir::EirRule) -> Vec<xlog_ir::EirAtom> {
1076    rule.body
1077        .iter()
1078        .filter_map(|lit| match lit {
1079            EirBodyLiteral::Relational {
1080                negated: false,
1081                atom,
1082            } => Some(atom.clone()),
1083            _ => None,
1084        })
1085        .collect()
1086}
1087
1088fn support_rule_head_matches_modal_atom(rule: &xlog_ir::EirRule, atom: &xlog_ir::EirAtom) -> bool {
1089    rule.head.predicate == atom.predicate
1090        && rule.head.arity == atom.arity
1091        && head_substitution_to_atom(&rule.head, atom).is_some()
1092}
1093
1094fn head_substitution_to_atom(
1095    head: &xlog_ir::EirAtom,
1096    atom: &xlog_ir::EirAtom,
1097) -> Option<BTreeMap<String, EirTerm>> {
1098    if head.predicate != atom.predicate || head.arity != atom.arity {
1099        return None;
1100    }
1101    let mut substitution = BTreeMap::new();
1102    for (head_term, atom_term) in head.terms.iter().zip(&atom.terms) {
1103        match head_term {
1104            EirTerm::Variable(name) => match substitution.get(name) {
1105                Some(existing) if existing != atom_term => return None,
1106                Some(_) => {}
1107                None => {
1108                    substitution.insert(name.clone(), atom_term.clone());
1109                }
1110            },
1111            EirTerm::Anonymous => return None,
1112            other if other == atom_term => {}
1113            _ => return None,
1114        }
1115    }
1116    Some(substitution)
1117}
1118
1119fn substitute_eir_atom(
1120    atom: &xlog_ir::EirAtom,
1121    substitution: &BTreeMap<String, EirTerm>,
1122) -> Option<xlog_ir::EirAtom> {
1123    let terms = atom
1124        .terms
1125        .iter()
1126        .map(|term| substitute_eir_term(term, substitution))
1127        .collect::<Option<Vec<_>>>()?;
1128    Some(xlog_ir::EirAtom {
1129        predicate: atom.predicate.clone(),
1130        arity: atom.arity,
1131        terms,
1132    })
1133}
1134
1135fn substitute_eir_term(
1136    term: &EirTerm,
1137    substitution: &BTreeMap<String, EirTerm>,
1138) -> Option<EirTerm> {
1139    match term {
1140        EirTerm::Variable(name) => Some(
1141            substitution
1142                .get(name)
1143                .cloned()
1144                .unwrap_or_else(|| term.clone()),
1145        ),
1146        EirTerm::Anonymous => None,
1147        EirTerm::List(items) => items
1148            .iter()
1149            .map(|item| substitute_eir_term(item, substitution))
1150            .collect::<Option<Vec<_>>>()
1151            .map(EirTerm::List),
1152        EirTerm::Cons { head, tail } => Some(EirTerm::Cons {
1153            head: Box::new(substitute_eir_term(head, substitution)?),
1154            tail: Box::new(substitute_eir_term(tail, substitution)?),
1155        }),
1156        EirTerm::Compound { functor, args } => Some(EirTerm::Compound {
1157            functor: functor.clone(),
1158            args: args
1159                .iter()
1160                .map(|arg| substitute_eir_term(arg, substitution))
1161                .collect::<Option<Vec<_>>>()?,
1162        }),
1163        EirTerm::Aggregate { .. } => None,
1164        EirTerm::Integer(_)
1165        | EirTerm::FloatBits(_)
1166        | EirTerm::String(_)
1167        | EirTerm::Symbol(_)
1168        | EirTerm::PredRef(_) => Some(term.clone()),
1169    }
1170}
1171
1172fn has_independent_founded_support_inner(
1173    eir: &EirProgram,
1174    atom: &xlog_ir::EirAtom,
1175    support_stack: &mut Vec<(String, usize)>,
1176) -> bool {
1177    if atom.arity > 0 && !atom.terms.iter().all(eir_term_is_ground) {
1178        return false;
1179    }
1180
1181    let key = (atom.predicate.clone(), atom.arity);
1182    if support_stack.iter().any(|ancestor| ancestor == &key) {
1183        return false;
1184    }
1185    support_stack.push(key);
1186
1187    let supported = eir.rules.iter().any(|rule| {
1188        let Some(substitution) = head_substitution_to_atom(&rule.head, atom) else {
1189            return false;
1190        };
1191        eir_rule_has_independent_founded_body_with_substitution(
1192            eir,
1193            rule,
1194            &substitution,
1195            support_stack,
1196        )
1197    });
1198
1199    support_stack.pop();
1200    supported
1201}
1202
1203fn eir_rule_has_independent_founded_body(
1204    eir: &EirProgram,
1205    rule: &xlog_ir::EirRule,
1206    support_stack: &mut Vec<(String, usize)>,
1207) -> bool {
1208    eir_rule_has_independent_founded_body_with_substitution(
1209        eir,
1210        rule,
1211        &BTreeMap::new(),
1212        support_stack,
1213    )
1214}
1215
1216fn eir_rule_has_independent_founded_body_with_substitution(
1217    eir: &EirProgram,
1218    rule: &xlog_ir::EirRule,
1219    substitution: &BTreeMap<String, EirTerm>,
1220    support_stack: &mut Vec<(String, usize)>,
1221) -> bool {
1222    rule.body.iter().all(|lit| match lit {
1223        EirBodyLiteral::Epistemic(_) => false,
1224        EirBodyLiteral::Relational { negated: true, .. } => false,
1225        EirBodyLiteral::Relational {
1226            negated: false,
1227            atom,
1228        } => {
1229            let Some(atom) = substitute_eir_atom(atom, substitution) else {
1230                return false;
1231            };
1232            let dependency_key = (atom.predicate.clone(), atom.arity);
1233            if support_stack
1234                .iter()
1235                .any(|ancestor| ancestor == &dependency_key)
1236            {
1237                return false;
1238            }
1239            if !eir
1240                .rules
1241                .iter()
1242                .any(|rule| head_substitution_to_atom(&rule.head, &atom).is_some())
1243            {
1244                return true;
1245            }
1246            has_independent_founded_support_inner(eir, &atom, support_stack)
1247        }
1248        EirBodyLiteral::Constraint | EirBodyLiteral::Binding => true,
1249    })
1250}
1251
1252fn eir_term_is_ground(term: &EirTerm) -> bool {
1253    match term {
1254        EirTerm::Variable(_) | EirTerm::Anonymous | EirTerm::Aggregate { .. } => false,
1255        EirTerm::Integer(_) | EirTerm::FloatBits(_) | EirTerm::String(_) | EirTerm::Symbol(_) => {
1256            true
1257        }
1258        EirTerm::List(items) => items.iter().all(eir_term_is_ground),
1259        EirTerm::Cons { head, tail } => eir_term_is_ground(head) && eir_term_is_ground(tail),
1260        EirTerm::Compound { args, .. } => args.iter().all(eir_term_is_ground),
1261        EirTerm::PredRef(_) => true,
1262    }
1263}
1264
1265/// Compile an epistemic program into its GPU contract and reduced runtime plan.
1266///
1267/// This is the first production-lowering boundary for epistemic execution. It
1268/// removes epistemic literals only after `plan_epistemic_gpu_execution` proves
1269/// the explicit EIR/GPU semantic contract, then sends the ordinary reduced
1270/// program through the same compiler, optimizer, helper-splitting, and WCOJ
1271/// promotion pipeline used by non-epistemic programs.
1272pub fn compile_epistemic_gpu_execution(program: &Program) -> Result<EpistemicExecutablePlan> {
1273    compile_epistemic_gpu_execution_with_stats_snapshot(program, None)
1274}
1275
1276/// Compile an epistemic program with an optional production statistics snapshot.
1277///
1278/// This preserves the W2.x/W38-B planner contract for reduced ordinary bodies:
1279/// cardinality, selectivity, heat, prefix-degree, sorted-layout, and
1280/// helper-splitting decisions are owned by the existing production compiler
1281/// pipeline rather than by an epistemic side planner.
1282pub fn compile_epistemic_gpu_execution_with_stats_snapshot(
1283    program: &Program,
1284    stats_snapshot: Option<&StatsSnapshot>,
1285) -> Result<EpistemicExecutablePlan> {
1286    compile_epistemic_gpu_execution_inner(program, stats_snapshot, false)
1287}
1288
1289/// Lower an epistemic program to its GPU contract and reduced runtime plan.
1290///
1291/// When `allow_multiple_output_heads` is false (the default monolithic and
1292/// single-head split path) the single-output-buffer contract
1293/// ([`require_single_epistemic_output_relation`]) is enforced. When true, the
1294/// caller has proven the component is a JOINT-SOLVABLE coalesced multi-head
1295/// component (see [`classify_cross_component_modal_coupling`]): one candidate
1296/// enumeration + world-view validation over the combined modal literals, with
1297/// each head materialized against the shared accepted world view at runtime.
1298fn compile_epistemic_gpu_execution_inner(
1299    program: &Program,
1300    stats_snapshot: Option<&StatsSnapshot>,
1301    allow_multiple_output_heads: bool,
1302) -> Result<EpistemicExecutablePlan> {
1303    let gpu_plan = plan_epistemic_gpu_execution(program)?;
1304    if !allow_multiple_output_heads {
1305        require_single_epistemic_output_relation(&gpu_plan)?;
1306    }
1307    // JOINT-SOLVING multi-head materialization now projects each coupled head by ITS
1308    // OWN `public_head_arity` (see `final_output_columns_for_materialization`): each
1309    // head is materialized from its own reduced relation buffer with its own
1310    // reduction row-filter, reading only the store/world-view boundary. An augmented
1311    // multi-head component (a modal-literal variable absent from a head) therefore
1312    // projects every head's public tuple shape soundly, including coupled heads of
1313    // DIFFERING arity. The former blanket fail-closed guard on
1314    // `final_output_columns.is_some()` over multiple heads is no longer needed.
1315    let reduced_program = reduce_epistemic_program_to_ordinary(program);
1316    let mut compiler = Compiler::new();
1317    let reduced_runtime_plan =
1318        compiler.compile_program_with_stats_snapshot(&reduced_program, stats_snapshot)?;
1319    let relation_ids = compiler
1320        .rel_ids()
1321        .iter()
1322        .map(|(name, rel)| (name.clone(), *rel))
1323        .collect();
1324
1325    Ok(EpistemicExecutablePlan {
1326        gpu_plan,
1327        relation_ids,
1328        reduced_runtime_plan,
1329    })
1330}
1331
1332/// Validate a Case-A recursive epistemic program and return its ordinary reduction.
1333///
1334/// This is the Case-A counterpart to [`compile_epistemic_gpu_execution`]: instead of
1335/// building a single-pass GPU world-view plan, it proves the program is admissible
1336/// Case A and resolves it to an ordinary recursive program for the existing fixpoint
1337/// engine. Validation still flows through the EIR boundary ([`build_eir`]) via
1338/// [`classify_recursive_epistemic_program`], which already requires EVERY modal literal
1339/// to range over an INVARIANT relation. A direct modal self-support over the recursive
1340/// head (`possible p` with `p` the recursive/derived head) ranges over a NON-invariant
1341/// relation and is therefore rejected as non-Case-A upstream — so unfounded modal
1342/// self-support never reaches this reduction. Only EXECUTION routes through the
1343/// ordinary engine.
1344///
1345/// Returns `Ok(Some(reduced))` when the program is admissible Case A, `Ok(None)` when
1346/// the program has no ordinary recursion (the caller should use the single-pass
1347/// epistemic path), and a typed error for any non-Case-A recursive shape.
1348pub fn try_reduce_case_a_recursive_epistemic_program(program: &Program) -> Result<Option<Program>> {
1349    match classify_recursive_epistemic_program(program)? {
1350        RecursiveEpistemicClass::NonRecursive => Ok(None),
1351        // Case A and Case B share the same reduction: each positive `know`/`possible`
1352        // modal is resolved to its ordinary atom. In Case A that atom is invariant (a
1353        // fixed gated relation); in Case B it co-evolves inside the recursive SCC, so
1354        // the semi-naive least fixpoint computes the founded co-evolving result. The
1355        // reduction is identical — only the dependency shape of the resolved relation
1356        // differs — so both route through the ordinary recursive engine.
1357        RecursiveEpistemicClass::CaseA | RecursiveEpistemicClass::CaseB => {
1358            Ok(Some(reduce_case_a_epistemic_program_to_ordinary(program)))
1359        }
1360    }
1361}
1362
1363fn require_single_epistemic_output_relation(gpu_plan: &EpistemicGpuPlan) -> Result<()> {
1364    let output_relations: BTreeSet<&str> = gpu_plan
1365        .reductions
1366        .iter()
1367        .map(|reduction| reduction.head_predicate.as_str())
1368        .collect();
1369    if output_relations.len() > 1 {
1370        return Err(XlogError::UnsupportedEpistemicConstruct {
1371            construct: "epistemic GPU final output relation".to_string(),
1372            context: format!(
1373                "single-plan GPU execution materializes one final output buffer, but reductions \
1374                 target multiple head predicates {:?}; use split GPU execution for independent \
1375                 epistemic outputs",
1376                output_relations
1377            ),
1378        });
1379    }
1380    Ok(())
1381}
1382
1383fn reject_epistemic_constraints(program: &Program) -> Result<()> {
1384    reject_epistemic_constraints_for_boundary(program, "epistemic GPU constraint", "GPU lowering")
1385}
1386
1387fn reject_gpt_epistemic_constraints(program: &Program) -> Result<()> {
1388    reject_epistemic_constraints_for_boundary(
1389        program,
1390        "epistemic GPT constraint",
1391        "GPT candidate testing",
1392    )
1393}
1394
1395fn reject_epistemic_constraints_for_boundary(
1396    program: &Program,
1397    construct: &str,
1398    boundary: &str,
1399) -> Result<()> {
1400    for (constraint_index, constraint) in program.constraints.iter().enumerate() {
1401        for lit in &constraint.body {
1402            let BodyLiteral::Epistemic(lit) = lit else {
1403                continue;
1404            };
1405            return Err(XlogError::UnsupportedEpistemicConstruct {
1406                construct: construct.to_string(),
1407                context: format!(
1408                    "constraint[{constraint_index}] contains unsupported {} {}/{}; epistemic integrity constraints must be represented explicitly before {boundary}",
1409                    epistemic_literal_label(lit),
1410                    lit.atom.predicate,
1411                    lit.atom.arity()
1412                ),
1413            });
1414        }
1415    }
1416    Ok(())
1417}
1418
1419fn epistemic_literal_label(lit: &EpistemicLiteral) -> &'static str {
1420    match (lit.negated, lit.op) {
1421        (false, EpistemicOp::Know) => "know",
1422        (false, EpistemicOp::Possible) => "possible",
1423        (true, EpistemicOp::Know) => "not know",
1424        (true, EpistemicOp::Possible) => "not possible",
1425    }
1426}
1427
1428/// Flatten a modal literal's structured key terms, returning a literal whose
1429/// atom carries the FLATTENED arity/terms.
1430///
1431/// This is the single normalization point for structured modal keys: the stored
1432/// epistemic literal, its tuple-membership binding, and its solver assumption
1433/// binding are all derived from the same flattened atom, so the plan validators
1434/// (which require `binding.arity == literal.atom.arity` and `binding.key_terms ==
1435/// literal.atom.terms`) stay consistent and the runtime matches the modal
1436/// relation's real column tuple. Scalar-only keys are returned unchanged.
1437fn flatten_epistemic_literal(lit: &EirEpistemicLiteral) -> Result<EirEpistemicLiteral> {
1438    let (arity, terms, _key_columns) =
1439        flatten_structured_key_terms(&lit.atom.predicate, &lit.atom.terms)?;
1440    Ok(EirEpistemicLiteral {
1441        op: lit.op,
1442        negated: lit.negated,
1443        atom: xlog_ir::EirAtom {
1444            predicate: lit.atom.predicate.clone(),
1445            arity,
1446            terms,
1447        },
1448    })
1449}
1450
1451/// Whether a term encodes directly into one scalar/Symbol GPU key column.
1452///
1453/// These are the leaf forms the device tuple-key matcher already handles per
1454/// column: bound variables (BOUND_OUTPUT), anonymous wildcards (WILDCARD), and
1455/// ground integer/float/string/symbol literals (GROUND).
1456fn eir_term_is_scalar_key_element(term: &EirTerm) -> bool {
1457    matches!(
1458        term,
1459        EirTerm::Variable(_)
1460            | EirTerm::Anonymous
1461            | EirTerm::Integer(_)
1462            | EirTerm::FloatBits(_)
1463            | EirTerm::String(_)
1464            | EirTerm::Symbol(_)
1465    )
1466}
1467
1468/// Flatten a modal atom's key terms ELEMENT-WISE into a flat list of scalar key
1469/// terms plus the matching `0..n` key-column indices.
1470///
1471/// A STRUCTURED finite+typed key term -- a fixed-arity list `[a, b]` or compound
1472/// `f(a, b)` whose elements are each scalar/Symbol-typed -- is expanded into its
1473/// elements, each of which becomes one GPU key column. The flattened arity must
1474/// equal the modal relation's arity (the runtime arity check enforces that
1475/// downstream). Scalar terms pass through unchanged.
1476///
1477/// Genuinely UNBOUNDED or UNTYPED structured forms (a `cons` with a non-list
1478/// tail, a nested structure, a `predref`, or an `aggregate`) carry no fixed,
1479/// typed column set and stay rejected with a precise finiteness/resource
1480/// diagnostic -- NOT an "unsupported construct".
1481fn flatten_structured_key_terms(
1482    predicate: &str,
1483    terms: &[EirTerm],
1484) -> Result<(usize, Vec<EirTerm>, Vec<usize>)> {
1485    let mut flattened: Vec<EirTerm> = Vec::with_capacity(terms.len());
1486    for term in terms {
1487        match term {
1488            EirTerm::List(items) => {
1489                flatten_structured_elements(predicate, "list", items, &mut flattened)?;
1490            }
1491            EirTerm::Compound { functor, args } => {
1492                flatten_structured_elements(
1493                    predicate,
1494                    &format!("compound {functor}/{}", args.len()),
1495                    args,
1496                    &mut flattened,
1497                )?;
1498            }
1499            EirTerm::Cons { .. } => {
1500                return Err(XlogError::ResourceExhausted {
1501                    context: format!(
1502                        "modal tuple-key for {predicate} uses a `cons` pattern whose tail length \
1503                         is not statically fixed, so it has no finite, typed GPU key-column set; \
1504                         bind it to a fixed-arity list literal `[a, b, ...]` instead"
1505                    ),
1506                    estimated_bytes: 0,
1507                    budget_bytes: 0,
1508                });
1509            }
1510            EirTerm::PredRef(name) => {
1511                return Err(XlogError::ResourceExhausted {
1512                    context: format!(
1513                        "modal tuple-key for {predicate} uses predref `{name}`, which has no \
1514                         finite, typed GPU key-column encoding"
1515                    ),
1516                    estimated_bytes: 0,
1517                    budget_bytes: 0,
1518                });
1519            }
1520            EirTerm::Aggregate { op, variable } => {
1521                return Err(XlogError::ResourceExhausted {
1522                    context: format!(
1523                        "modal tuple-key for {predicate} uses aggregate `{op}({variable})`, whose \
1524                         value is not a finite, typed GPU key-column tuple"
1525                    ),
1526                    estimated_bytes: 0,
1527                    budget_bytes: 0,
1528                });
1529            }
1530            scalar => flattened.push(scalar.clone()),
1531        }
1532    }
1533
1534    let arity = flattened.len();
1535    let key_columns = (0..arity).collect();
1536    Ok((arity, flattened, key_columns))
1537}
1538
1539/// Splice the elements of a fixed-arity structured key term into `flattened`.
1540///
1541/// Each element must itself be a scalar/Symbol key element; a nested structure
1542/// would need a column to hold its own sub-tuple, which a flat relation schema
1543/// cannot express, so it is rejected with a precise finiteness diagnostic.
1544fn flatten_structured_elements(
1545    predicate: &str,
1546    shape: &str,
1547    elements: &[EirTerm],
1548    flattened: &mut Vec<EirTerm>,
1549) -> Result<()> {
1550    for element in elements {
1551        if eir_term_is_scalar_key_element(element) {
1552            flattened.push(element.clone());
1553        } else {
1554            return Err(XlogError::ResourceExhausted {
1555                context: format!(
1556                    "modal tuple-key for {predicate} nests a non-scalar element {element:?} inside \
1557                     a {shape}; only fixed-arity structures of scalar/Symbol-typed elements have a \
1558                     finite, typed GPU key-column encoding"
1559                ),
1560                estimated_bytes: 0,
1561                budget_bytes: 0,
1562            });
1563        }
1564    }
1565    Ok(())
1566}
1567
1568fn bound_output_columns_for_terms(
1569    key_terms: &[EirTerm],
1570    output_terms: &[EirTerm],
1571) -> Vec<Option<usize>> {
1572    key_terms
1573        .iter()
1574        .map(|term| match term {
1575            EirTerm::Variable(variable) => output_terms.iter().position(
1576                |head_term| matches!(head_term, EirTerm::Variable(name) if name == variable),
1577            ),
1578            _ => None,
1579        })
1580        .collect()
1581}
1582
1583fn augmented_eir_head_terms(rule: &xlog_ir::EirRule) -> Vec<EirTerm> {
1584    let mut output_terms = rule.head.terms.clone();
1585    for lit in &rule.body {
1586        let EirBodyLiteral::Epistemic(lit) = lit else {
1587            continue;
1588        };
1589        // A modal key variable may be NESTED inside a structured key term
1590        // (`know p([X, Y])`), so flatten before collecting variables that need a
1591        // reduced output column to bind against. Flattening failures are surfaced
1592        // by the binding-construction path; here we fall back to the raw terms so
1593        // diagnostics remain anchored at that site.
1594        let key_terms = flatten_structured_key_terms(&lit.atom.predicate, &lit.atom.terms)
1595            .map(|(_, terms, _)| terms)
1596            .unwrap_or_else(|_| lit.atom.terms.clone());
1597        for term in &key_terms {
1598            let EirTerm::Variable(variable) = term else {
1599                continue;
1600            };
1601            if !output_terms
1602                .iter()
1603                .any(|head_term| matches!(head_term, EirTerm::Variable(name) if name == variable))
1604            {
1605                output_terms.push(EirTerm::Variable(variable.clone()));
1606            }
1607        }
1608    }
1609    output_terms
1610}
1611
1612fn final_output_columns_for_eir(eir: &EirProgram) -> Option<Vec<usize>> {
1613    let mut final_columns = Vec::new();
1614    let mut needs_projection = false;
1615    for rule in &eir.rules {
1616        if !rule
1617            .body
1618            .iter()
1619            .any(|lit| matches!(lit, EirBodyLiteral::Epistemic(_)))
1620        {
1621            continue;
1622        }
1623        let augmented_len = augmented_eir_head_terms(rule).len();
1624        if augmented_len > rule.head.terms.len() {
1625            needs_projection = true;
1626        }
1627        if final_columns.is_empty() {
1628            final_columns = (0..rule.head.terms.len()).collect();
1629        }
1630    }
1631    if needs_projection {
1632        Some(final_columns)
1633    } else {
1634        None
1635    }
1636}
1637
1638/// Indices (into `program.rules`) of FAEEL rules that are unfounded by circular modal
1639/// self-support and must be excluded from the reduced founded-model base.
1640///
1641/// A rule qualifies when (a) the program is in FAEEL mode, (b) the rule body contains a
1642/// modal literal `possible p`/`know p` over the rule's OWN head predicate/arity
1643/// (direct self-support), (c) that head has NO independent founded support
1644/// ([`has_independent_founded_support`]) and NO tuple-level founded support
1645/// ([`has_tuple_level_independent_founded_support`]), and (d) excluding the rule does
1646/// NOT silently elide a mode-independent safety failure — i.e. the head carries no
1647/// variable bound ONLY by the self-supporting modal. Condition (d) preserves the clean
1648/// `UnsafeVariable` honest-exit for pure nonzero self-support (`p(X) :- possible p(X)`)
1649/// in EVERY mode (G91 rejects it identically): dropping such a rule would replace a
1650/// precise safety diagnostic with a confusing materialization error.
1651///
1652/// Returns indices in ASCENDING order; callers must remove in DESCENDING order to keep
1653/// the remaining indices stable.
1654///
1655/// This is the structural foundedness DECISION; the founded EXTENSION is then computed
1656/// by the existing GPU world-view validation over the reduced base (no CPU semantic
1657/// solver). G91 mode never drops, so circular self-support stays accepted there — the
1658/// drop is exactly the FAEEL-vs-G91 mode difference.
1659fn faeel_unfounded_self_support_rule_indices(program: &Program) -> Vec<usize> {
1660    let Ok(eir) = build_eir(program) else {
1661        return Vec::new();
1662    };
1663    if eir.mode != EirEpistemicMode::Faeel {
1664        return Vec::new();
1665    }
1666    let mut indices = Vec::new();
1667    for (index, (rule, eir_rule)) in program.rules.iter().zip(&eir.rules).enumerate() {
1668        let modal_only_output_variables = modal_only_bound_output_variables(rule);
1669        let drop = eir_rule.body.iter().any(|lit| {
1670            let EirBodyLiteral::Epistemic(modal) = lit else {
1671                return false;
1672            };
1673            // Direct modal self-support over the rule's own head.
1674            if modal.atom.predicate != eir_rule.head.predicate
1675                || modal.atom.arity != eir_rule.head.arity
1676            {
1677                return false;
1678            }
1679            // Founded by an independent (non-circular) derivation: keep the rule; the
1680            // founded support proves the head, so it stays in the model.
1681            if has_independent_founded_support(&eir, &modal.atom)
1682                || has_tuple_level_independent_founded_support(&eir, eir_rule, &modal.atom)
1683            {
1684                return false;
1685            }
1686            // A head variable bound ONLY by this self-supporting modal would be unbound
1687            // (`UnsafeVariable`) in every mode once the modal is stripped: do NOT drop,
1688            // let the existing safety path raise the precise diagnostic.
1689            if modal
1690                .atom
1691                .terms
1692                .iter()
1693                .any(|term| matches!(term, EirTerm::Variable(name) if modal_only_output_variables.contains(name)))
1694            {
1695                return false;
1696            }
1697            true
1698        });
1699        if drop {
1700            indices.push(index);
1701        }
1702    }
1703    indices
1704}
1705
1706/// Return the ordinary runtime program produced after epistemic GPU planning.
1707///
1708/// Epistemic literals are removed only for the reduced production runtime
1709/// dispatch; callers must still plan and certify the explicit epistemic GPU
1710/// contract before using this reduced program.
1711///
1712/// The augmenting positive-modal resolve is gated on INVARIANT targets only (see the
1713/// body comment): for an invariant `R`, `know R`/`possible R` ranges exactly over
1714/// `R`'s extension, so resolving the modal into an ordinary join binds the augmented
1715/// output column WITHOUT leaking — and the GPU EGB-02 membership filter re-gates
1716/// post hoc. A determined-but-not-invariant target (an epistemic-derived head like a
1717/// multi-column `r`) is NOT resolved here, so its augmenting output variable stays
1718/// unbound and the reduced program fails closed at this strict (execution) entry
1719/// point. See [`reduce_epistemic_program_to_ordinary_for_stratified_schema`] for the
1720/// schema-only relaxation used by the stratified driver.
1721pub fn reduce_epistemic_program_to_ordinary(program: &Program) -> Program {
1722    reduce_epistemic_program_to_ordinary_inner(program, &BTreeSet::new())
1723}
1724
1725/// Schema-only reduction for the stratified epistemic driver.
1726///
1727/// Identical to [`reduce_epistemic_program_to_ordinary`] EXCEPT it also resolves an
1728/// augmenting positive modal whose target is epistemically DETERMINED (per
1729/// [`EpistemicallyDeterminedPredicates::analyze`]) but not invariant — e.g. a
1730/// multi-column determined head `r` in `out(X) :- node(X), know r(X, Y)`, where the
1731/// modal binds the augmented output column `Y`. This is used SOLELY to compute the
1732/// plan-wide relation SCHEMAS (column types/arities) for an
1733/// [`crate::EpistemicStratifiedPlan`]; the resolved positive atom over `r` supplies
1734/// `Y`'s declared column type so the schema compiler does not reject the augmented
1735/// `out(X, Y)` head as unsafe.
1736///
1737/// SOUNDNESS / NON-LEAK: a determined `r` IS gated into the store as a materialized
1738/// base relation by the LOWER stratum before the higher stratum runs (the stratified
1739/// executor's `materialize_epistemic_head_relation` at the STORE boundary), and the
1740/// higher stratum is compiled by `compile_stratum_plan` over a sub-program where
1741/// `r`'s defining rule is DROPPED — so there `r` is invariant and the EXISTING strict
1742/// resolve binds `Y` against the GATED `r` for execution. The determined-relaxed
1743/// resolve here therefore NEVER drives runtime data: it only types columns. It is not
1744/// used by the single/joint or Case-A EXECUTION reduce, so it cannot resolve a modal
1745/// into a join over an UN-gated candidate relation.
1746pub fn reduce_epistemic_program_to_ordinary_for_stratified_schema(program: &Program) -> Program {
1747    let determined = EpistemicallyDeterminedPredicates::analyze(program);
1748    reduce_epistemic_program_to_ordinary_inner(program, &determined.determined)
1749}
1750
1751/// Shared body of the epistemic-to-ordinary reduction.
1752///
1753/// `schema_only_determined_resolve` names predicates that are epistemically
1754/// DETERMINED and whose augmenting positive modal may additionally be resolved into a
1755/// positive ordinary atom for SCHEMA inference only (empty for the strict execution
1756/// reduce). The INVARIANT-target resolve is always active for both entry points.
1757fn reduce_epistemic_program_to_ordinary_inner(
1758    program: &Program,
1759    schema_only_determined_resolve: &BTreeSet<String>,
1760) -> Program {
1761    let mut reduced = program.clone();
1762
1763    // FAEEL FOUNDED-MODEL EXTENSION: a rule whose head is supported ONLY by circular
1764    // modal self-support (`possible p`/`know p` over its own head, with no independent
1765    // founded derivation) contributes nothing to the FAEEL founded model. Excluding the
1766    // rule from the reduced ordinary base is precisely the founded/equilibrium
1767    // semantics: the unfounded head is absent from the model rather than fabricated by
1768    // the stripped-modal `1=1` filler (which would wrongly found it, the G91 answer).
1769    //
1770    // This is the structural foundedness DECISION (compile-time, reusing the exact
1771    // `has_independent_founded_support` / `has_tuple_level_independent_founded_support`
1772    // predicates the legacy guard used) driving the EXTENSION COMPUTATION on the
1773    // GPU/runtime path: the dropped rule simply removes the unfounded head's founding
1774    // base, and the existing GPU world-view validation then accepts the empty/founded
1775    // candidate. G91 keeps the filler (no drop), so `possible p` stays accepted —
1776    // this drop IS the FAEEL-vs-G91 mode difference.
1777    //
1778    // SCOPE: the drop fires only for FAEEL mode. A rule whose head carries a variable
1779    // bound ONLY by the self-supporting modal is NOT dropped here; with the modal
1780    // stripped that variable is genuinely unbound (`UnsafeVariable`) in EVERY mode
1781    // (G91 included), so it must fall through to the existing safety path rather than
1782    // be silently elided. Dropping it would mask a mode-independent safety failure.
1783    for index in faeel_unfounded_self_support_rule_indices(program)
1784        .into_iter()
1785        .rev()
1786    {
1787        reduced.rules.remove(index);
1788    }
1789
1790    // AUGMENTING positive modals over INVARIANT relations are resolved into positive
1791    // ordinary join atoms (instead of being stripped) so the augmented head columns
1792    // they introduce are range-restricted in the reduced ordinary candidate program.
1793    //
1794    // An AUGMENTING modal carries a variable that is appended to the head by
1795    // `append_body_local_tuple_key_variables_to_head` (a modal-local variable absent
1796    // from the user-visible head, e.g. `Y` in `one_hop(X) :- node(X), know edge(X,
1797    // Y)`). After the modal is stripped, that augmented `Y` column has no binding, so
1798    // the reduced rule would be unsafe (`UnsafeVariable`). Resolving the positive
1799    // modal over its (invariant) gated relation into a positive ordinary atom binds
1800    // the column. This mirrors the proven-sound Case-A invariant resolution
1801    // (`reduce_case_a_epistemic_program_to_ordinary`): for an INVARIANT relation `R`,
1802    // `know R`/`possible R` ranges exactly over `R`'s extension, so the reduced
1803    // candidate join over `R` enumerates the correct augmented tuples and the GPU
1804    // EGB-02 membership filter then re-gates them against the accepted world view.
1805    //
1806    // STRICTLY SCOPED to keep the prohibition on resolving over still-modal relations
1807    // machine-checked: only POSITIVE modals (negated `not know`/`not possible` is an
1808    // anti-join that does NOT range-restrict, so it is never resolved) over INVARIANT
1809    // targets (a still-modal / epistemic-derived target is NOT invariant, so it is
1810    // never resolved — its augmenting variable stays unbound and the reduced program
1811    // fails closed). Non-augmenting modals keep the original strip-and-gate path, so
1812    // every existing single/joint pilot (16/18/09/19/21) is untouched.
1813    let invariant = InvariantRelations::analyze(program);
1814
1815    // Heads where a positive-invariant modal was ACTUALLY resolved into a positive
1816    // ordinary atom (i.e. a modal-only-bound output variable was genuinely augmented).
1817    // ONLY these heads' declarations/queries are reconciled to the augmented arity.
1818    // `append_body_local_tuple_key_variables_to_head` may spuriously append a
1819    // modal-local variable that is ALSO positively bound (e.g. `Y` in the recursive
1820    // `reach(X,Z) :- reach(X,Y), vertex(Z), know a(Y,Z)`), which must NOT trigger a
1821    // declaration bump — that head is materialized at its original arity by the
1822    // (Case-A) recursive engine, so bumping its declaration would corrupt the schema.
1823    let mut resolved_augmented_heads: BTreeSet<String> = BTreeSet::new();
1824
1825    for rule in &mut reduced.rules {
1826        // Head variables that NO non-epistemic positive body literal binds. After the
1827        // modal is stripped, an output (head) variable bound ONLY by the modal would
1828        // be unsafe in the reduced ordinary program. Computed BEFORE the head is
1829        // mutated by augmentation. (`append_body_local_tuple_key_variables_to_head`
1830        // appends modal-local variables to the head, so both already-present head
1831        // variables like `Y` in `pair(X,Y) :- ..possible edge(X,Y)` AND augmented
1832        // variables like `Y` in `one_hop(X) :- ..know edge(X,Y)` are covered here.)
1833        let modal_only_output_variables = modal_only_bound_output_variables(rule);
1834        append_body_local_tuple_key_variables_to_head(rule);
1835        let was_fact = rule.body.is_empty();
1836        let had_epistemic_body = rule
1837            .body
1838            .iter()
1839            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
1840        // Resolve a POSITIVE modal over an INVARIANT relation into a positive ordinary
1841        // join atom WHEN it is the sole binder of some output variable (so that output
1842        // variable is range-restricted in the reduced candidate program); strip every
1843        // other modal. For an invariant relation `R`, `know R`/`possible R` ranges
1844        // exactly over `R`'s extension, so the reduced join enumerates the correct
1845        // candidate tuples and the GPU EGB-02 filter re-gates against the accepted
1846        // world view. A NEGATED modal (anti-join) never binds and is never resolved; a
1847        // still-modal / epistemic-derived target is NOT invariant and is never
1848        // resolved, so its unbound output variable correctly fails closed downstream.
1849        let mut resolved_here = false;
1850        for lit in &mut rule.body {
1851            if let BodyLiteral::Epistemic(modal) = lit {
1852                // The target is resolvable when it is INVARIANT (always — proven-sound
1853                // for both schema and execution), OR — for SCHEMA inference only — when
1854                // it is epistemically DETERMINED. The determined relaxation is empty for
1855                // the strict execution reduce, so an execution-path reduce never
1856                // resolves a modal over a still-derived (un-gated) relation.
1857                let resolvable_target = invariant.is_invariant(&modal.atom.predicate)
1858                    || schema_only_determined_resolve.contains(&modal.atom.predicate);
1859                if !modal.negated
1860                    && resolvable_target
1861                    && modal_atom_binds_output_variable(modal, &modal_only_output_variables)
1862                {
1863                    *lit = BodyLiteral::Positive(modal.atom.clone());
1864                    resolved_here = true;
1865                }
1866            }
1867        }
1868        if resolved_here {
1869            resolved_augmented_heads.insert(rule.head.predicate.clone());
1870        }
1871        rule.body
1872            .retain(|lit| !matches!(lit, BodyLiteral::Epistemic(_)));
1873        if !was_fact && had_epistemic_body && rule.body.is_empty() {
1874            rule.body.push(BodyLiteral::Comparison(Comparison {
1875                left: Term::Integer(1),
1876                op: CompOp::Eq,
1877                right: Term::Integer(1),
1878            }));
1879        }
1880    }
1881    // Head augmentation appends modal-local columns to a genuinely-augmented rule head
1882    // (e.g. `one_hop(X)` becomes `one_hop(X, Y)`), so the reduced relation carries the
1883    // augmented columns needed for the GPU tuple-key membership gate. The predicate
1884    // DECLARATION must be widened to the augmented arity, or the runtime would union
1885    // the augmented rule output against the narrow declared (empty) stub and fail with
1886    // a schema mismatch. SCOPED to heads where the resolve actually fired (so a
1887    // spuriously-appended-but-positively-bound recursive head like `reach` is NOT
1888    // bumped). Infer each appended column's type from the resolved body atom.
1889    let augmented_heads =
1890        reconcile_augmented_head_declarations(&mut reduced, &resolved_augmented_heads);
1891
1892    // Drop reduced-program queries that reference an AUGMENTED head: the reduced
1893    // relation is now arity-bumped, so an original arity-N query over it would union
1894    // the arity-N query projection against the augmented relation and fail with a
1895    // schema mismatch. The user-visible query results for epistemic heads are
1896    // surfaced separately from the GPU gated buffers (`epistemic_result_to_query_
1897    // results`, projected to public arity), and the surfacing gate
1898    // (`queried_predicates`) reads the ORIGINAL program's queries, so dropping the
1899    // redundant reduced query here is inert for display and only removes the crash.
1900    // Non-augmented epistemic heads keep their arity-matched reduced queries untouched.
1901    if !augmented_heads.is_empty() {
1902        reduced
1903            .queries
1904            .retain(|query| !augmented_heads.contains(&query.atom.predicate));
1905    }
1906
1907    // Constraints that contain epistemic literals are world-view integrity
1908    // constraints: they constrain accepted candidate world views and are
1909    // evaluated by the GPU world-view constraint kernel, NOT by the reduced
1910    // ordinary runtime. Stripping their epistemic literals would leave an
1911    // always-true ordinary constraint, so drop them from the reduced program
1912    // entirely. Purely relational constraints stay as ordinary constraints.
1913    reduced.constraints.retain(|constraint| {
1914        !constraint
1915            .body
1916            .iter()
1917            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
1918    });
1919
1920    reduced
1921}
1922
1923/// Reduce a Case-A recursive epistemic program to an equivalent ordinary recursive
1924/// program for the existing fixpoint engine.
1925///
1926/// Unlike [`reduce_epistemic_program_to_ordinary`] (which strips modal literals and
1927/// gates the single-pass result post hoc), this RESOLVES each positive `know`/
1928/// `possible` literal to its gated relation by rewriting it into an ordinary positive
1929/// body atom over the same predicate. Because the modal relation is invariant (EDB or
1930/// a lower non-recursive, non-epistemic stratum — proved by
1931/// [`classify_recursive_epistemic_program`]), its extension is the accepted world
1932/// view's extension, so the rewrite preserves modal semantics while letting the
1933/// recursive/semi-naive engine join the recursion against the gated relation at every
1934/// iteration. The modal atom's variables become ordinary join variables (no hidden
1935/// head columns are appended), which fixes both the missing in-loop gate and the
1936/// arity mismatch that make the post-hoc reduction single-pass-only.
1937///
1938/// Callers MUST first prove the program is Case A via
1939/// [`classify_recursive_epistemic_program`]; this function assumes that contract.
1940pub fn reduce_case_a_epistemic_program_to_ordinary(program: &Program) -> Program {
1941    let mut reduced = program.clone();
1942    let mode = program.directives.epistemic_mode_or_default();
1943    let invariant = InvariantRelations::analyze(program);
1944    for rule in &mut reduced.rules {
1945        for lit in &mut rule.body {
1946            if let BodyLiteral::Epistemic(modal) = lit {
1947                // Case A admits modal literals over invariant relations. Case B also
1948                // routes here: FAEEL positive co-evolving modals resolve to ordinary
1949                // recursive atoms (founded least fixpoint), while G91 positive
1950                // `possible` over a NON-invariant target is the compatibility
1951                // self-support assumption and drops to a tautological conjunct.
1952                *lit = if mode == EpistemicMode::G91
1953                    && modal.op == EpistemicOp::Possible
1954                    && !modal.negated
1955                    && !invariant.is_invariant(&modal.atom.predicate)
1956                {
1957                    BodyLiteral::Comparison(Comparison {
1958                        left: Term::Integer(1),
1959                        op: CompOp::Eq,
1960                        right: Term::Integer(1),
1961                    })
1962                } else if modal.negated {
1963                    BodyLiteral::Negated(modal.atom.clone())
1964                } else {
1965                    BodyLiteral::Positive(modal.atom.clone())
1966                };
1967            }
1968        }
1969    }
1970    // World-view integrity constraints have no place in a Case-A ordinary program: the
1971    // recursion already joins against the gated relations. Drop any constraint that
1972    // still references a modal literal (purely relational constraints are retained).
1973    reduced.constraints.retain(|constraint| {
1974        !constraint
1975            .body
1976            .iter()
1977            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
1978    });
1979    reduced
1980}
1981
1982/// Output (head) variables of `rule` that are bound ONLY by epistemic literals, i.e.
1983/// no positive non-epistemic body literal binds them.
1984///
1985/// Includes BOTH variables already in the user-visible head (e.g. `Y` in
1986/// `pair(X,Y) :- color(X), possible edge(X,Y)`) AND modal-local variables that
1987/// augmentation will append to the head (e.g. `Y` in
1988/// `one_hop(X) :- node(X), know edge(X,Y)`). After the modal is stripped, each such
1989/// variable would be an unsafe head column unless a positive-invariant modal carrying
1990/// it is resolved into a positive ordinary atom. Computed from the ORIGINAL rule,
1991/// before the head is mutated by augmentation.
1992fn modal_only_bound_output_variables(rule: &crate::ast::Rule) -> BTreeSet<String> {
1993    // Variables bound by a positive non-epistemic body literal (positive atoms,
1994    // `is`-expressions, and univ all introduce bindings; comparisons and negated atoms
1995    // do not range-restrict).
1996    let mut positively_bound: BTreeSet<&str> = BTreeSet::new();
1997    for lit in &rule.body {
1998        if let BodyLiteral::Positive(atom) = lit {
1999            for term in &atom.terms {
2000                if let Term::Variable(name) = term {
2001                    positively_bound.insert(name.as_str());
2002                }
2003            }
2004        }
2005    }
2006
2007    // Candidate output variables: every variable occurring in the user-visible head
2008    // plus every modal-local variable (which augmentation will append to the head).
2009    let mut modal_only = BTreeSet::new();
2010    let mut consider = |name: &str| {
2011        if name != "_" && !positively_bound.contains(name) {
2012            modal_only.insert(name.to_string());
2013        }
2014    };
2015    for term in &rule.head.terms {
2016        if let Term::Variable(name) = term {
2017            consider(name);
2018        }
2019    }
2020    for lit in &rule.body {
2021        if let BodyLiteral::Epistemic(lit) = lit {
2022            for term in &lit.atom.terms {
2023                if let Term::Variable(name) = term {
2024                    consider(name);
2025                }
2026            }
2027        }
2028    }
2029    modal_only
2030}
2031
2032/// Whether `modal`'s atom carries at least one output variable that no positive
2033/// non-epistemic body literal binds (so resolving this positive-invariant modal into a
2034/// positive ordinary atom range-restricts an otherwise-unbound head column).
2035fn modal_atom_binds_output_variable(
2036    modal: &EpistemicLiteral,
2037    modal_only_output_variables: &BTreeSet<String>,
2038) -> bool {
2039    modal.atom.terms.iter().any(
2040        |term| matches!(term, Term::Variable(name) if modal_only_output_variables.contains(name)),
2041    )
2042}
2043
2044/// Widen each predicate's declaration to the maximum arity of its (now possibly
2045/// augmented) defining rule heads, inferring appended column types from the positive
2046/// body atoms that bind the augmented head variables.
2047///
2048/// Augmentation appends modal-local columns to a rule head; without widening the
2049/// matching `PredDecl`, the runtime would union the augmented rule output against the
2050/// narrow declared (empty) relation stub and fail with a schema mismatch.
2051///
2052/// Only heads in `resolved_augmented_heads` (where a positive-invariant modal was
2053/// genuinely resolved into a positive atom, range-restricting a modal-only-bound
2054/// output variable) are reconciled; a head that merely had a positively-bound
2055/// modal-local variable spuriously appended is NOT widened.
2056///
2057/// Returns the set of head predicates whose declaration was widened (i.e. whose rule
2058/// heads were augmented beyond the original declared arity).
2059fn reconcile_augmented_head_declarations(
2060    reduced: &mut Program,
2061    resolved_augmented_heads: &BTreeSet<String>,
2062) -> BTreeSet<String> {
2063    use crate::ast::{PredColumn, TypeRef};
2064
2065    let mut augmented_heads = BTreeSet::new();
2066
2067    // Per head predicate: the maximum rule-head arity and, per column position, an
2068    // inferred type from a positive body atom (the resolved modal or any binder).
2069    let mut max_arity: BTreeMap<String, usize> = BTreeMap::new();
2070    let mut inferred_types: BTreeMap<String, Vec<Option<TypeRef>>> = BTreeMap::new();
2071
2072    // Map predicate -> declared column types (for type inference of body atom columns).
2073    let declared_types: BTreeMap<String, Vec<TypeRef>> = reduced
2074        .predicates
2075        .iter()
2076        .map(|decl| (decl.name.clone(), decl.types.clone()))
2077        .collect();
2078
2079    for rule in &reduced.rules {
2080        if rule.body.is_empty() {
2081            continue;
2082        }
2083        // Only heads where the invariant-resolve genuinely fired are reconciled.
2084        if !resolved_augmented_heads.contains(&rule.head.predicate) {
2085            continue;
2086        }
2087        let head = rule.head.predicate.as_str();
2088        let arity = rule.head.terms.len();
2089        let entry = max_arity.entry(head.to_string()).or_insert(0);
2090        if arity > *entry {
2091            *entry = arity;
2092        }
2093        let types = inferred_types
2094            .entry(head.to_string())
2095            .or_insert_with(|| vec![None; arity]);
2096        if types.len() < arity {
2097            types.resize(arity, None);
2098        }
2099        // Infer each head variable's type from a positive body atom that binds it.
2100        for (col, term) in rule.head.terms.iter().enumerate() {
2101            if types[col].is_some() {
2102                continue;
2103            }
2104            let Term::Variable(head_var) = term else {
2105                continue;
2106            };
2107            for lit in &rule.body {
2108                let BodyLiteral::Positive(atom) = lit else {
2109                    continue;
2110                };
2111                let Some(pos) = atom
2112                    .terms
2113                    .iter()
2114                    .position(|t| matches!(t, Term::Variable(name) if name == head_var))
2115                else {
2116                    continue;
2117                };
2118                if let Some(decl_types) = declared_types.get(&atom.predicate) {
2119                    if let Some(typ) = decl_types.get(pos) {
2120                        types[col] = Some(typ.clone());
2121                        break;
2122                    }
2123                }
2124            }
2125        }
2126    }
2127
2128    for decl in &mut reduced.predicates {
2129        let Some(&target_arity) = max_arity.get(&decl.name) else {
2130            continue;
2131        };
2132        if target_arity <= decl.types.len() {
2133            continue;
2134        }
2135        let inferred = inferred_types.get(&decl.name);
2136        for col in decl.types.len()..target_arity {
2137            let typ = inferred
2138                .and_then(|types| types.get(col))
2139                .and_then(|t| t.clone())
2140                // Default appended columns to U32 (the modal relation key column type).
2141                .unwrap_or(TypeRef::Scalar(xlog_core::ScalarType::U32));
2142            decl.types.push(typ.clone());
2143            decl.columns.push(PredColumn { name: None, typ });
2144        }
2145        augmented_heads.insert(decl.name.clone());
2146    }
2147
2148    augmented_heads
2149}
2150
2151fn append_body_local_tuple_key_variables_to_head(rule: &mut crate::ast::Rule) {
2152    let mut hidden_variables = Vec::new();
2153    for lit in &rule.body {
2154        let BodyLiteral::Epistemic(lit) = lit else {
2155            continue;
2156        };
2157        for term in &lit.atom.terms {
2158            let Term::Variable(variable) = term else {
2159                continue;
2160            };
2161            if variable == "_" {
2162                continue;
2163            }
2164            let already_in_head = rule
2165                .head
2166                .terms
2167                .iter()
2168                .any(|head_term| matches!(head_term, Term::Variable(name) if name == variable));
2169            if !already_in_head && !hidden_variables.iter().any(|name| name == variable) {
2170                hidden_variables.push(variable.clone());
2171            }
2172        }
2173    }
2174    for variable in hidden_variables {
2175        rule.head.terms.push(Term::Variable(variable));
2176    }
2177}
2178
2179fn wcoj_status_for_reduction(
2180    relational_body_atoms: usize,
2181    has_negated_relational_atom: bool,
2182) -> EpistemicWcojReductionStatus {
2183    if relational_body_atoms >= 3 && !has_negated_relational_atom {
2184        EpistemicWcojReductionStatus::RequiresPlannerEligibility
2185    } else {
2186        EpistemicWcojReductionStatus::NotWcojCandidate
2187    }
2188}
2189
2190/// Result of bounded FAEEL candidate evaluation.
2191#[derive(Debug, Clone, PartialEq, Eq)]
2192pub enum FaeelCandidateResult {
2193    /// Candidate satisfies the bounded FAEEL fixture semantics.
2194    Model,
2195    /// Candidate has no model for a typed reason.
2196    NoModel(FaeelNoModelReason),
2197}
2198
2199/// Typed no-model reason for bounded FAEEL fixtures.
2200#[derive(Debug, Clone, PartialEq, Eq)]
2201pub enum FaeelNoModelReason {
2202    /// Candidate uses possible-only support where FAEEL requires founded knowledge.
2203    UnfoundedPossible {
2204        /// Predicate name.
2205        predicate: String,
2206        /// Predicate arity.
2207        arity: usize,
2208    },
2209    /// Candidate marks the same atom known and rejected.
2210    Contradiction {
2211        /// Predicate name.
2212        predicate: String,
2213        /// Predicate arity.
2214        arity: usize,
2215    },
2216    /// An epistemic literal is unsatisfied by the candidate.
2217    UnsatisfiedLiteral {
2218        /// Predicate name.
2219        predicate: String,
2220        /// Predicate arity.
2221        arity: usize,
2222    },
2223}
2224
2225/// Configuration for bounded Generate-Propagate-Test fixture execution.
2226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2227pub struct GeneratePropagateTestConfig {
2228    /// Maximum candidate count accepted by the generate phase.
2229    pub max_candidates: usize,
2230}
2231
2232/// Phase counters emitted by bounded Generate-Propagate-Test execution.
2233#[derive(Debug, Clone, Default, PartialEq, Eq)]
2234pub struct GeneratePropagateTestTrace {
2235    /// Number of generated candidates.
2236    pub generated: usize,
2237    /// Number of epistemic guesses generated.
2238    pub guesses: usize,
2239    /// Number of candidates that survived propagation.
2240    pub propagated: usize,
2241    /// Number of candidates pruned during propagation.
2242    pub pruned: usize,
2243    /// Number of reduced-program models inspected by the test phase.
2244    pub reduced_program_models: usize,
2245    /// Number of candidates tested.
2246    pub tested: usize,
2247    /// Number of accepted candidates.
2248    pub accepted: usize,
2249    /// Number of accepted world views.
2250    pub accepted_world_views: usize,
2251    /// Number of rejected candidates.
2252    pub rejected: usize,
2253    /// Rejection reasons observed during propagation and testing.
2254    pub rejection_reasons: Vec<FaeelNoModelReason>,
2255}
2256
2257/// Result of bounded Generate-Propagate-Test fixture execution.
2258#[derive(Debug, Clone, PartialEq, Eq)]
2259pub struct GeneratePropagateTestOutcome {
2260    /// Phase counts.
2261    pub trace: GeneratePropagateTestTrace,
2262    /// Original indices of accepted candidates.
2263    pub accepted_candidate_indices: Vec<usize>,
2264    /// Original indices of rejected candidates in rejection-reason order.
2265    pub rejected_candidate_indices: Vec<usize>,
2266}
2267
2268/// Reason that two source rules were coalesced into the same dependency component.
2269///
2270/// These reasons make the split planner's structural decisions explainable: a
2271/// caller can read, for every component, *why* its rules could not be solved
2272/// independently of one another (K3 split diagnostics).
2273#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2274pub enum EpistemicComponentMergeReason {
2275    /// Two rules share the same head predicate, so they jointly define one
2276    /// derived relation and must be solved together.
2277    SharedHeadPredicate {
2278        /// Head predicate defined by both rules.
2279        predicate: String,
2280    },
2281    /// One rule's body consumes a predicate that another rule derives in its
2282    /// head (an ordinary/negated derived dependency).
2283    DerivedPredicate {
2284        /// Head predicate produced by the producer rule and consumed by the
2285        /// consumer rule body.
2286        predicate: String,
2287    },
2288    /// Two rules reference the same epistemic (modal) predicate, so their
2289    /// world-view acceptance is mutually dependent.
2290    SharedModalPredicate {
2291        /// Epistemic predicate referenced by both rules, with arity.
2292        predicate: String,
2293    },
2294    /// An integrity constraint mentions head predicates owned by both rules, so
2295    /// the constraint coalesces exactly those components.
2296    Constraint {
2297        /// Constraint-mentioned head predicates that forced the coalesce.
2298        predicates: Vec<String>,
2299    },
2300}
2301
2302/// One deterministic dependency component for epistemic splitting.
2303#[derive(Debug, Clone, PartialEq, Eq)]
2304pub struct EpistemicDependencyComponent {
2305    /// Sorted predicate names in the component.
2306    pub predicates: Vec<String>,
2307    /// Source rule indices owned by the component.
2308    pub rule_indices: Vec<usize>,
2309    /// Sorted, deduplicated reasons the component's rules were coalesced.
2310    ///
2311    /// Empty when the component is a single independent rule that no
2312    /// dependency forced together (it was split out on its own).
2313    pub merge_reasons: Vec<EpistemicComponentMergeReason>,
2314}
2315
2316/// Deterministic dependency graph used by bounded epistemic splitting.
2317#[derive(Debug, Clone, PartialEq, Eq)]
2318pub struct EpistemicDependencyGraph {
2319    /// Sorted components.
2320    pub components: Vec<EpistemicDependencyComponent>,
2321}
2322
2323/// Split plan for independently solvable epistemic components.
2324#[derive(Debug, Clone, PartialEq, Eq)]
2325pub struct EpistemicSplitPlan {
2326    /// Components to solve independently.
2327    pub components: Vec<EpistemicDependencyComponent>,
2328}
2329
2330impl EpistemicSplitPlan {
2331    /// Return the original rule order recovered from all components.
2332    pub fn recomposed_rule_indices(&self) -> Vec<usize> {
2333        let mut indices: Vec<usize> = self
2334            .components
2335            .iter()
2336            .flat_map(|component| component.rule_indices.iter().copied())
2337            .collect();
2338        indices.sort_unstable();
2339        indices
2340    }
2341}
2342
2343/// One split component lowered through the production epistemic GPU plan path.
2344#[derive(Debug, Clone)]
2345pub struct EpistemicSplitExecutableComponent {
2346    /// Source dependency component covered by this executable subplan.
2347    pub component: EpistemicDependencyComponent,
2348    /// GPU contract plus reduced runtime plan for this component.
2349    pub executable: EpistemicExecutablePlan,
2350}
2351
2352/// Executable split plan whose components reuse the normal epistemic GPU lowering.
2353#[derive(Debug, Clone)]
2354pub struct EpistemicSplitExecutablePlan {
2355    /// Original bounded split certificate.
2356    pub split_plan: EpistemicSplitPlan,
2357    /// Epistemic components compiled into GPU executable subplans.
2358    pub components: Vec<EpistemicSplitExecutableComponent>,
2359}
2360
2361impl EpistemicSplitExecutablePlan {
2362    /// Return the source rule indices actually recomposed by GPU split execution.
2363    ///
2364    /// This reflects the rules the *executable* plan runs: epistemic-bearing
2365    /// components only. Pure-ordinary independent components carry no epistemic
2366    /// output buffer and are not part of the epistemic execution surface, so
2367    /// they are intentionally excluded here. The full dependency-graph view
2368    /// (including non-executed ordinary components) lives on
2369    /// [`EpistemicSplitPlan::recomposed_rule_indices`]; the two coincide exactly
2370    /// when every component is epistemic-bearing.
2371    pub fn recomposed_rule_indices(&self) -> Vec<usize> {
2372        let mut indices: Vec<usize> = self
2373            .components
2374            .iter()
2375            .flat_map(|component| component.component.rule_indices.iter().copied())
2376            .collect();
2377        indices.sort_unstable();
2378        indices
2379    }
2380
2381    /// Return the full dependency-graph recomposition view, including
2382    /// independent non-epistemic components that the executable plan does not run.
2383    pub fn planned_recomposed_rule_indices(&self) -> Vec<usize> {
2384        self.split_plan.recomposed_rule_indices()
2385    }
2386
2387    /// Return executable components ordered by the first source rule they cover.
2388    pub fn recomposed_components(&self) -> Vec<&EpistemicSplitExecutableComponent> {
2389        let mut components: Vec<_> = self.components.iter().collect();
2390        components.sort_by_key(|component| {
2391            component
2392                .component
2393                .rule_indices
2394                .iter()
2395                .copied()
2396                .min()
2397                .unwrap_or(usize::MAX)
2398        });
2399        components
2400    }
2401}
2402
2403/// Evaluate a single parsed epistemic literal against a bounded interpretation.
2404pub fn evaluate_epistemic_literal(
2405    mode: EpistemicMode,
2406    lit: &EpistemicLiteral,
2407    interpretation: &EpistemicInterpretation,
2408) -> TruthValue {
2409    let value = match lit.op {
2410        EpistemicOp::Know => interpretation.contains_known(&lit.atom),
2411        EpistemicOp::Possible => match mode {
2412            EpistemicMode::G91 => {
2413                interpretation.contains_known(&lit.atom)
2414                    || interpretation.contains_possible(&lit.atom)
2415            }
2416            EpistemicMode::Faeel => interpretation.contains_known(&lit.atom),
2417        },
2418    };
2419
2420    TruthValue::from_bool(if lit.negated { !value } else { value })
2421}
2422
2423/// Evaluate all epistemic literals in a program under bounded FAEEL fixture semantics.
2424pub fn evaluate_faeel_candidate(
2425    program: &Program,
2426    interpretation: &EpistemicInterpretation,
2427) -> Result<FaeelCandidateResult> {
2428    evaluate_epistemic_candidate(program, interpretation, EpistemicMode::Faeel)
2429}
2430
2431/// Evaluate all epistemic literals in a program under a bounded fixture semantics mode.
2432pub fn evaluate_epistemic_candidate(
2433    program: &Program,
2434    interpretation: &EpistemicInterpretation,
2435    mode: EpistemicMode,
2436) -> Result<FaeelCandidateResult> {
2437    reject_gpt_epistemic_constraints(program)?;
2438    if let Some((predicate, arity)) = interpretation.first_contradiction() {
2439        return Ok(FaeelCandidateResult::NoModel(
2440            FaeelNoModelReason::Contradiction { predicate, arity },
2441        ));
2442    }
2443
2444    for rule in &program.rules {
2445        for body_lit in &rule.body {
2446            let BodyLiteral::Epistemic(lit) = body_lit else {
2447                continue;
2448            };
2449            if interpretation.contains_known(&lit.atom)
2450                && interpretation.contains_rejected(&lit.atom)
2451            {
2452                return Ok(FaeelCandidateResult::NoModel(
2453                    FaeelNoModelReason::Contradiction {
2454                        predicate: lit.atom.predicate.clone(),
2455                        arity: lit.atom.arity(),
2456                    },
2457                ));
2458            }
2459            if mode == EpistemicMode::Faeel
2460                && lit.op == EpistemicOp::Possible
2461                && interpretation.contains_possible(&lit.atom)
2462                && !interpretation.contains_known(&lit.atom)
2463            {
2464                return Ok(FaeelCandidateResult::NoModel(
2465                    FaeelNoModelReason::UnfoundedPossible {
2466                        predicate: lit.atom.predicate.clone(),
2467                        arity: lit.atom.arity(),
2468                    },
2469                ));
2470            }
2471            if evaluate_epistemic_literal(mode, lit, interpretation) == TruthValue::False {
2472                return Ok(FaeelCandidateResult::NoModel(
2473                    FaeelNoModelReason::UnsatisfiedLiteral {
2474                        predicate: lit.atom.predicate.clone(),
2475                        arity: lit.atom.arity(),
2476                    },
2477                ));
2478            }
2479        }
2480    }
2481
2482    Ok(FaeelCandidateResult::Model)
2483}
2484
2485/// Run bounded Generate-Propagate-Test execution over explicit candidates.
2486pub fn run_generate_propagate_test(
2487    program: &Program,
2488    candidates: Vec<EpistemicInterpretation>,
2489    config: GeneratePropagateTestConfig,
2490) -> Result<GeneratePropagateTestOutcome> {
2491    run_generate_propagate_test_with_mode(
2492        program,
2493        candidates,
2494        config,
2495        program.directives.epistemic_mode_or_default(),
2496    )
2497}
2498
2499/// Run bounded Generate-Propagate-Test execution over explicit candidates and semantics mode.
2500pub fn run_generate_propagate_test_with_mode(
2501    program: &Program,
2502    candidates: Vec<EpistemicInterpretation>,
2503    config: GeneratePropagateTestConfig,
2504    mode: EpistemicMode,
2505) -> Result<GeneratePropagateTestOutcome> {
2506    reject_gpt_epistemic_constraints(program)?;
2507    if candidates.len() > config.max_candidates {
2508        return Err(xlog_core::XlogError::ResourceExhausted {
2509            context: "epistemic GPT candidate guard".to_string(),
2510            estimated_bytes: candidates.len() as u64,
2511            budget_bytes: config.max_candidates as u64,
2512        });
2513    }
2514
2515    let generated = candidates.len();
2516    let guesses = candidates
2517        .iter()
2518        .map(EpistemicInterpretation::epistemic_guess_count)
2519        .sum();
2520    let mut propagated_candidates = Vec::new();
2521    let mut rejection_reasons = Vec::new();
2522    let mut rejected_candidate_indices = Vec::new();
2523    for (idx, candidate) in candidates.into_iter().enumerate() {
2524        if let Some((predicate, arity)) = candidate.first_contradiction() {
2525            rejection_reasons.push(FaeelNoModelReason::Contradiction { predicate, arity });
2526            rejected_candidate_indices.push(idx);
2527        } else {
2528            propagated_candidates.push((idx, candidate));
2529        }
2530    }
2531
2532    let mut trace = GeneratePropagateTestTrace {
2533        generated,
2534        guesses,
2535        propagated: propagated_candidates.len(),
2536        pruned: generated.saturating_sub(propagated_candidates.len()),
2537        reduced_program_models: propagated_candidates.len(),
2538        rejection_reasons,
2539        ..GeneratePropagateTestTrace::default()
2540    };
2541    let mut accepted_candidate_indices = Vec::new();
2542
2543    for (idx, candidate) in &propagated_candidates {
2544        trace.tested += 1;
2545        match evaluate_epistemic_candidate(program, candidate, mode)? {
2546            FaeelCandidateResult::Model => {
2547                trace.accepted += 1;
2548                trace.accepted_world_views += 1;
2549                accepted_candidate_indices.push(*idx);
2550            }
2551            FaeelCandidateResult::NoModel(reason) => {
2552                trace.rejected += 1;
2553                trace.rejection_reasons.push(reason);
2554                rejected_candidate_indices.push(*idx);
2555            }
2556        }
2557    }
2558
2559    Ok(GeneratePropagateTestOutcome {
2560        trace,
2561        accepted_candidate_indices,
2562        rejected_candidate_indices,
2563    })
2564}
2565
2566/// Build a deterministic dependency graph for bounded epistemic splitting.
2567pub fn build_epistemic_dependency_graph(program: &Program) -> Result<EpistemicDependencyGraph> {
2568    if program.rules.is_empty() {
2569        return Ok(EpistemicDependencyGraph { components: vec![] });
2570    }
2571
2572    let mut parents: Vec<usize> = (0..program.rules.len()).collect();
2573    let mut rule_predicates = Vec::with_capacity(program.rules.len());
2574    let mut head_owner: BTreeMap<String, usize> = BTreeMap::new();
2575    // Each merge records (one source rule index touched by the merge, reason).
2576    // After roots collapse, reasons are attributed to the surviving root so the
2577    // emitted component carries an explainable account of why it was coalesced.
2578    let mut merge_log: Vec<(usize, EpistemicComponentMergeReason)> = Vec::new();
2579
2580    for (idx, rule) in program.rules.iter().enumerate() {
2581        if rule.body.is_empty() {
2582            continue;
2583        }
2584        if let Some(owner) = head_owner.get(&rule.head.predicate).copied() {
2585            union_components(&mut parents, owner, idx);
2586            merge_log.push((
2587                idx,
2588                EpistemicComponentMergeReason::SharedHeadPredicate {
2589                    predicate: rule.head.predicate.clone(),
2590                },
2591            ));
2592        } else {
2593            head_owner.insert(rule.head.predicate.clone(), idx);
2594        }
2595    }
2596
2597    let mut modal_owner: BTreeMap<EpistemicAtomKey, usize> = BTreeMap::new();
2598    for (idx, rule) in program.rules.iter().enumerate() {
2599        let mut predicates = BTreeSet::new();
2600        predicates.insert(rule.head.predicate.clone());
2601        for lit in &rule.body {
2602            if let BodyLiteral::Epistemic(lit) = lit {
2603                let key =
2604                    EpistemicAtomKey::from_arity(lit.atom.predicate.clone(), lit.atom.arity());
2605                if let Some(owner) = modal_owner.get(&key).copied() {
2606                    union_components(&mut parents, owner, idx);
2607                    merge_log.push((
2608                        idx,
2609                        EpistemicComponentMergeReason::SharedModalPredicate {
2610                            predicate: format!("{}/{}", lit.atom.predicate, lit.atom.arity()),
2611                        },
2612                    ));
2613                } else {
2614                    modal_owner.insert(key, idx);
2615                }
2616            }
2617            if let Some(atom) = lit.atom() {
2618                if let Some(owner) = head_owner.get(&atom.predicate).copied() {
2619                    if owner != idx {
2620                        union_components(&mut parents, owner, idx);
2621                        merge_log.push((
2622                            idx,
2623                            EpistemicComponentMergeReason::DerivedPredicate {
2624                                predicate: atom.predicate.clone(),
2625                            },
2626                        ));
2627                    }
2628                }
2629                predicates.insert(atom.predicate.clone());
2630            }
2631        }
2632
2633        rule_predicates.push(predicates);
2634    }
2635
2636    let mut constraint_predicates = Vec::with_capacity(program.constraints.len());
2637    for constraint in &program.constraints {
2638        let predicates = constraint_predicate_set(constraint);
2639        let mut owners = predicates
2640            .iter()
2641            .filter_map(|predicate| head_owner.get(predicate).copied());
2642        if let Some(first_owner) = owners.next() {
2643            let mut coalesced_any = false;
2644            for owner in owners {
2645                if find_component(&mut parents, first_owner) != find_component(&mut parents, owner)
2646                {
2647                    coalesced_any = true;
2648                }
2649                union_components(&mut parents, first_owner, owner);
2650            }
2651            if coalesced_any {
2652                let constraint_heads: Vec<String> = predicates
2653                    .iter()
2654                    .filter(|predicate| head_owner.contains_key(*predicate))
2655                    .cloned()
2656                    .collect();
2657                merge_log.push((
2658                    first_owner,
2659                    EpistemicComponentMergeReason::Constraint {
2660                        predicates: constraint_heads,
2661                    },
2662                ));
2663            }
2664        }
2665        constraint_predicates.push(predicates);
2666    }
2667
2668    let mut grouped: BTreeMap<usize, (BTreeSet<String>, Vec<usize>)> = BTreeMap::new();
2669    for (idx, predicates) in rule_predicates.into_iter().enumerate() {
2670        let root = find_component(&mut parents, idx);
2671        let entry = grouped
2672            .entry(root)
2673            .or_insert_with(|| (BTreeSet::new(), vec![]));
2674        entry.0.extend(predicates);
2675        entry.1.push(idx);
2676    }
2677    for predicates in constraint_predicates {
2678        let Some(root) = predicates
2679            .iter()
2680            .filter_map(|predicate| head_owner.get(predicate).copied())
2681            .map(|idx| find_component(&mut parents, idx))
2682            .next()
2683        else {
2684            continue;
2685        };
2686        grouped
2687            .entry(root)
2688            .or_insert_with(|| (BTreeSet::new(), vec![]))
2689            .0
2690            .extend(predicates);
2691    }
2692
2693    // Attribute every recorded merge reason to its surviving component root.
2694    let mut reasons_by_root: BTreeMap<usize, BTreeSet<EpistemicComponentMergeReason>> =
2695        BTreeMap::new();
2696    for (touched_idx, reason) in merge_log {
2697        let root = find_component(&mut parents, touched_idx);
2698        reasons_by_root.entry(root).or_default().insert(reason);
2699    }
2700
2701    let mut components: Vec<EpistemicDependencyComponent> = grouped
2702        .into_iter()
2703        .map(|(root, (predicates, mut rule_indices))| {
2704            rule_indices.sort_unstable();
2705            let merge_reasons = reasons_by_root
2706                .remove(&root)
2707                .map(|reasons| reasons.into_iter().collect())
2708                .unwrap_or_default();
2709            EpistemicDependencyComponent {
2710                predicates: predicates.into_iter().collect(),
2711                rule_indices,
2712                merge_reasons,
2713            }
2714        })
2715        .collect();
2716    components.sort_by(|a, b| a.predicates.cmp(&b.predicates));
2717    Ok(EpistemicDependencyGraph { components })
2718}
2719
2720fn constraint_predicate_set(constraint: &Constraint) -> BTreeSet<String> {
2721    constraint
2722        .body
2723        .iter()
2724        .filter_map(|lit| lit.atom().map(|atom| atom.predicate.clone()))
2725        .collect()
2726}
2727
2728fn find_component(parents: &mut [usize], idx: usize) -> usize {
2729    if parents[idx] != idx {
2730        let root = find_component(parents, parents[idx]);
2731        parents[idx] = root;
2732    }
2733    parents[idx]
2734}
2735
2736fn union_components(parents: &mut [usize], left: usize, right: usize) {
2737    let left_root = find_component(parents, left);
2738    let right_root = find_component(parents, right);
2739    if left_root != right_root {
2740        parents[right_root] = left_root;
2741    }
2742}
2743
2744/// Split an epistemic program into independently solvable bounded components.
2745/// One stratum of a stratified epistemic program: a self-contained sub-program
2746/// whose epistemic heads gate only over EDB/invariant relations OR over the
2747/// materialized (now-base) outputs of strictly-lower strata.
2748#[derive(Debug, Clone)]
2749pub struct EpistemicStratum {
2750    /// The epistemic output head predicate(s) this stratum materializes.
2751    pub head_predicates: Vec<String>,
2752    /// Source-rule indices owned by this stratum.
2753    pub rule_indices: Vec<usize>,
2754    /// The self-contained sub-program for this stratum (its own defining rules
2755    /// plus the facts/EDB it needs). Lower-stratum heads are NOT redefined here;
2756    /// at execution they are present in the store as materialized base relations.
2757    pub program: Program,
2758}
2759
2760/// A stratified epistemic execution plan: an ordered sequence of strata.
2761///
2762/// Stratum `i`'s epistemic heads are materialized (gated) into the relation store
2763/// BEFORE stratum `i+1` runs, so a higher stratum's `know`/`possible` over a
2764/// lower stratum's head reads the GATED extension through the EXISTING EGB-02
2765/// membership filter (no resolve-into-body, no double-gating).
2766#[derive(Debug, Clone)]
2767pub struct EpistemicStratifiedPlan {
2768    /// Strata in execution (topological) order.
2769    pub strata: Vec<EpistemicStratum>,
2770}
2771
2772/// Predicates whose epistemic extension is DETERMINED once lower strata are fixed.
2773///
2774/// A predicate is *epistemically determined* when every defining rule uses only
2775/// (a) positive `know`/`possible` modals and ordinary positive/negated literals,
2776/// (b) all ranging over predicates that are themselves invariant (EDB/lower
2777/// non-epistemic stratum) OR already epistemically determined, and (c) the
2778/// dependency is acyclic through BOTH modal and ordinary edges. Such a head's
2779/// materialized (gated) extension IS its truth, so it can be materialized into the
2780/// store as a base relation and a higher stratum can gate against it.
2781///
2782/// This is a STANDALONE analysis: it never feeds
2783/// [`reduce_case_a_epistemic_program_to_ordinary`] / `is_invariant`, so it cannot
2784/// trigger the resolve-into-body double-gating that the single-pass GPU filter
2785/// already performs.
2786struct EpistemicallyDeterminedPredicates {
2787    determined: BTreeSet<String>,
2788}
2789
2790impl EpistemicallyDeterminedPredicates {
2791    fn analyze(program: &Program) -> Self {
2792        let invariant = InvariantRelations::analyze(program);
2793
2794        // Heads defined by at least one rule.
2795        let mut derived_heads: BTreeSet<&str> = BTreeSet::new();
2796        for rule in &program.rules {
2797            if !rule.body.is_empty() {
2798                derived_heads.insert(rule.head.predicate.as_str());
2799            }
2800        }
2801
2802        // Least-fixpoint closure over ALL derived heads (epistemic AND ordinary): a
2803        // predicate becomes determined when EVERY rule defining it ranges (modal +
2804        // ordinary) only over invariant or already-determined predicates, with no
2805        // self-reference (acyclic).
2806        //
2807        // An ORDINARY head is determined transitively when every defining rule ranges
2808        // only over determined/invariant relations (e.g. `r :- a` with `a` a
2809        // determined epistemic head). Such an `r` is determined-in-principle: its
2810        // extension is fixed once the determined heads it derives from are fixed, so a
2811        // higher modal `know r`/`possible r` can stratify against the materialized
2812        // base `r` via the existing EGB-02 membership filter. The acyclicity guard in
2813        // `head_is_determined` (self-reference returns false) plus the fixpoint's
2814        // monotonicity keep every recursive predicate OUT of `determined`, so a
2815        // circular `know reach` in a recursive SCC (example 22) is never determined
2816        // and stays fail-closed.
2817        let mut determined: BTreeSet<String> = BTreeSet::new();
2818        let mut changed = true;
2819        while changed {
2820            changed = false;
2821            for head in &derived_heads {
2822                if determined.contains(*head) {
2823                    continue;
2824                }
2825                if Self::head_is_determined(program, head, &invariant, &derived_heads, &determined)
2826                {
2827                    determined.insert((*head).to_string());
2828                    changed = true;
2829                }
2830            }
2831        }
2832
2833        Self { determined }
2834    }
2835
2836    /// Whether `head`'s every defining rule ranges only over invariant or
2837    /// already-determined predicates (acyclic — no reference to `head` itself).
2838    fn head_is_determined(
2839        program: &Program,
2840        head: &str,
2841        invariant: &InvariantRelations,
2842        derived_heads: &BTreeSet<&str>,
2843        determined: &BTreeSet<String>,
2844    ) -> bool {
2845        let mut defined = false;
2846        for rule in &program.rules {
2847            if rule.head.predicate != head || rule.body.is_empty() {
2848                continue;
2849            }
2850            defined = true;
2851            for lit in &rule.body {
2852                let referenced = match lit {
2853                    BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
2854                        atom.predicate.as_str()
2855                    }
2856                    BodyLiteral::Epistemic(modal) => modal.atom.predicate.as_str(),
2857                    BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {
2858                        continue
2859                    }
2860                };
2861                if referenced == head {
2862                    // Self-reference: not acyclically determined (recursion /
2863                    // circular modality). Hand back to the recursive/FAEEL paths.
2864                    return false;
2865                }
2866                let ok = invariant.is_invariant(referenced)
2867                    || determined.contains(referenced)
2868                    // A pure-EDB predicate not seen by `derived_heads` is invariant.
2869                    || !derived_heads.contains(referenced);
2870                if !ok {
2871                    return false;
2872                }
2873            }
2874        }
2875        defined
2876    }
2877
2878    fn contains(&self, predicate: &str) -> bool {
2879        self.determined.contains(predicate)
2880    }
2881}
2882
2883/// Plan a STRATIFIED epistemic execution when the program contains a modal literal
2884/// over an epistemic-derived head that is itself epistemically DETERMINED.
2885///
2886/// This intercepts exactly the chained/nested-epistemic coupling that the joint
2887/// single-enumeration path fails closed on (`b :- know a` where `a :- know p`, `p`
2888/// invariant). It partitions the program's epistemic heads into strata by modal
2889/// dependency, where a head whose modal ranges over a lower DETERMINED head sits in
2890/// a strictly-higher stratum. Each stratum is a self-contained sub-program compiled
2891/// through the EXISTING single/joint epistemic path; at runtime the executor
2892/// materializes each stratum's GATED head into the store before the next stratum
2893/// runs, so the higher stratum gates against the materialized (now-base) relation
2894/// via the existing EGB-02 membership filter — never via resolve-into-body.
2895///
2896/// Returns:
2897/// - `Ok(Some(plan))` when the program genuinely needs (and admits) stratification:
2898///   at least one modal literal ranges over an epistemically-determined derived
2899///   head, and a sound stratification exists.
2900/// - `Ok(None)` when no modal ranges over a determined derived head (the existing
2901///   joint/split/single paths own the program — e.g. example 18's shared base
2902///   modal, where the modal target `q` is EDB, not a determined derived head), OR
2903///   when the nested target is NOT determined (circular modality / recursion /
2904///   unfounded self-support is handed back to the recursive + FAEEL/G91 guards,
2905///   which keep ownership and fail closed there).
2906pub fn try_plan_stratified_epistemic_program(
2907    program: &Program,
2908) -> Result<Option<EpistemicStratifiedPlan>> {
2909    let determined = EpistemicallyDeterminedPredicates::analyze(program);
2910
2911    // A stratification is needed only when some modal literal ranges over a
2912    // DETERMINED epistemic-derived head. (A modal over a base/EDB predicate is the
2913    // ordinary single/joint path — example 18 — and must NOT be intercepted.)
2914    let mut needs_stratification = false;
2915    for rule in &program.rules {
2916        for lit in &rule.body {
2917            if let BodyLiteral::Epistemic(modal) = lit {
2918                if determined.contains(modal.atom.predicate.as_str())
2919                    && modal.atom.predicate != rule.head.predicate
2920                {
2921                    needs_stratification = true;
2922                }
2923            }
2924        }
2925    }
2926    if !needs_stratification {
2927        return Ok(None);
2928    }
2929
2930    // Assign each epistemic-derived head a stratum level = longest modal-dependency
2931    // chain to a determined head it gates over. Heads not determined cannot be
2932    // stratified soundly here; if any modal ranges over a non-determined derived
2933    // epistemic head, hand back to the joint path's fail-closed diagnostic.
2934    let stratum_level = assign_epistemic_strata(program, &determined)?;
2935    let Some(stratum_level) = stratum_level else {
2936        return Ok(None);
2937    };
2938
2939    // Group epistemic-bearing rules by their head's stratum level.
2940    let mut levels: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
2941    for (idx, rule) in program.rules.iter().enumerate() {
2942        let has_epistemic = rule
2943            .body
2944            .iter()
2945            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
2946        if !has_epistemic {
2947            continue;
2948        }
2949        let Some(level) = stratum_level.get(rule.head.predicate.as_str()) else {
2950            // An epistemic head with no assigned level means the analysis could not
2951            // place it soundly; hand back.
2952            return Ok(None);
2953        };
2954        levels.entry(*level).or_default().push(idx);
2955    }
2956
2957    if levels.len() < 2 {
2958        // Only one stratum: there is no lower stratum to materialize, so this is
2959        // not a genuine stratification (the existing paths own it).
2960        return Ok(None);
2961    }
2962
2963    let mut strata = Vec::with_capacity(levels.len());
2964    for (_level, rule_indices) in levels {
2965        let head_predicates: Vec<String> = rule_indices
2966            .iter()
2967            .filter_map(|idx| program.rules.get(*idx))
2968            .map(|rule| rule.head.predicate.clone())
2969            .collect::<BTreeSet<_>>()
2970            .into_iter()
2971            .collect();
2972        let stratum_program =
2973            build_stratum_subprogram(program, &rule_indices, &head_predicates, &stratum_level)?;
2974        strata.push(EpistemicStratum {
2975            head_predicates,
2976            rule_indices,
2977            program: stratum_program,
2978        });
2979    }
2980
2981    Ok(Some(EpistemicStratifiedPlan { strata }))
2982}
2983
2984/// Assign each epistemic-derived head an integer stratum level.
2985///
2986/// Level 0 heads gate only over invariant/EDB relations. A head whose modal ranges
2987/// over a determined head at level `k` is at level `>= k + 1`. Returns `Ok(None)`
2988/// if any modal ranges over a derived-epistemic head that is NOT determined (those
2989/// genuinely-undefined / fail-closed shapes are owned by the joint/recursive
2990/// guards, which already produce typed diagnostics).
2991fn assign_epistemic_strata(
2992    program: &Program,
2993    determined: &EpistemicallyDeterminedPredicates,
2994) -> Result<Option<BTreeMap<String, usize>>> {
2995    // Epistemic-derived heads.
2996    let mut epistemic_heads: BTreeSet<&str> = BTreeSet::new();
2997    for rule in &program.rules {
2998        if rule
2999            .body
3000            .iter()
3001            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
3002        {
3003            epistemic_heads.insert(rule.head.predicate.as_str());
3004        }
3005    }
3006
3007    // Modal-over-derived-epistemic-head edges: head -> set of derived-epistemic
3008    // predicates its modals range over.
3009    //
3010    // A modal can target either a determined EPISTEMIC head directly (`b :- know a`),
3011    // or an ORDINARY predicate transitively derived from determined epistemic heads
3012    // (`b :- know r` with `r :- a`, `a` epistemic-determined). For the ordinary case,
3013    // the modal's head must sit strictly ABOVE the epistemic head(s) in the ordinary
3014    // target's transitive determined support, so those epistemic heads are materialized
3015    // (gated) into the store first and the ordinary `r :- a` is then computed over the
3016    // materialized base (making `r` locally invariant). We therefore route an edge from
3017    // the modal's head to EACH epistemic determined head in the target's support.
3018    let mut modal_edges: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
3019    for rule in &program.rules {
3020        let head = rule.head.predicate.as_str();
3021        for lit in &rule.body {
3022            if let BodyLiteral::Epistemic(modal) = lit {
3023                let target = modal.atom.predicate.as_str();
3024                if epistemic_heads.contains(target) {
3025                    if !determined.contains(target) {
3026                        // Modal over a non-determined epistemic head: not soundly
3027                        // stratifiable here. Hand back to the joint/recursive guard.
3028                        return Ok(None);
3029                    }
3030                    modal_edges.entry(head).or_default().insert(target);
3031                } else if determined.contains(target) {
3032                    // Modal over an ORDINARY determined predicate: route edges to the
3033                    // epistemic determined heads in its transitive support so the
3034                    // modal's head sits above them.
3035                    let support =
3036                        epistemic_support_of_determined_ordinary(program, target, &epistemic_heads);
3037                    if support.is_empty() {
3038                        // No epistemic head in the support means the target is fully
3039                        // invariant (pure-ordinary over EDB) — that is the ordinary
3040                        // single/joint path, not a stratification. Hand back.
3041                        return Ok(None);
3042                    }
3043                    let entry = modal_edges.entry(head).or_default();
3044                    for support_head in support {
3045                        entry.insert(support_head);
3046                    }
3047                }
3048            }
3049        }
3050    }
3051
3052    // Longest-path level via memoized DFS over modal_edges (acyclicity guaranteed
3053    // by `EpistemicallyDeterminedPredicates`, which rejects self-reference).
3054    let mut level: BTreeMap<String, usize> = BTreeMap::new();
3055    fn visit<'a>(
3056        head: &'a str,
3057        modal_edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
3058        level: &mut BTreeMap<String, usize>,
3059        active: &mut BTreeSet<&'a str>,
3060    ) -> Result<usize> {
3061        if let Some(l) = level.get(head) {
3062            return Ok(*l);
3063        }
3064        if !active.insert(head) {
3065            // A cycle through modal edges should have been excluded upstream; be
3066            // defensive and refuse to stratify.
3067            return Err(recursive_epistemic_rejection(
3068                "stratified epistemic planning encountered a modal dependency cycle",
3069            ));
3070        }
3071        let mut l = 0;
3072        if let Some(targets) = modal_edges.get(head) {
3073            for target in targets {
3074                let tl = visit(target, modal_edges, level, active)?;
3075                l = l.max(tl + 1);
3076            }
3077        }
3078        active.remove(head);
3079        level.insert(head.to_string(), l);
3080        Ok(l)
3081    }
3082
3083    for head in &epistemic_heads {
3084        visit(head, &modal_edges, &mut level, &mut BTreeSet::new())?;
3085    }
3086
3087    Ok(Some(level))
3088}
3089
3090/// The epistemic determined heads in the transitive ordinary support of a determined
3091/// ORDINARY predicate.
3092///
3093/// For `r :- a` with `a` an epistemic-determined head, `support_of("r") = {"a"}`. The
3094/// search follows positive/negated ordinary body atoms (the ordinary derivation), and
3095/// collects any referenced predicate that is itself an epistemic head. Bounded by the
3096/// (acyclic) determined-closure, so a simple visited-set DFS terminates.
3097fn epistemic_support_of_determined_ordinary<'a>(
3098    program: &'a Program,
3099    predicate: &'a str,
3100    epistemic_heads: &BTreeSet<&'a str>,
3101) -> BTreeSet<&'a str> {
3102    let mut support: BTreeSet<&'a str> = BTreeSet::new();
3103    let mut seen: BTreeSet<&'a str> = BTreeSet::new();
3104    let mut stack: Vec<&'a str> = vec![predicate];
3105    while let Some(current) = stack.pop() {
3106        if !seen.insert(current) {
3107            continue;
3108        }
3109        for rule in &program.rules {
3110            if rule.head.predicate != current || rule.body.is_empty() {
3111                continue;
3112            }
3113            for lit in &rule.body {
3114                let referenced = match lit {
3115                    BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
3116                        atom.predicate.as_str()
3117                    }
3118                    // An epistemic literal in the support means `current` is itself an
3119                    // epistemic head; record it and do not descend through the modal.
3120                    BodyLiteral::Epistemic(_)
3121                    | BodyLiteral::Comparison(_)
3122                    | BodyLiteral::IsExpr(_)
3123                    | BodyLiteral::Univ(_) => continue,
3124                };
3125                if epistemic_heads.contains(referenced) {
3126                    support.insert(referenced);
3127                } else {
3128                    // Descend through ordinary derivations toward their epistemic roots.
3129                    stack.push(referenced);
3130                }
3131            }
3132        }
3133        // If `current` itself is an epistemic head, it is its own support root.
3134        if epistemic_heads.contains(current) && current != predicate {
3135            support.insert(current);
3136        }
3137    }
3138    support
3139}
3140
3141/// Build a self-contained sub-program for one stratum.
3142///
3143/// Includes this stratum's epistemic-defining rules plus every fact and every
3144/// ordinary (non-epistemic) supporting rule whose head is NOT a lower-stratum
3145/// epistemic head. Lower-stratum epistemic heads are intentionally OMITTED: at
3146/// execution they are present in the store as materialized base relations, and
3147/// including their (modal-stripped, ungated) defining rules would overwrite the
3148/// gated extension. Their `pred` declarations are retained so the reduced compiler
3149/// sees a schema for the materialized base relation.
3150fn build_stratum_subprogram(
3151    program: &Program,
3152    rule_indices: &[usize],
3153    head_predicates: &[String],
3154    stratum_level: &BTreeMap<String, usize>,
3155) -> Result<Program> {
3156    let this_level = head_predicates
3157        .iter()
3158        .filter_map(|h| stratum_level.get(h))
3159        .copied()
3160        .max()
3161        .unwrap_or(0);
3162
3163    // Lower-stratum epistemic heads: present as materialized base relations at
3164    // runtime; their defining rules must NOT appear in this sub-program.
3165    let lower_epistemic_heads: BTreeSet<&str> = stratum_level
3166        .iter()
3167        .filter(|(_, level)| **level < this_level)
3168        .map(|(head, _)| head.as_str())
3169        .collect();
3170
3171    // All epistemic-derived heads (used to compute an ordinary rule's epistemic
3172    // support for deferral of determined-ordinary supporting rules).
3173    let all_epistemic_heads: BTreeSet<&str> = program
3174        .rules
3175        .iter()
3176        .filter(|rule| {
3177            rule.body
3178                .iter()
3179                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
3180        })
3181        .map(|rule| rule.head.predicate.as_str())
3182        .collect();
3183
3184    let own_rule_indices: BTreeSet<usize> = rule_indices.iter().copied().collect();
3185
3186    let mut stratum = program.clone();
3187    stratum.rules = program
3188        .rules
3189        .iter()
3190        .enumerate()
3191        .filter_map(|(idx, rule)| {
3192            if own_rule_indices.contains(&idx) {
3193                return Some(rule.clone());
3194            }
3195            // Drop any rule that (re)defines a lower-stratum epistemic head.
3196            if lower_epistemic_heads.contains(rule.head.predicate.as_str()) {
3197                return None;
3198            }
3199            // Keep facts and ordinary supporting rules (EDB + non-epistemic
3200            // derivations the stratum's bodies may reference).
3201            let has_epistemic = rule
3202                .body
3203                .iter()
3204                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
3205            if has_epistemic && !own_rule_indices.contains(&idx) {
3206                // Another stratum's epistemic rule: exclude.
3207                return None;
3208            }
3209            // An ORDINARY supporting rule whose transitive epistemic support includes a
3210            // head NOT yet materialized (gated) at this level must NOT run here — it
3211            // would compute over the UNGATED candidate extension of that head and leak
3212            // the wrong tuples into the store (which the higher stratum then gates
3213            // against). Defer it to the lowest stratum where ALL its epistemic support
3214            // is already a materialized gated base relation. E.g. `r :- a` (a an
3215            // epistemic-determined head) is dropped from `a`'s own stratum (level 0) and
3216            // kept only in the strictly-higher stratum where `a` is materialized base,
3217            // so `r` is computed once from the gated `a`. Pure-ordinary rules over EDB
3218            // (empty epistemic support) are never deferred.
3219            let support = epistemic_support_of_determined_ordinary(
3220                program,
3221                rule.head.predicate.as_str(),
3222                &all_epistemic_heads,
3223            );
3224            if support
3225                .iter()
3226                .any(|h| stratum_level.get(*h).copied().unwrap_or(0) >= this_level)
3227            {
3228                return None;
3229            }
3230            Some(rule.clone())
3231        })
3232        .collect();
3233
3234    // Keep only the queries whose predicate this stratum materializes, so each
3235    // stratum's executable surfaces its own head(s).
3236    let head_set: BTreeSet<&str> = head_predicates.iter().map(String::as_str).collect();
3237    stratum.queries = program
3238        .queries
3239        .iter()
3240        .filter(|query| head_set.contains(query.atom.predicate.as_str()))
3241        .cloned()
3242        .collect();
3243
3244    // Drop constraints that reference predicates this stratum does not own, to keep
3245    // the sub-program self-contained.
3246    stratum.constraints = program
3247        .constraints
3248        .iter()
3249        .filter(|constraint| {
3250            constraint_predicate_set(constraint)
3251                .iter()
3252                .all(|p| head_set.contains(p.as_str()) || !is_program_head(program, p))
3253        })
3254        .cloned()
3255        .collect();
3256
3257    Ok(stratum)
3258}
3259
3260fn is_program_head(program: &Program, predicate: &str) -> bool {
3261    program
3262        .rules
3263        .iter()
3264        .any(|rule| !rule.body.is_empty() && rule.head.predicate == predicate)
3265}
3266
3267/// Partition an epistemic program into independently-evaluable components.
3268///
3269/// Builds the epistemic dependency graph (coalescing rules that couple distinct
3270/// epistemic body predicates into one component) and returns an
3271/// [`EpistemicSplitPlan`] describing which output heads evaluate together versus
3272/// in isolation. This is the entry point for the safe-split / joint-solving and
3273/// stratified-execution routing decisions in the GPU driver.
3274pub fn split_epistemic_program(program: &Program) -> Result<EpistemicSplitPlan> {
3275    // EGB-06: rules that couple more than one distinct epistemic body predicate
3276    // are NOT rejected here. The dependency graph already unions every such rule
3277    // into a single component (each epistemic predicate occurrence routes through
3278    // `modal_owner` in `build_epistemic_dependency_graph`), and that component is
3279    // recompiled through the unsplit joint path
3280    // (`compile_epistemic_gpu_execution`), which enumerates the full candidate
3281    // lattice and validates the FULL modal conjunction jointly on device. Any
3282    // genuinely out-of-fragment coupling (unsafe variables, unsupported
3283    // tuple-key/nested-modal semantics) stays fail-closed via the downstream
3284    // joint-path guards (`build_eir` safety analysis,
3285    // `validate_tuple_membership_bindings`, `validate_solver_contract`) with their
3286    // own typed source-contextualized diagnostics, so no blanket coupling
3287    // rejection is needed at the split boundary.
3288    Ok(EpistemicSplitPlan {
3289        components: build_epistemic_dependency_graph(program)?.components,
3290    })
3291}
3292
3293/// Compile valid epistemic split components through the production GPU executable path.
3294pub fn compile_epistemic_gpu_split_execution(
3295    program: &Program,
3296) -> Result<EpistemicSplitExecutablePlan> {
3297    compile_epistemic_gpu_split_execution_with_stats_snapshot(program, None)
3298}
3299
3300/// Compile valid epistemic split components with an optional production stats snapshot.
3301///
3302/// Each component subprogram is lowered through
3303/// [`compile_epistemic_gpu_execution_with_stats_snapshot`], so split execution
3304/// reuses the same GPU contract, reduced compiler pipeline, WCOJ promotion, and
3305/// helper-splitting surfaces as unsplit epistemic execution.
3306pub fn compile_epistemic_gpu_split_execution_with_stats_snapshot(
3307    program: &Program,
3308    stats_snapshot: Option<&StatsSnapshot>,
3309) -> Result<EpistemicSplitExecutablePlan> {
3310    reject_epistemic_constraints(program)?;
3311    let split_plan = split_epistemic_program(program)?;
3312    let mut components = Vec::new();
3313
3314    for component in &split_plan.components {
3315        if !component_has_epistemic_rule(program, component) {
3316            continue;
3317        }
3318
3319        // Cross-component coupling carrying >1 epistemic output head is either
3320        // JOINT-SOLVED (a coalesced component whose modal literals all range over
3321        // base/invariant predicates -- a shared accepted world view materializes
3322        // every head) or fails closed with a precise typed diagnostic (a modal
3323        // literal ranges over an epistemic-derived head of the same component, so
3324        // the heads' world-view acceptance is genuinely interdependent and the
3325        // independent split would be unsound). A single epistemic head is always
3326        // the existing single-output joint path.
3327        let coupling = classify_cross_component_modal_coupling(program, component)?;
3328
3329        let component_program = split_component_program(program, component)?;
3330        let executable = compile_epistemic_gpu_execution_inner(
3331            &component_program,
3332            stats_snapshot,
3333            coupling.allows_multiple_output_heads(),
3334        )?;
3335        components.push(EpistemicSplitExecutableComponent {
3336            component: component.clone(),
3337            executable,
3338        });
3339    }
3340
3341    if components.is_empty() {
3342        return Err(XlogError::UnsupportedEpistemicConstruct {
3343            construct: "epistemic GPU split execution".to_string(),
3344            context: "requires at least one epistemic split component".to_string(),
3345        });
3346    }
3347
3348    Ok(EpistemicSplitExecutablePlan {
3349        split_plan,
3350        components,
3351    })
3352}
3353
3354fn component_has_epistemic_rule(
3355    program: &Program,
3356    component: &EpistemicDependencyComponent,
3357) -> bool {
3358    component
3359        .rule_indices
3360        .iter()
3361        .filter_map(|idx| program.rules.get(*idx))
3362        .any(|rule| {
3363            rule.body
3364                .iter()
3365                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
3366        })
3367}
3368
3369/// Distinct head predicates of the component's epistemic-bearing rules, sorted.
3370///
3371/// Each such head is a final epistemic output relation the joint single-pass GPU
3372/// path would have to materialize. The single-output-buffer contract
3373/// ([`require_single_epistemic_output_relation`]) admits exactly one, so a count
3374/// above one means the component is genuinely *coupled* across what local
3375/// analysis would otherwise split — its epistemic outputs cannot be solved
3376/// independently AND cannot be jointly materialized into one buffer.
3377fn component_epistemic_output_heads(
3378    program: &Program,
3379    component: &EpistemicDependencyComponent,
3380) -> Vec<String> {
3381    let mut heads: BTreeSet<String> = BTreeSet::new();
3382    for idx in &component.rule_indices {
3383        let Some(rule) = program.rules.get(*idx) else {
3384            continue;
3385        };
3386        let has_epistemic_body = rule
3387            .body
3388            .iter()
3389            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
3390        if has_epistemic_body {
3391            heads.insert(rule.head.predicate.clone());
3392        }
3393    }
3394    heads.into_iter().collect()
3395}
3396
3397/// Render a coalesced component's merge reasons into a stable, human-readable list
3398/// for the cross-component coupling diagnostic.
3399///
3400/// These reasons (`DerivedPredicate`, `SharedModalPredicate`, `SharedHeadPredicate`,
3401/// `Constraint`) are exactly *why* the dependency graph could not split the
3402/// component's epistemic outputs, so naming them tells the caller which structural
3403/// coupling forced the fail-closed.
3404fn format_component_merge_reasons(component: &EpistemicDependencyComponent) -> String {
3405    if component.merge_reasons.is_empty() {
3406        return "no recorded coalesce reason".to_string();
3407    }
3408    component
3409        .merge_reasons
3410        .iter()
3411        .map(|reason| match reason {
3412            EpistemicComponentMergeReason::SharedHeadPredicate { predicate } => {
3413                format!("SharedHeadPredicate({predicate})")
3414            }
3415            EpistemicComponentMergeReason::DerivedPredicate { predicate } => {
3416                format!("DerivedPredicate({predicate})")
3417            }
3418            EpistemicComponentMergeReason::SharedModalPredicate { predicate } => {
3419                format!("SharedModalPredicate({predicate})")
3420            }
3421            EpistemicComponentMergeReason::Constraint { predicates } => {
3422                format!("Constraint({})", predicates.join(", "))
3423            }
3424        })
3425        .collect::<Vec<_>>()
3426        .join(", ")
3427}
3428
3429/// Classification of a coalesced epistemic component's cross-component coupling.
3430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3431enum CrossComponentCoupling {
3432    /// At most one epistemic output head, or a multi-head component whose modal
3433    /// literals all range over base/invariant predicates. The shared accepted
3434    /// world view materializes every head, so the component is JOINT-SOLVED.
3435    JointSolvable,
3436}
3437
3438impl CrossComponentCoupling {
3439    /// True when the component's GPU plan is permitted to carry more than one
3440    /// epistemic output head (joint multi-head materialization).
3441    fn allows_multiple_output_heads(self) -> bool {
3442        match self {
3443            CrossComponentCoupling::JointSolvable => true,
3444        }
3445    }
3446}
3447
3448/// Classify a coalesced component's cross-component modal coupling, JOINT-SOLVING
3449/// the canonical shared-base-modal case and failing closed (with a precise typed
3450/// diagnostic) on genuinely interdependent nested-epistemic coupling.
3451///
3452/// A coalesced component carrying more than one epistemic output head is either:
3453///
3454/// - **Joint-solvable** — every modal literal in the component ranges over a
3455///   predicate that is NOT an epistemic-derived head of the component (a
3456///   base/invariant relation or an ordinary-derived relation). The accepted
3457///   world-view set is then determined independently of which head is being
3458///   materialized, so one joint candidate enumeration + world-view validation
3459///   over the combined modal literals yields a single accepted world view, and
3460///   each head materialized against THAT world view equals its per-head
3461///   reduced-program evaluation. This is the canonical `SharedModalPredicate`
3462///   joint-solving target (`a(X):-know q(X). b(X):-possible q(X).` over base `q`).
3463///
3464/// - **Genuinely interdependent (fail closed)** — some modal literal ranges over
3465///   an EPISTEMIC-DERIVED head of the same component (`flagged():-know trusted()`
3466///   where `trusted` is itself `know`-derived). The modal truth of that predicate
3467///   depends on a DIFFERENT head's accepted world view, so the heads' acceptance
3468///   is mutually entangled (nested/stratified epistemic dependency). Solving it
3469///   would require stratified world-view nesting that the single joint enumeration
3470///   does not provide, so it stays FAIL-CLOSED with a typed diagnostic naming the
3471///   coupled heads, the modal predicate, and the merge reason -- never silently
3472///   mis-evaluated.
3473///
3474/// SAFE single-epistemic-head coupling (an ordinary body consuming an epistemic
3475/// head, `b():-a()` over `a():-know p()`) and EDB-only sharing are both
3476/// `JointSolvable` (one or zero coupled heads), so they stay accepted.
3477/// Compute the predicates whose extension depends, directly or transitively
3478/// through ordinary rules in the component, on an epistemic-derived head.
3479///
3480/// Seeded with the component's epistemic output heads (each is "tainted" because
3481/// its extension is gated by a modal literal), then closed under the rule
3482/// dependency relation: a head becomes tainted when ANY rule defining it (within
3483/// the component) references an already-tainted predicate in its body. A modal
3484/// literal over a tainted predicate is a nested/stratified epistemic dependency.
3485fn epistemic_tainted_predicates<'a>(
3486    program: &'a Program,
3487    component: &EpistemicDependencyComponent,
3488    epistemic_heads: &'a [String],
3489) -> BTreeSet<&'a str> {
3490    let mut tainted: BTreeSet<&str> = epistemic_heads.iter().map(String::as_str).collect();
3491    // Iterate the component's rules to a least fixpoint: a rule's head is tainted
3492    // if any body atom references a tainted predicate.
3493    let mut changed = true;
3494    while changed {
3495        changed = false;
3496        for idx in &component.rule_indices {
3497            let Some(rule) = program.rules.get(*idx) else {
3498                continue;
3499            };
3500            if tainted.contains(rule.head.predicate.as_str()) {
3501                continue;
3502            }
3503            // `BodyLiteral::atom()` covers relational AND epistemic literals
3504            // (the modal predicate), so this taints a head whether it depends on a
3505            // tainted predicate ordinarily or through a modal literal.
3506            let body_touches_tainted = rule.body.iter().any(|lit| {
3507                lit.atom()
3508                    .map(|atom| tainted.contains(atom.predicate.as_str()))
3509                    .unwrap_or(false)
3510            });
3511            if body_touches_tainted {
3512                tainted.insert(rule.head.predicate.as_str());
3513                changed = true;
3514            }
3515        }
3516    }
3517    tainted
3518}
3519
3520fn classify_cross_component_modal_coupling(
3521    program: &Program,
3522    component: &EpistemicDependencyComponent,
3523) -> Result<CrossComponentCoupling> {
3524    let epistemic_heads = component_epistemic_output_heads(program, component);
3525    if epistemic_heads.len() <= 1 {
3526        return Ok(CrossComponentCoupling::JointSolvable);
3527    }
3528
3529    // A modal literal ranging over a predicate whose extension DEPENDS (directly
3530    // OR TRANSITIVELY, through ordinary rules in this component) on an
3531    // epistemic-derived head is a nested/stratified epistemic dependency that the
3532    // single joint enumeration cannot solve soundly: that modal's truth would have
3533    // to be re-evaluated under EACH candidate world view chosen for the head it
3534    // depends on, which one shared world-view enumeration does not provide.
3535    //
3536    // "Epistemic-tainted" predicates = epistemic-derived heads, closed under the
3537    // ordinary rule dependency relation within the component (least fixpoint). A
3538    // modal over any tainted predicate fails closed. A modal over a purely
3539    // base/invariant or epistemic-INDEPENDENT predicate is joint-solvable.
3540    let tainted = epistemic_tainted_predicates(program, component, &epistemic_heads);
3541
3542    let mut nested_modal_predicates: BTreeSet<String> = BTreeSet::new();
3543    for idx in &component.rule_indices {
3544        let Some(rule) = program.rules.get(*idx) else {
3545            continue;
3546        };
3547        for lit in &rule.body {
3548            if let BodyLiteral::Epistemic(modal) = lit {
3549                if tainted.contains(modal.atom.predicate.as_str()) {
3550                    nested_modal_predicates.insert(format!(
3551                        "{}/{}",
3552                        modal.atom.predicate,
3553                        modal.atom.arity()
3554                    ));
3555                }
3556            }
3557        }
3558    }
3559
3560    if nested_modal_predicates.is_empty() {
3561        // Every modal literal ranges over a predicate that is independent of every
3562        // epistemic-derived head, so the accepted world view is determined solely
3563        // by base/invariant relations and the component is joint-solvable over one
3564        // shared accepted world view.
3565        return Ok(CrossComponentCoupling::JointSolvable);
3566    }
3567
3568    Err(XlogError::UnsupportedEpistemicConstruct {
3569        construct: "cross-component epistemic coupling".to_string(),
3570        context: format!(
3571            "epistemic output heads {:?} are coupled into a single dependency \
3572             component (reasons: {}) through nested modal literals over \
3573             epistemic-derived predicates {:?}; the modal truth of an \
3574             epistemic-derived head depends on another head's accepted world view, \
3575             so a single joint world-view enumeration would mis-evaluate the \
3576             nested modality and an independent split would be unsound, so this \
3577             fails closed",
3578            epistemic_heads,
3579            format_component_merge_reasons(component),
3580            nested_modal_predicates.into_iter().collect::<Vec<_>>(),
3581        ),
3582    })
3583}
3584
3585fn split_component_program(
3586    program: &Program,
3587    component: &EpistemicDependencyComponent,
3588) -> Result<Program> {
3589    let mut component_program = program.clone();
3590    let component_predicates: BTreeSet<&str> =
3591        component.predicates.iter().map(String::as_str).collect();
3592    let component_rule_indices: BTreeSet<usize> = component.rule_indices.iter().copied().collect();
3593    let head_predicates: BTreeSet<&str> = program
3594        .rules
3595        .iter()
3596        .map(|rule| rule.head.predicate.as_str())
3597        .collect();
3598    component_program.rules = program
3599        .rules
3600        .iter()
3601        .enumerate()
3602        .filter_map(|(idx, rule)| {
3603            (component_rule_indices.contains(&idx)
3604                || (rule.body.is_empty()
3605                    && component_predicates.contains(rule.head.predicate.as_str())))
3606            .then_some(rule.clone())
3607        })
3608        .collect();
3609    component_program.constraints = program
3610        .constraints
3611        .iter()
3612        .filter(|constraint| {
3613            let predicates = constraint_predicate_set(constraint);
3614            let has_component_owned_predicate = predicates
3615                .iter()
3616                .any(|predicate| head_predicates.contains(predicate.as_str()));
3617            !has_component_owned_predicate
3618                || predicates
3619                    .iter()
3620                    .all(|predicate| component_predicates.contains(predicate.as_str()))
3621        })
3622        .cloned()
3623        .collect();
3624    Ok(component_program)
3625}