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 positive_relational_atoms = Vec::new();
343        let mut has_negated_relational_atom = false;
344
345        for lit in &rule.body {
346            match lit {
347                EirBodyLiteral::Relational { negated, atom } => {
348                    if *negated {
349                        has_negated_relational_atom = true;
350                    } else {
351                        positive_relational_atoms.push(atom.clone());
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: positive_relational_atoms.len(),
407            wcoj_status: wcoj_status_for_reduction(
408                &positive_relational_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 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 deferred 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    // NEGATED-MODAL 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 reduced ordinary-body planner contract: cardinality,
1279/// selectivity, access heat, prefix-degree, sorted-layout, and helper-splitting
1280/// decisions are owned by the existing production compiler pipeline rather than
1281/// 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 validate 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 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    // 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 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    positive_relational_atoms: &[xlog_ir::EirAtom],
2181    has_negated_relational_atom: bool,
2182) -> EpistemicWcojReductionStatus {
2183    if !has_negated_relational_atom
2184        && positive_relational_atoms_are_supported_wcoj_shape(positive_relational_atoms)
2185    {
2186        EpistemicWcojReductionStatus::RequiresPlannerEligibility
2187    } else {
2188        EpistemicWcojReductionStatus::NotWcojCandidate
2189    }
2190}
2191
2192fn positive_relational_atoms_are_supported_wcoj_shape(atoms: &[xlog_ir::EirAtom]) -> bool {
2193    let mut edges: BTreeSet<(String, String)> = BTreeSet::new();
2194    let mut degrees: BTreeMap<String, usize> = BTreeMap::new();
2195    for atom in atoms {
2196        if atom.arity != 2 || atom.terms.len() != 2 {
2197            return false;
2198        }
2199        let Some(left) = eir_variable_name(&atom.terms[0]) else {
2200            return false;
2201        };
2202        let Some(right) = eir_variable_name(&atom.terms[1]) else {
2203            return false;
2204        };
2205        if left == right {
2206            return false;
2207        }
2208        let edge = if left < right {
2209            (left.to_string(), right.to_string())
2210        } else {
2211            (right.to_string(), left.to_string())
2212        };
2213        if !edges.insert(edge.clone()) {
2214            return false;
2215        }
2216        *degrees.entry(edge.0).or_insert(0) += 1;
2217        *degrees.entry(edge.1).or_insert(0) += 1;
2218    }
2219
2220    match edges.len() {
2221        3 => degrees.len() == 3 && degrees.values().all(|degree| *degree == 2),
2222        4 => degrees.len() == 4 && degrees.values().all(|degree| *degree == 2),
2223        10 | 15 | 21 | 28 => {
2224            let variable_count = degrees.len();
2225            (5..=8).contains(&variable_count)
2226                && edges.len() == variable_count * (variable_count - 1) / 2
2227                && degrees.values().all(|degree| *degree == variable_count - 1)
2228        }
2229        _ => false,
2230    }
2231}
2232
2233fn eir_variable_name(term: &EirTerm) -> Option<&str> {
2234    match term {
2235        EirTerm::Variable(name) => Some(name.as_str()),
2236        _ => None,
2237    }
2238}
2239
2240/// Result of bounded FAEEL candidate evaluation.
2241#[derive(Debug, Clone, PartialEq, Eq)]
2242pub enum FaeelCandidateResult {
2243    /// Candidate satisfies the bounded FAEEL fixture semantics.
2244    Model,
2245    /// Candidate has no model for a typed reason.
2246    NoModel(FaeelNoModelReason),
2247}
2248
2249/// Typed no-model reason for bounded FAEEL fixtures.
2250#[derive(Debug, Clone, PartialEq, Eq)]
2251pub enum FaeelNoModelReason {
2252    /// Candidate uses possible-only support where FAEEL requires founded knowledge.
2253    UnfoundedPossible {
2254        /// Predicate name.
2255        predicate: String,
2256        /// Predicate arity.
2257        arity: usize,
2258    },
2259    /// Candidate marks the same atom known and rejected.
2260    Contradiction {
2261        /// Predicate name.
2262        predicate: String,
2263        /// Predicate arity.
2264        arity: usize,
2265    },
2266    /// An epistemic literal is unsatisfied by the candidate.
2267    UnsatisfiedLiteral {
2268        /// Predicate name.
2269        predicate: String,
2270        /// Predicate arity.
2271        arity: usize,
2272    },
2273}
2274
2275/// Configuration for bounded Generate-Propagate-Test fixture execution.
2276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2277pub struct GeneratePropagateTestConfig {
2278    /// Maximum candidate count accepted by the generate phase.
2279    pub max_candidates: usize,
2280}
2281
2282/// Phase counters emitted by bounded Generate-Propagate-Test execution.
2283#[derive(Debug, Clone, Default, PartialEq, Eq)]
2284pub struct GeneratePropagateTestTrace {
2285    /// Number of generated candidates.
2286    pub generated: usize,
2287    /// Number of epistemic guesses generated.
2288    pub guesses: usize,
2289    /// Number of candidates that survived propagation.
2290    pub propagated: usize,
2291    /// Number of candidates pruned during propagation.
2292    pub pruned: usize,
2293    /// Number of reduced-program models inspected by the test phase.
2294    pub reduced_program_models: usize,
2295    /// Number of candidates tested.
2296    pub tested: usize,
2297    /// Number of accepted candidates.
2298    pub accepted: usize,
2299    /// Number of accepted world views.
2300    pub accepted_world_views: usize,
2301    /// Number of rejected candidates.
2302    pub rejected: usize,
2303    /// Rejection reasons observed during propagation and testing.
2304    pub rejection_reasons: Vec<FaeelNoModelReason>,
2305}
2306
2307/// Result of bounded Generate-Propagate-Test fixture execution.
2308#[derive(Debug, Clone, PartialEq, Eq)]
2309pub struct GeneratePropagateTestOutcome {
2310    /// Phase counts.
2311    pub trace: GeneratePropagateTestTrace,
2312    /// Original indices of accepted candidates.
2313    pub accepted_candidate_indices: Vec<usize>,
2314    /// Original indices of rejected candidates in rejection-reason order.
2315    pub rejected_candidate_indices: Vec<usize>,
2316}
2317
2318/// Reason that two source rules were coalesced into the same dependency component.
2319///
2320/// These reasons make the split planner's structural decisions explainable: a
2321/// caller can read, for every component, *why* its rules could not be solved
2322/// independently of one another (K3 split diagnostics).
2323#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2324pub enum EpistemicComponentMergeReason {
2325    /// Two rules share the same head predicate, so they jointly define one
2326    /// derived relation and must be solved together.
2327    SharedHeadPredicate {
2328        /// Head predicate defined by both rules.
2329        predicate: String,
2330    },
2331    /// One rule's body consumes a predicate that another rule derives in its
2332    /// head (an ordinary/negated derived dependency).
2333    DerivedPredicate {
2334        /// Head predicate produced by the producer rule and consumed by the
2335        /// consumer rule body.
2336        predicate: String,
2337    },
2338    /// Two rules reference the same epistemic (modal) predicate, so their
2339    /// world-view acceptance is mutually dependent.
2340    SharedModalPredicate {
2341        /// Epistemic predicate referenced by both rules, with arity.
2342        predicate: String,
2343    },
2344    /// An integrity constraint mentions head predicates owned by both rules, so
2345    /// the constraint coalesces exactly those components.
2346    Constraint {
2347        /// Constraint-mentioned head predicates that forced the coalesce.
2348        predicates: Vec<String>,
2349    },
2350}
2351
2352/// One deterministic dependency component for epistemic splitting.
2353#[derive(Debug, Clone, PartialEq, Eq)]
2354pub struct EpistemicDependencyComponent {
2355    /// Sorted predicate names in the component.
2356    pub predicates: Vec<String>,
2357    /// Source rule indices owned by the component.
2358    pub rule_indices: Vec<usize>,
2359    /// Sorted, deduplicated reasons the component's rules were coalesced.
2360    ///
2361    /// Empty when the component is a single independent rule that no
2362    /// dependency forced together (it was split out on its own).
2363    pub merge_reasons: Vec<EpistemicComponentMergeReason>,
2364}
2365
2366/// Deterministic dependency graph used by bounded epistemic splitting.
2367#[derive(Debug, Clone, PartialEq, Eq)]
2368pub struct EpistemicDependencyGraph {
2369    /// Sorted components.
2370    pub components: Vec<EpistemicDependencyComponent>,
2371}
2372
2373/// Split plan for independently solvable epistemic components.
2374#[derive(Debug, Clone, PartialEq, Eq)]
2375pub struct EpistemicSplitPlan {
2376    /// Components to solve independently.
2377    pub components: Vec<EpistemicDependencyComponent>,
2378}
2379
2380impl EpistemicSplitPlan {
2381    /// Return the original rule order recovered from all components.
2382    pub fn recomposed_rule_indices(&self) -> Vec<usize> {
2383        let mut indices: Vec<usize> = self
2384            .components
2385            .iter()
2386            .flat_map(|component| component.rule_indices.iter().copied())
2387            .collect();
2388        indices.sort_unstable();
2389        indices
2390    }
2391}
2392
2393/// One split component lowered through the production epistemic GPU plan path.
2394#[derive(Debug, Clone)]
2395pub struct EpistemicSplitExecutableComponent {
2396    /// Source dependency component covered by this executable subplan.
2397    pub component: EpistemicDependencyComponent,
2398    /// GPU contract plus reduced runtime plan for this component.
2399    pub executable: EpistemicExecutablePlan,
2400}
2401
2402/// Executable split plan whose components reuse the normal epistemic GPU lowering.
2403#[derive(Debug, Clone)]
2404pub struct EpistemicSplitExecutablePlan {
2405    /// Original bounded split plan.
2406    pub split_plan: EpistemicSplitPlan,
2407    /// Epistemic components compiled into GPU executable subplans.
2408    pub components: Vec<EpistemicSplitExecutableComponent>,
2409}
2410
2411impl EpistemicSplitExecutablePlan {
2412    /// Return the source rule indices actually recomposed by GPU split execution.
2413    ///
2414    /// This reflects the rules the *executable* plan runs: epistemic-bearing
2415    /// components only. Pure-ordinary independent components carry no epistemic
2416    /// output buffer and are not part of the epistemic execution surface, so
2417    /// they are intentionally excluded here. The full dependency-graph view
2418    /// (including non-executed ordinary components) lives on
2419    /// [`EpistemicSplitPlan::recomposed_rule_indices`]; the two coincide exactly
2420    /// when every component is epistemic-bearing.
2421    pub fn recomposed_rule_indices(&self) -> Vec<usize> {
2422        let mut indices: Vec<usize> = self
2423            .components
2424            .iter()
2425            .flat_map(|component| component.component.rule_indices.iter().copied())
2426            .collect();
2427        indices.sort_unstable();
2428        indices
2429    }
2430
2431    /// Return the full dependency-graph recomposition view, including
2432    /// independent non-epistemic components that the executable plan does not run.
2433    pub fn planned_recomposed_rule_indices(&self) -> Vec<usize> {
2434        self.split_plan.recomposed_rule_indices()
2435    }
2436
2437    /// Return executable components ordered by the first source rule they cover.
2438    pub fn recomposed_components(&self) -> Vec<&EpistemicSplitExecutableComponent> {
2439        let mut components: Vec<_> = self.components.iter().collect();
2440        components.sort_by_key(|component| {
2441            component
2442                .component
2443                .rule_indices
2444                .iter()
2445                .copied()
2446                .min()
2447                .unwrap_or(usize::MAX)
2448        });
2449        components
2450    }
2451}
2452
2453/// Evaluate a single parsed epistemic literal against a bounded interpretation.
2454pub fn evaluate_epistemic_literal(
2455    mode: EpistemicMode,
2456    lit: &EpistemicLiteral,
2457    interpretation: &EpistemicInterpretation,
2458) -> TruthValue {
2459    let value = match lit.op {
2460        EpistemicOp::Know => interpretation.contains_known(&lit.atom),
2461        EpistemicOp::Possible => match mode {
2462            EpistemicMode::G91 => {
2463                interpretation.contains_known(&lit.atom)
2464                    || interpretation.contains_possible(&lit.atom)
2465            }
2466            EpistemicMode::Faeel => interpretation.contains_known(&lit.atom),
2467        },
2468    };
2469
2470    TruthValue::from_bool(if lit.negated { !value } else { value })
2471}
2472
2473/// Evaluate all epistemic literals in a program under bounded FAEEL fixture semantics.
2474pub fn evaluate_faeel_candidate(
2475    program: &Program,
2476    interpretation: &EpistemicInterpretation,
2477) -> Result<FaeelCandidateResult> {
2478    evaluate_epistemic_candidate(program, interpretation, EpistemicMode::Faeel)
2479}
2480
2481/// Evaluate all epistemic literals in a program under a bounded fixture semantics mode.
2482pub fn evaluate_epistemic_candidate(
2483    program: &Program,
2484    interpretation: &EpistemicInterpretation,
2485    mode: EpistemicMode,
2486) -> Result<FaeelCandidateResult> {
2487    reject_gpt_epistemic_constraints(program)?;
2488    if let Some((predicate, arity)) = interpretation.first_contradiction() {
2489        return Ok(FaeelCandidateResult::NoModel(
2490            FaeelNoModelReason::Contradiction { predicate, arity },
2491        ));
2492    }
2493
2494    for rule in &program.rules {
2495        for body_lit in &rule.body {
2496            let BodyLiteral::Epistemic(lit) = body_lit else {
2497                continue;
2498            };
2499            if interpretation.contains_known(&lit.atom)
2500                && interpretation.contains_rejected(&lit.atom)
2501            {
2502                return Ok(FaeelCandidateResult::NoModel(
2503                    FaeelNoModelReason::Contradiction {
2504                        predicate: lit.atom.predicate.clone(),
2505                        arity: lit.atom.arity(),
2506                    },
2507                ));
2508            }
2509            if mode == EpistemicMode::Faeel
2510                && lit.op == EpistemicOp::Possible
2511                && interpretation.contains_possible(&lit.atom)
2512                && !interpretation.contains_known(&lit.atom)
2513            {
2514                return Ok(FaeelCandidateResult::NoModel(
2515                    FaeelNoModelReason::UnfoundedPossible {
2516                        predicate: lit.atom.predicate.clone(),
2517                        arity: lit.atom.arity(),
2518                    },
2519                ));
2520            }
2521            if evaluate_epistemic_literal(mode, lit, interpretation) == TruthValue::False {
2522                return Ok(FaeelCandidateResult::NoModel(
2523                    FaeelNoModelReason::UnsatisfiedLiteral {
2524                        predicate: lit.atom.predicate.clone(),
2525                        arity: lit.atom.arity(),
2526                    },
2527                ));
2528            }
2529        }
2530    }
2531
2532    Ok(FaeelCandidateResult::Model)
2533}
2534
2535/// Run bounded Generate-Propagate-Test execution over explicit candidates.
2536pub fn run_generate_propagate_test(
2537    program: &Program,
2538    candidates: Vec<EpistemicInterpretation>,
2539    config: GeneratePropagateTestConfig,
2540) -> Result<GeneratePropagateTestOutcome> {
2541    run_generate_propagate_test_with_mode(
2542        program,
2543        candidates,
2544        config,
2545        program.directives.epistemic_mode_or_default(),
2546    )
2547}
2548
2549/// Run bounded Generate-Propagate-Test execution over explicit candidates and semantics mode.
2550pub fn run_generate_propagate_test_with_mode(
2551    program: &Program,
2552    candidates: Vec<EpistemicInterpretation>,
2553    config: GeneratePropagateTestConfig,
2554    mode: EpistemicMode,
2555) -> Result<GeneratePropagateTestOutcome> {
2556    reject_gpt_epistemic_constraints(program)?;
2557    if candidates.len() > config.max_candidates {
2558        return Err(xlog_core::XlogError::ResourceExhausted {
2559            context: "epistemic GPT candidate guard".to_string(),
2560            estimated_bytes: candidates.len() as u64,
2561            budget_bytes: config.max_candidates as u64,
2562        });
2563    }
2564
2565    let generated = candidates.len();
2566    let guesses = candidates
2567        .iter()
2568        .map(EpistemicInterpretation::epistemic_guess_count)
2569        .sum();
2570    let mut propagated_candidates = Vec::new();
2571    let mut rejection_reasons = Vec::new();
2572    let mut rejected_candidate_indices = Vec::new();
2573    for (idx, candidate) in candidates.into_iter().enumerate() {
2574        if let Some((predicate, arity)) = candidate.first_contradiction() {
2575            rejection_reasons.push(FaeelNoModelReason::Contradiction { predicate, arity });
2576            rejected_candidate_indices.push(idx);
2577        } else {
2578            propagated_candidates.push((idx, candidate));
2579        }
2580    }
2581
2582    let mut trace = GeneratePropagateTestTrace {
2583        generated,
2584        guesses,
2585        propagated: propagated_candidates.len(),
2586        pruned: generated.saturating_sub(propagated_candidates.len()),
2587        reduced_program_models: propagated_candidates.len(),
2588        rejection_reasons,
2589        ..GeneratePropagateTestTrace::default()
2590    };
2591    let mut accepted_candidate_indices = Vec::new();
2592
2593    for (idx, candidate) in &propagated_candidates {
2594        trace.tested += 1;
2595        match evaluate_epistemic_candidate(program, candidate, mode)? {
2596            FaeelCandidateResult::Model => {
2597                trace.accepted += 1;
2598                trace.accepted_world_views += 1;
2599                accepted_candidate_indices.push(*idx);
2600            }
2601            FaeelCandidateResult::NoModel(reason) => {
2602                trace.rejected += 1;
2603                trace.rejection_reasons.push(reason);
2604                rejected_candidate_indices.push(*idx);
2605            }
2606        }
2607    }
2608
2609    Ok(GeneratePropagateTestOutcome {
2610        trace,
2611        accepted_candidate_indices,
2612        rejected_candidate_indices,
2613    })
2614}
2615
2616/// Build a deterministic dependency graph for bounded epistemic splitting.
2617pub fn build_epistemic_dependency_graph(program: &Program) -> Result<EpistemicDependencyGraph> {
2618    if program.rules.is_empty() {
2619        return Ok(EpistemicDependencyGraph { components: vec![] });
2620    }
2621
2622    let mut parents: Vec<usize> = (0..program.rules.len()).collect();
2623    let mut rule_predicates = Vec::with_capacity(program.rules.len());
2624    let mut head_owner: BTreeMap<String, usize> = BTreeMap::new();
2625    // Each merge records (one source rule index touched by the merge, reason).
2626    // After roots collapse, reasons are attributed to the surviving root so the
2627    // emitted component carries an explainable account of why it was coalesced.
2628    let mut merge_log: Vec<(usize, EpistemicComponentMergeReason)> = Vec::new();
2629
2630    for (idx, rule) in program.rules.iter().enumerate() {
2631        if rule.body.is_empty() {
2632            continue;
2633        }
2634        if let Some(owner) = head_owner.get(&rule.head.predicate).copied() {
2635            union_components(&mut parents, owner, idx);
2636            merge_log.push((
2637                idx,
2638                EpistemicComponentMergeReason::SharedHeadPredicate {
2639                    predicate: rule.head.predicate.clone(),
2640                },
2641            ));
2642        } else {
2643            head_owner.insert(rule.head.predicate.clone(), idx);
2644        }
2645    }
2646
2647    let mut modal_owner: BTreeMap<EpistemicAtomKey, usize> = BTreeMap::new();
2648    for (idx, rule) in program.rules.iter().enumerate() {
2649        let mut predicates = BTreeSet::new();
2650        predicates.insert(rule.head.predicate.clone());
2651        for lit in &rule.body {
2652            if let BodyLiteral::Epistemic(lit) = lit {
2653                let key =
2654                    EpistemicAtomKey::from_arity(lit.atom.predicate.clone(), lit.atom.arity());
2655                if let Some(owner) = modal_owner.get(&key).copied() {
2656                    union_components(&mut parents, owner, idx);
2657                    merge_log.push((
2658                        idx,
2659                        EpistemicComponentMergeReason::SharedModalPredicate {
2660                            predicate: format!("{}/{}", lit.atom.predicate, lit.atom.arity()),
2661                        },
2662                    ));
2663                } else {
2664                    modal_owner.insert(key, idx);
2665                }
2666            }
2667            if let Some(atom) = lit.atom() {
2668                if let Some(owner) = head_owner.get(&atom.predicate).copied() {
2669                    if owner != idx {
2670                        union_components(&mut parents, owner, idx);
2671                        merge_log.push((
2672                            idx,
2673                            EpistemicComponentMergeReason::DerivedPredicate {
2674                                predicate: atom.predicate.clone(),
2675                            },
2676                        ));
2677                    }
2678                }
2679                predicates.insert(atom.predicate.clone());
2680            }
2681        }
2682
2683        rule_predicates.push(predicates);
2684    }
2685
2686    let mut constraint_predicates = Vec::with_capacity(program.constraints.len());
2687    for constraint in &program.constraints {
2688        let predicates = constraint_predicate_set(constraint);
2689        let mut owners = predicates
2690            .iter()
2691            .filter_map(|predicate| head_owner.get(predicate).copied());
2692        if let Some(first_owner) = owners.next() {
2693            let mut coalesced_any = false;
2694            for owner in owners {
2695                if find_component(&mut parents, first_owner) != find_component(&mut parents, owner)
2696                {
2697                    coalesced_any = true;
2698                }
2699                union_components(&mut parents, first_owner, owner);
2700            }
2701            if coalesced_any {
2702                let constraint_heads: Vec<String> = predicates
2703                    .iter()
2704                    .filter(|predicate| head_owner.contains_key(*predicate))
2705                    .cloned()
2706                    .collect();
2707                merge_log.push((
2708                    first_owner,
2709                    EpistemicComponentMergeReason::Constraint {
2710                        predicates: constraint_heads,
2711                    },
2712                ));
2713            }
2714        }
2715        constraint_predicates.push(predicates);
2716    }
2717
2718    let mut grouped: BTreeMap<usize, (BTreeSet<String>, Vec<usize>)> = BTreeMap::new();
2719    for (idx, predicates) in rule_predicates.into_iter().enumerate() {
2720        let root = find_component(&mut parents, idx);
2721        let entry = grouped
2722            .entry(root)
2723            .or_insert_with(|| (BTreeSet::new(), vec![]));
2724        entry.0.extend(predicates);
2725        entry.1.push(idx);
2726    }
2727    for predicates in constraint_predicates {
2728        let Some(root) = predicates
2729            .iter()
2730            .filter_map(|predicate| head_owner.get(predicate).copied())
2731            .map(|idx| find_component(&mut parents, idx))
2732            .next()
2733        else {
2734            continue;
2735        };
2736        grouped
2737            .entry(root)
2738            .or_insert_with(|| (BTreeSet::new(), vec![]))
2739            .0
2740            .extend(predicates);
2741    }
2742
2743    // Attribute every recorded merge reason to its surviving component root.
2744    let mut reasons_by_root: BTreeMap<usize, BTreeSet<EpistemicComponentMergeReason>> =
2745        BTreeMap::new();
2746    for (touched_idx, reason) in merge_log {
2747        let root = find_component(&mut parents, touched_idx);
2748        reasons_by_root.entry(root).or_default().insert(reason);
2749    }
2750
2751    let mut components: Vec<EpistemicDependencyComponent> = grouped
2752        .into_iter()
2753        .map(|(root, (predicates, mut rule_indices))| {
2754            rule_indices.sort_unstable();
2755            let merge_reasons = reasons_by_root
2756                .remove(&root)
2757                .map(|reasons| reasons.into_iter().collect())
2758                .unwrap_or_default();
2759            EpistemicDependencyComponent {
2760                predicates: predicates.into_iter().collect(),
2761                rule_indices,
2762                merge_reasons,
2763            }
2764        })
2765        .collect();
2766    components.sort_by(|a, b| a.predicates.cmp(&b.predicates));
2767    Ok(EpistemicDependencyGraph { components })
2768}
2769
2770fn constraint_predicate_set(constraint: &Constraint) -> BTreeSet<String> {
2771    constraint
2772        .body
2773        .iter()
2774        .filter_map(|lit| lit.atom().map(|atom| atom.predicate.clone()))
2775        .collect()
2776}
2777
2778fn find_component(parents: &mut [usize], idx: usize) -> usize {
2779    if parents[idx] != idx {
2780        let root = find_component(parents, parents[idx]);
2781        parents[idx] = root;
2782    }
2783    parents[idx]
2784}
2785
2786fn union_components(parents: &mut [usize], left: usize, right: usize) {
2787    let left_root = find_component(parents, left);
2788    let right_root = find_component(parents, right);
2789    if left_root != right_root {
2790        parents[right_root] = left_root;
2791    }
2792}
2793
2794/// Split an epistemic program into independently solvable bounded components.
2795/// One stratum of a stratified epistemic program: a self-contained sub-program
2796/// whose epistemic heads gate only over EDB/invariant relations OR over the
2797/// materialized (now-base) outputs of strictly-lower strata.
2798#[derive(Debug, Clone)]
2799pub struct EpistemicStratum {
2800    /// The epistemic output head predicate(s) this stratum materializes.
2801    pub head_predicates: Vec<String>,
2802    /// Source-rule indices owned by this stratum.
2803    pub rule_indices: Vec<usize>,
2804    /// The self-contained sub-program for this stratum (its own defining rules
2805    /// plus the facts/EDB it needs). Lower-stratum heads are NOT redefined here;
2806    /// at execution they are present in the store as materialized base relations.
2807    pub program: Program,
2808}
2809
2810/// A stratified epistemic execution plan: an ordered sequence of strata.
2811///
2812/// Stratum `i`'s epistemic heads are materialized (gated) into the relation store
2813/// BEFORE stratum `i+1` runs, so a higher stratum's `know`/`possible` over a
2814/// lower stratum's head reads the GATED extension through the EXISTING
2815/// membership filter (no resolve-into-body, no double-gating).
2816#[derive(Debug, Clone)]
2817pub struct EpistemicStratifiedPlan {
2818    /// Strata in execution (topological) order.
2819    pub strata: Vec<EpistemicStratum>,
2820}
2821
2822/// Predicates whose epistemic extension is DETERMINED once lower strata are fixed.
2823///
2824/// A predicate is *epistemically determined* when every defining rule uses only
2825/// (a) positive `know`/`possible` modals and ordinary positive/negated literals,
2826/// (b) all ranging over predicates that are themselves invariant (EDB/lower
2827/// non-epistemic stratum) OR already epistemically determined, and (c) the
2828/// dependency is acyclic through BOTH modal and ordinary edges. Such a head's
2829/// materialized (gated) extension IS its truth, so it can be materialized into the
2830/// store as a base relation and a higher stratum can gate against it.
2831///
2832/// This is a STANDALONE analysis: it never feeds
2833/// [`reduce_case_a_epistemic_program_to_ordinary`] / `is_invariant`, so it cannot
2834/// trigger the resolve-into-body double-gating that the single-pass GPU filter
2835/// already performs.
2836struct EpistemicallyDeterminedPredicates {
2837    determined: BTreeSet<String>,
2838}
2839
2840impl EpistemicallyDeterminedPredicates {
2841    fn analyze(program: &Program) -> Self {
2842        let invariant = InvariantRelations::analyze(program);
2843
2844        // Heads defined by at least one rule.
2845        let mut derived_heads: BTreeSet<&str> = BTreeSet::new();
2846        for rule in &program.rules {
2847            if !rule.body.is_empty() {
2848                derived_heads.insert(rule.head.predicate.as_str());
2849            }
2850        }
2851
2852        // Least-fixpoint closure over ALL derived heads (epistemic AND ordinary): a
2853        // predicate becomes determined when EVERY rule defining it ranges (modal +
2854        // ordinary) only over invariant or already-determined predicates, with no
2855        // self-reference (acyclic).
2856        //
2857        // An ORDINARY head is determined transitively when every defining rule ranges
2858        // only over determined/invariant relations (e.g. `r :- a` with `a` a
2859        // determined epistemic head). Such an `r` is determined-in-principle: its
2860        // extension is fixed once the determined heads it derives from are fixed, so a
2861        // higher modal `know r`/`possible r` can stratify against the materialized
2862        // base `r` via the existing membership filter. The acyclicity guard in
2863        // `head_is_determined` (self-reference returns false) plus the fixpoint's
2864        // monotonicity keep every recursive predicate OUT of `determined`, so a
2865        // circular `know reach` in a recursive SCC (example 22) is never determined
2866        // and stays fail-closed.
2867        let mut determined: BTreeSet<String> = BTreeSet::new();
2868        let mut changed = true;
2869        while changed {
2870            changed = false;
2871            for head in &derived_heads {
2872                if determined.contains(*head) {
2873                    continue;
2874                }
2875                if Self::head_is_determined(program, head, &invariant, &derived_heads, &determined)
2876                {
2877                    determined.insert((*head).to_string());
2878                    changed = true;
2879                }
2880            }
2881        }
2882
2883        Self { determined }
2884    }
2885
2886    /// Whether `head`'s every defining rule ranges only over invariant or
2887    /// already-determined predicates (acyclic — no reference to `head` itself).
2888    fn head_is_determined(
2889        program: &Program,
2890        head: &str,
2891        invariant: &InvariantRelations,
2892        derived_heads: &BTreeSet<&str>,
2893        determined: &BTreeSet<String>,
2894    ) -> bool {
2895        let mut defined = false;
2896        for rule in &program.rules {
2897            if rule.head.predicate != head || rule.body.is_empty() {
2898                continue;
2899            }
2900            defined = true;
2901            for lit in &rule.body {
2902                let referenced = match lit {
2903                    BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
2904                        atom.predicate.as_str()
2905                    }
2906                    BodyLiteral::Epistemic(modal) => modal.atom.predicate.as_str(),
2907                    BodyLiteral::Comparison(_) | BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_) => {
2908                        continue
2909                    }
2910                };
2911                if referenced == head {
2912                    // Self-reference: not acyclically determined (recursion /
2913                    // circular modality). Hand back to the recursive/FAEEL paths.
2914                    return false;
2915                }
2916                let ok = invariant.is_invariant(referenced)
2917                    || determined.contains(referenced)
2918                    // A pure-EDB predicate not seen by `derived_heads` is invariant.
2919                    || !derived_heads.contains(referenced);
2920                if !ok {
2921                    return false;
2922                }
2923            }
2924        }
2925        defined
2926    }
2927
2928    fn contains(&self, predicate: &str) -> bool {
2929        self.determined.contains(predicate)
2930    }
2931}
2932
2933/// Plan a STRATIFIED epistemic execution when the program contains a modal literal
2934/// over an epistemic-derived head that is itself epistemically DETERMINED.
2935///
2936/// This intercepts exactly the chained/nested-epistemic coupling that the joint
2937/// single-enumeration path fails closed on (`b :- know a` where `a :- know p`, `p`
2938/// invariant). It partitions the program's epistemic heads into strata by modal
2939/// dependency, where a head whose modal ranges over a lower DETERMINED head sits in
2940/// a strictly-higher stratum. Each stratum is a self-contained sub-program compiled
2941/// through the EXISTING single/joint epistemic path; at runtime the executor
2942/// materializes each stratum's GATED head into the store before the next stratum
2943/// runs, so the higher stratum gates against the materialized (now-base) relation
2944/// via the existing membership filter — never via resolve-into-body.
2945///
2946/// Returns:
2947/// - `Ok(Some(plan))` when the program genuinely needs (and admits) stratification:
2948///   at least one modal literal ranges over an epistemically-determined derived
2949///   head, and a sound stratification exists.
2950/// - `Ok(None)` when no modal ranges over a determined derived head (the existing
2951///   joint/split/single paths own the program — e.g. example 18's shared base
2952///   modal, where the modal target `q` is EDB, not a determined derived head), OR
2953///   when the nested target is NOT determined (circular modality / recursion /
2954///   unfounded self-support is handed back to the recursive + FAEEL/G91 guards,
2955///   which keep ownership and fail closed there).
2956pub fn try_plan_stratified_epistemic_program(
2957    program: &Program,
2958) -> Result<Option<EpistemicStratifiedPlan>> {
2959    let determined = EpistemicallyDeterminedPredicates::analyze(program);
2960
2961    // A stratification is needed only when some modal literal ranges over a
2962    // DETERMINED epistemic-derived head. (A modal over a base/EDB predicate is the
2963    // ordinary single/joint path — example 18 — and must NOT be intercepted.)
2964    let mut needs_stratification = false;
2965    for rule in &program.rules {
2966        for lit in &rule.body {
2967            if let BodyLiteral::Epistemic(modal) = lit {
2968                if determined.contains(modal.atom.predicate.as_str())
2969                    && modal.atom.predicate != rule.head.predicate
2970                {
2971                    needs_stratification = true;
2972                }
2973            }
2974        }
2975    }
2976    if !needs_stratification {
2977        return Ok(None);
2978    }
2979
2980    // Assign each epistemic-derived head a stratum level = longest modal-dependency
2981    // chain to a determined head it gates over. Heads not determined cannot be
2982    // stratified soundly here; if any modal ranges over a non-determined derived
2983    // epistemic head, hand back to the joint path's fail-closed diagnostic.
2984    let stratum_level = assign_epistemic_strata(program, &determined)?;
2985    let Some(stratum_level) = stratum_level else {
2986        return Ok(None);
2987    };
2988
2989    // Group epistemic-bearing rules by their head's stratum level.
2990    let mut levels: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
2991    for (idx, rule) in program.rules.iter().enumerate() {
2992        let has_epistemic = rule
2993            .body
2994            .iter()
2995            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
2996        if !has_epistemic {
2997            continue;
2998        }
2999        let Some(level) = stratum_level.get(rule.head.predicate.as_str()) else {
3000            // An epistemic head with no assigned level means the analysis could not
3001            // place it soundly; hand back.
3002            return Ok(None);
3003        };
3004        levels.entry(*level).or_default().push(idx);
3005    }
3006
3007    if levels.len() < 2 {
3008        // Only one stratum: there is no lower stratum to materialize, so this is
3009        // not a genuine stratification (the existing paths own it).
3010        return Ok(None);
3011    }
3012
3013    let mut strata = Vec::with_capacity(levels.len());
3014    for (_level, rule_indices) in levels {
3015        let head_predicates: Vec<String> = rule_indices
3016            .iter()
3017            .filter_map(|idx| program.rules.get(*idx))
3018            .map(|rule| rule.head.predicate.clone())
3019            .collect::<BTreeSet<_>>()
3020            .into_iter()
3021            .collect();
3022        let stratum_program =
3023            build_stratum_subprogram(program, &rule_indices, &head_predicates, &stratum_level)?;
3024        strata.push(EpistemicStratum {
3025            head_predicates,
3026            rule_indices,
3027            program: stratum_program,
3028        });
3029    }
3030
3031    Ok(Some(EpistemicStratifiedPlan { strata }))
3032}
3033
3034/// Assign each epistemic-derived head an integer stratum level.
3035///
3036/// Level 0 heads gate only over invariant/EDB relations. A head whose modal ranges
3037/// over a determined head at level `k` is at level `>= k + 1`. Returns `Ok(None)`
3038/// if any modal ranges over a derived-epistemic head that is NOT determined (those
3039/// genuinely-undefined / fail-closed shapes are owned by the joint/recursive
3040/// guards, which already produce typed diagnostics).
3041fn assign_epistemic_strata(
3042    program: &Program,
3043    determined: &EpistemicallyDeterminedPredicates,
3044) -> Result<Option<BTreeMap<String, usize>>> {
3045    // Epistemic-derived heads.
3046    let mut epistemic_heads: BTreeSet<&str> = BTreeSet::new();
3047    for rule in &program.rules {
3048        if rule
3049            .body
3050            .iter()
3051            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
3052        {
3053            epistemic_heads.insert(rule.head.predicate.as_str());
3054        }
3055    }
3056
3057    // Modal-over-derived-epistemic-head edges: head -> set of derived-epistemic
3058    // predicates its modals range over.
3059    //
3060    // A modal can target either a determined EPISTEMIC head directly (`b :- know a`),
3061    // or an ORDINARY predicate transitively derived from determined epistemic heads
3062    // (`b :- know r` with `r :- a`, `a` epistemic-determined). For the ordinary case,
3063    // the modal's head must sit strictly ABOVE the epistemic head(s) in the ordinary
3064    // target's transitive determined support, so those epistemic heads are materialized
3065    // (gated) into the store first and the ordinary `r :- a` is then computed over the
3066    // materialized base (making `r` locally invariant). We therefore route an edge from
3067    // the modal's head to EACH epistemic determined head in the target's support.
3068    let mut modal_edges: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
3069    for rule in &program.rules {
3070        let head = rule.head.predicate.as_str();
3071        for lit in &rule.body {
3072            if let BodyLiteral::Epistemic(modal) = lit {
3073                let target = modal.atom.predicate.as_str();
3074                if epistemic_heads.contains(target) {
3075                    if !determined.contains(target) {
3076                        // Modal over a non-determined epistemic head: not soundly
3077                        // stratifiable here. Hand back to the joint/recursive guard.
3078                        return Ok(None);
3079                    }
3080                    modal_edges.entry(head).or_default().insert(target);
3081                } else if determined.contains(target) {
3082                    // Modal over an ORDINARY determined predicate: route edges to the
3083                    // epistemic determined heads in its transitive support so the
3084                    // modal's head sits above them.
3085                    let support =
3086                        epistemic_support_of_determined_ordinary(program, target, &epistemic_heads);
3087                    if support.is_empty() {
3088                        // No epistemic head in the support means the target is fully
3089                        // invariant (pure-ordinary over EDB) — that is the ordinary
3090                        // single/joint path, not a stratification. Hand back.
3091                        return Ok(None);
3092                    }
3093                    let entry = modal_edges.entry(head).or_default();
3094                    for support_head in support {
3095                        entry.insert(support_head);
3096                    }
3097                }
3098            }
3099        }
3100    }
3101
3102    // Longest-path level via memoized DFS over modal_edges (acyclicity guaranteed
3103    // by `EpistemicallyDeterminedPredicates`, which rejects self-reference).
3104    let mut level: BTreeMap<String, usize> = BTreeMap::new();
3105    fn visit<'a>(
3106        head: &'a str,
3107        modal_edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
3108        level: &mut BTreeMap<String, usize>,
3109        active: &mut BTreeSet<&'a str>,
3110    ) -> Result<usize> {
3111        if let Some(l) = level.get(head) {
3112            return Ok(*l);
3113        }
3114        if !active.insert(head) {
3115            // A cycle through modal edges should have been excluded upstream; be
3116            // defensive and refuse to stratify.
3117            return Err(recursive_epistemic_rejection(
3118                "stratified epistemic planning encountered a modal dependency cycle",
3119            ));
3120        }
3121        let mut l = 0;
3122        if let Some(targets) = modal_edges.get(head) {
3123            for target in targets {
3124                let tl = visit(target, modal_edges, level, active)?;
3125                l = l.max(tl + 1);
3126            }
3127        }
3128        active.remove(head);
3129        level.insert(head.to_string(), l);
3130        Ok(l)
3131    }
3132
3133    for head in &epistemic_heads {
3134        visit(head, &modal_edges, &mut level, &mut BTreeSet::new())?;
3135    }
3136
3137    Ok(Some(level))
3138}
3139
3140/// The epistemic determined heads in the transitive ordinary support of a determined
3141/// ORDINARY predicate.
3142///
3143/// For `r :- a` with `a` an epistemic-determined head, `support_of("r") = {"a"}`. The
3144/// search follows positive/negated ordinary body atoms (the ordinary derivation), and
3145/// collects any referenced predicate that is itself an epistemic head. Bounded by the
3146/// (acyclic) determined-closure, so a simple visited-set DFS terminates.
3147fn epistemic_support_of_determined_ordinary<'a>(
3148    program: &'a Program,
3149    predicate: &'a str,
3150    epistemic_heads: &BTreeSet<&'a str>,
3151) -> BTreeSet<&'a str> {
3152    let mut support: BTreeSet<&'a str> = BTreeSet::new();
3153    let mut seen: BTreeSet<&'a str> = BTreeSet::new();
3154    let mut stack: Vec<&'a str> = vec![predicate];
3155    while let Some(current) = stack.pop() {
3156        if !seen.insert(current) {
3157            continue;
3158        }
3159        for rule in &program.rules {
3160            if rule.head.predicate != current || rule.body.is_empty() {
3161                continue;
3162            }
3163            for lit in &rule.body {
3164                let referenced = match lit {
3165                    BodyLiteral::Positive(atom) | BodyLiteral::Negated(atom) => {
3166                        atom.predicate.as_str()
3167                    }
3168                    // An epistemic literal in the support means `current` is itself an
3169                    // epistemic head; record it and do not descend through the modal.
3170                    BodyLiteral::Epistemic(_)
3171                    | BodyLiteral::Comparison(_)
3172                    | BodyLiteral::IsExpr(_)
3173                    | BodyLiteral::Univ(_) => continue,
3174                };
3175                if epistemic_heads.contains(referenced) {
3176                    support.insert(referenced);
3177                } else {
3178                    // Descend through ordinary derivations toward their epistemic roots.
3179                    stack.push(referenced);
3180                }
3181            }
3182        }
3183        // If `current` itself is an epistemic head, it is its own support root.
3184        if epistemic_heads.contains(current) && current != predicate {
3185            support.insert(current);
3186        }
3187    }
3188    support
3189}
3190
3191/// Build a self-contained sub-program for one stratum.
3192///
3193/// Includes this stratum's epistemic-defining rules plus every fact and every
3194/// ordinary (non-epistemic) supporting rule whose head is NOT a lower-stratum
3195/// epistemic head. Lower-stratum epistemic heads are intentionally OMITTED: at
3196/// execution they are present in the store as materialized base relations, and
3197/// including their (modal-stripped, ungated) defining rules would overwrite the
3198/// gated extension. Their `pred` declarations are retained so the reduced compiler
3199/// sees a schema for the materialized base relation.
3200fn build_stratum_subprogram(
3201    program: &Program,
3202    rule_indices: &[usize],
3203    head_predicates: &[String],
3204    stratum_level: &BTreeMap<String, usize>,
3205) -> Result<Program> {
3206    let this_level = head_predicates
3207        .iter()
3208        .filter_map(|h| stratum_level.get(h))
3209        .copied()
3210        .max()
3211        .unwrap_or(0);
3212
3213    // Lower-stratum epistemic heads: present as materialized base relations at
3214    // runtime; their defining rules must NOT appear in this sub-program.
3215    let lower_epistemic_heads: BTreeSet<&str> = stratum_level
3216        .iter()
3217        .filter(|(_, level)| **level < this_level)
3218        .map(|(head, _)| head.as_str())
3219        .collect();
3220
3221    // All epistemic-derived heads (used to compute an ordinary rule's epistemic
3222    // support for deferral of determined-ordinary supporting rules).
3223    let all_epistemic_heads: BTreeSet<&str> = program
3224        .rules
3225        .iter()
3226        .filter(|rule| {
3227            rule.body
3228                .iter()
3229                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
3230        })
3231        .map(|rule| rule.head.predicate.as_str())
3232        .collect();
3233
3234    let own_rule_indices: BTreeSet<usize> = rule_indices.iter().copied().collect();
3235
3236    let mut stratum = program.clone();
3237    stratum.rules = program
3238        .rules
3239        .iter()
3240        .enumerate()
3241        .filter_map(|(idx, rule)| {
3242            if own_rule_indices.contains(&idx) {
3243                return Some(rule.clone());
3244            }
3245            // Drop any rule that (re)defines a lower-stratum epistemic head.
3246            if lower_epistemic_heads.contains(rule.head.predicate.as_str()) {
3247                return None;
3248            }
3249            // Keep facts and ordinary supporting rules (EDB + non-epistemic
3250            // derivations the stratum's bodies may reference).
3251            let has_epistemic = rule
3252                .body
3253                .iter()
3254                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
3255            if has_epistemic && !own_rule_indices.contains(&idx) {
3256                // Another stratum's epistemic rule: exclude.
3257                return None;
3258            }
3259            // An ORDINARY supporting rule whose transitive epistemic support includes a
3260            // head NOT yet materialized (gated) at this level must NOT run here — it
3261            // would compute over the UNGATED candidate extension of that head and leak
3262            // the wrong tuples into the store (which the higher stratum then gates
3263            // against). Defer it to the lowest stratum where ALL its epistemic support
3264            // is already a materialized gated base relation. E.g. `r :- a` (a an
3265            // epistemic-determined head) is dropped from `a`'s own stratum (level 0) and
3266            // kept only in the strictly-higher stratum where `a` is materialized base,
3267            // so `r` is computed once from the gated `a`. Pure-ordinary rules over EDB
3268            // (empty epistemic support) are never deferred.
3269            let support = epistemic_support_of_determined_ordinary(
3270                program,
3271                rule.head.predicate.as_str(),
3272                &all_epistemic_heads,
3273            );
3274            if support
3275                .iter()
3276                .any(|h| stratum_level.get(*h).copied().unwrap_or(0) >= this_level)
3277            {
3278                return None;
3279            }
3280            Some(rule.clone())
3281        })
3282        .collect();
3283
3284    // Keep only the queries whose predicate this stratum materializes, so each
3285    // stratum's executable surfaces its own head(s).
3286    let head_set: BTreeSet<&str> = head_predicates.iter().map(String::as_str).collect();
3287    stratum.queries = program
3288        .queries
3289        .iter()
3290        .filter(|query| head_set.contains(query.atom.predicate.as_str()))
3291        .cloned()
3292        .collect();
3293
3294    // Drop constraints that reference predicates this stratum does not own, to keep
3295    // the sub-program self-contained.
3296    stratum.constraints = program
3297        .constraints
3298        .iter()
3299        .filter(|constraint| {
3300            constraint_predicate_set(constraint)
3301                .iter()
3302                .all(|p| head_set.contains(p.as_str()) || !is_program_head(program, p))
3303        })
3304        .cloned()
3305        .collect();
3306
3307    Ok(stratum)
3308}
3309
3310fn is_program_head(program: &Program, predicate: &str) -> bool {
3311    program
3312        .rules
3313        .iter()
3314        .any(|rule| !rule.body.is_empty() && rule.head.predicate == predicate)
3315}
3316
3317/// Partition an epistemic program into independently-evaluable components.
3318///
3319/// Builds the epistemic dependency graph (coalescing rules that couple distinct
3320/// epistemic body predicates into one component) and returns an
3321/// [`EpistemicSplitPlan`] describing which output heads evaluate together versus
3322/// in isolation. This is the entry point for the safe-split / joint-solving and
3323/// stratified-execution routing decisions in the GPU driver.
3324pub fn split_epistemic_program(program: &Program) -> Result<EpistemicSplitPlan> {
3325    // rules that couple more than one distinct epistemic body predicate
3326    // are NOT rejected here. The dependency graph already unions every such rule
3327    // into a single component (each epistemic predicate occurrence routes through
3328    // `modal_owner` in `build_epistemic_dependency_graph`), and that component is
3329    // recompiled through the unsplit joint path
3330    // (`compile_epistemic_gpu_execution`), which enumerates the full candidate
3331    // lattice and validates the FULL modal conjunction jointly on device. Any
3332    // genuinely out-of-fragment coupling (unsafe variables, unsupported
3333    // tuple-key/nested-modal semantics) stays fail-closed via the downstream
3334    // joint-path guards (`build_eir` safety analysis,
3335    // `validate_tuple_membership_bindings`, `validate_solver_contract`) with their
3336    // own typed source-contextualized diagnostics, so no blanket coupling
3337    // rejection is needed at the split boundary.
3338    Ok(EpistemicSplitPlan {
3339        components: build_epistemic_dependency_graph(program)?.components,
3340    })
3341}
3342
3343/// Compile valid epistemic split components through the production GPU executable path.
3344pub fn compile_epistemic_gpu_split_execution(
3345    program: &Program,
3346) -> Result<EpistemicSplitExecutablePlan> {
3347    compile_epistemic_gpu_split_execution_with_stats_snapshot(program, None)
3348}
3349
3350/// Compile valid epistemic split components with an optional production stats snapshot.
3351///
3352/// Each component subprogram is lowered through
3353/// [`compile_epistemic_gpu_execution_with_stats_snapshot`], so split execution
3354/// reuses the same GPU contract, reduced compiler pipeline, WCOJ promotion, and
3355/// helper-splitting surfaces as unsplit epistemic execution.
3356pub fn compile_epistemic_gpu_split_execution_with_stats_snapshot(
3357    program: &Program,
3358    stats_snapshot: Option<&StatsSnapshot>,
3359) -> Result<EpistemicSplitExecutablePlan> {
3360    reject_epistemic_constraints(program)?;
3361    let split_plan = split_epistemic_program(program)?;
3362    let mut components = Vec::new();
3363
3364    for component in &split_plan.components {
3365        if !component_has_epistemic_rule(program, component) {
3366            continue;
3367        }
3368
3369        // Cross-component coupling carrying >1 epistemic output head is either
3370        // JOINT-SOLVED (a coalesced component whose modal literals all range over
3371        // base/invariant predicates -- a shared accepted world view materializes
3372        // every head) or fails closed with a precise typed diagnostic (a modal
3373        // literal ranges over an epistemic-derived head of the same component, so
3374        // the heads' world-view acceptance is genuinely interdependent and the
3375        // independent split would be unsound). A single epistemic head is always
3376        // the existing single-output joint path.
3377        let coupling = classify_cross_component_modal_coupling(program, component)?;
3378
3379        let component_program = split_component_program(program, component)?;
3380        let executable = compile_epistemic_gpu_execution_inner(
3381            &component_program,
3382            stats_snapshot,
3383            coupling.allows_multiple_output_heads(),
3384        )?;
3385        components.push(EpistemicSplitExecutableComponent {
3386            component: component.clone(),
3387            executable,
3388        });
3389    }
3390
3391    if components.is_empty() {
3392        return Err(XlogError::UnsupportedEpistemicConstruct {
3393            construct: "epistemic GPU split execution".to_string(),
3394            context: "requires at least one epistemic split component".to_string(),
3395        });
3396    }
3397
3398    Ok(EpistemicSplitExecutablePlan {
3399        split_plan,
3400        components,
3401    })
3402}
3403
3404fn component_has_epistemic_rule(
3405    program: &Program,
3406    component: &EpistemicDependencyComponent,
3407) -> bool {
3408    component
3409        .rule_indices
3410        .iter()
3411        .filter_map(|idx| program.rules.get(*idx))
3412        .any(|rule| {
3413            rule.body
3414                .iter()
3415                .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)))
3416        })
3417}
3418
3419/// Distinct head predicates of the component's epistemic-bearing rules, sorted.
3420///
3421/// Each such head is a final epistemic output relation the joint single-pass GPU
3422/// path would have to materialize. The single-output-buffer contract
3423/// ([`require_single_epistemic_output_relation`]) admits exactly one, so a count
3424/// above one means the component is genuinely *coupled* across what local
3425/// analysis would otherwise split — its epistemic outputs cannot be solved
3426/// independently AND cannot be jointly materialized into one buffer.
3427fn component_epistemic_output_heads(
3428    program: &Program,
3429    component: &EpistemicDependencyComponent,
3430) -> Vec<String> {
3431    let mut heads: BTreeSet<String> = BTreeSet::new();
3432    for idx in &component.rule_indices {
3433        let Some(rule) = program.rules.get(*idx) else {
3434            continue;
3435        };
3436        let has_epistemic_body = rule
3437            .body
3438            .iter()
3439            .any(|lit| matches!(lit, BodyLiteral::Epistemic(_)));
3440        if has_epistemic_body {
3441            heads.insert(rule.head.predicate.clone());
3442        }
3443    }
3444    heads.into_iter().collect()
3445}
3446
3447/// Render a coalesced component's merge reasons into a stable, human-readable list
3448/// for the cross-component coupling diagnostic.
3449///
3450/// These reasons (`DerivedPredicate`, `SharedModalPredicate`, `SharedHeadPredicate`,
3451/// `Constraint`) are exactly *why* the dependency graph could not split the
3452/// component's epistemic outputs, so naming them tells the caller which structural
3453/// coupling forced the fail-closed.
3454fn format_component_merge_reasons(component: &EpistemicDependencyComponent) -> String {
3455    if component.merge_reasons.is_empty() {
3456        return "no recorded coalesce reason".to_string();
3457    }
3458    component
3459        .merge_reasons
3460        .iter()
3461        .map(|reason| match reason {
3462            EpistemicComponentMergeReason::SharedHeadPredicate { predicate } => {
3463                format!("SharedHeadPredicate({predicate})")
3464            }
3465            EpistemicComponentMergeReason::DerivedPredicate { predicate } => {
3466                format!("DerivedPredicate({predicate})")
3467            }
3468            EpistemicComponentMergeReason::SharedModalPredicate { predicate } => {
3469                format!("SharedModalPredicate({predicate})")
3470            }
3471            EpistemicComponentMergeReason::Constraint { predicates } => {
3472                format!("Constraint({})", predicates.join(", "))
3473            }
3474        })
3475        .collect::<Vec<_>>()
3476        .join(", ")
3477}
3478
3479/// Classification of a coalesced epistemic component's cross-component coupling.
3480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3481enum CrossComponentCoupling {
3482    /// At most one epistemic output head, or a multi-head component whose modal
3483    /// literals all range over base/invariant predicates. The shared accepted
3484    /// world view materializes every head, so the component is JOINT-SOLVED.
3485    JointSolvable,
3486}
3487
3488impl CrossComponentCoupling {
3489    /// True when the component's GPU plan is permitted to carry more than one
3490    /// epistemic output head (joint multi-head materialization).
3491    fn allows_multiple_output_heads(self) -> bool {
3492        match self {
3493            CrossComponentCoupling::JointSolvable => true,
3494        }
3495    }
3496}
3497
3498/// Classify a coalesced component's cross-component modal coupling, JOINT-SOLVING
3499/// the canonical shared-base-modal case and failing closed (with a precise typed
3500/// diagnostic) on genuinely interdependent nested-epistemic coupling.
3501///
3502/// A coalesced component carrying more than one epistemic output head is either:
3503///
3504/// - **Joint-solvable** — every modal literal in the component ranges over a
3505///   predicate that is NOT an epistemic-derived head of the component (a
3506///   base/invariant relation or an ordinary-derived relation). The accepted
3507///   world-view set is then determined independently of which head is being
3508///   materialized, so one joint candidate enumeration + world-view validation
3509///   over the combined modal literals yields a single accepted world view, and
3510///   each head materialized against THAT world view equals its per-head
3511///   reduced-program evaluation. This is the canonical `SharedModalPredicate`
3512///   joint-solving target (`a(X):-know q(X). b(X):-possible q(X).` over base `q`).
3513///
3514/// - **Genuinely interdependent (fail closed)** — some modal literal ranges over
3515///   an EPISTEMIC-DERIVED head of the same component (`flagged():-know trusted()`
3516///   where `trusted` is itself `know`-derived). The modal truth of that predicate
3517///   depends on a DIFFERENT head's accepted world view, so the heads' acceptance
3518///   is mutually entangled (nested/stratified epistemic dependency). Solving it
3519///   would require stratified world-view nesting that the single joint enumeration
3520///   does not provide, so it stays FAIL-CLOSED with a typed diagnostic naming the
3521///   coupled heads, the modal predicate, and the merge reason -- never silently
3522///   mis-evaluated.
3523///
3524/// SAFE single-epistemic-head coupling (an ordinary body consuming an epistemic
3525/// head, `b():-a()` over `a():-know p()`) and EDB-only sharing are both
3526/// `JointSolvable` (one or zero coupled heads), so they stay accepted.
3527/// Compute the predicates whose extension depends, directly or transitively
3528/// through ordinary rules in the component, on an epistemic-derived head.
3529///
3530/// Seeded with the component's epistemic output heads (each is "tainted" because
3531/// its extension is gated by a modal literal), then closed under the rule
3532/// dependency relation: a head becomes tainted when ANY rule defining it (within
3533/// the component) references an already-tainted predicate in its body. A modal
3534/// literal over a tainted predicate is a nested/stratified epistemic dependency.
3535fn epistemic_tainted_predicates<'a>(
3536    program: &'a Program,
3537    component: &EpistemicDependencyComponent,
3538    epistemic_heads: &'a [String],
3539) -> BTreeSet<&'a str> {
3540    let mut tainted: BTreeSet<&str> = epistemic_heads.iter().map(String::as_str).collect();
3541    // Iterate the component's rules to a least fixpoint: a rule's head is tainted
3542    // if any body atom references a tainted predicate.
3543    let mut changed = true;
3544    while changed {
3545        changed = false;
3546        for idx in &component.rule_indices {
3547            let Some(rule) = program.rules.get(*idx) else {
3548                continue;
3549            };
3550            if tainted.contains(rule.head.predicate.as_str()) {
3551                continue;
3552            }
3553            // `BodyLiteral::atom()` covers relational AND epistemic literals
3554            // (the modal predicate), so this taints a head whether it depends on a
3555            // tainted predicate ordinarily or through a modal literal.
3556            let body_touches_tainted = rule.body.iter().any(|lit| {
3557                lit.atom()
3558                    .map(|atom| tainted.contains(atom.predicate.as_str()))
3559                    .unwrap_or(false)
3560            });
3561            if body_touches_tainted {
3562                tainted.insert(rule.head.predicate.as_str());
3563                changed = true;
3564            }
3565        }
3566    }
3567    tainted
3568}
3569
3570fn classify_cross_component_modal_coupling(
3571    program: &Program,
3572    component: &EpistemicDependencyComponent,
3573) -> Result<CrossComponentCoupling> {
3574    let epistemic_heads = component_epistemic_output_heads(program, component);
3575    if epistemic_heads.len() <= 1 {
3576        return Ok(CrossComponentCoupling::JointSolvable);
3577    }
3578
3579    // A modal literal ranging over a predicate whose extension DEPENDS (directly
3580    // OR TRANSITIVELY, through ordinary rules in this component) on an
3581    // epistemic-derived head is a nested/stratified epistemic dependency that the
3582    // single joint enumeration cannot solve soundly: that modal's truth would have
3583    // to be re-evaluated under EACH candidate world view chosen for the head it
3584    // depends on, which one shared world-view enumeration does not provide.
3585    //
3586    // "Epistemic-tainted" predicates = epistemic-derived heads, closed under the
3587    // ordinary rule dependency relation within the component (least fixpoint). A
3588    // modal over any tainted predicate fails closed. A modal over a purely
3589    // base/invariant or epistemic-INDEPENDENT predicate is joint-solvable.
3590    let tainted = epistemic_tainted_predicates(program, component, &epistemic_heads);
3591
3592    let mut nested_modal_predicates: BTreeSet<String> = BTreeSet::new();
3593    for idx in &component.rule_indices {
3594        let Some(rule) = program.rules.get(*idx) else {
3595            continue;
3596        };
3597        for lit in &rule.body {
3598            if let BodyLiteral::Epistemic(modal) = lit {
3599                if tainted.contains(modal.atom.predicate.as_str()) {
3600                    nested_modal_predicates.insert(format!(
3601                        "{}/{}",
3602                        modal.atom.predicate,
3603                        modal.atom.arity()
3604                    ));
3605                }
3606            }
3607        }
3608    }
3609
3610    if nested_modal_predicates.is_empty() {
3611        // Every modal literal ranges over a predicate that is independent of every
3612        // epistemic-derived head, so the accepted world view is determined solely
3613        // by base/invariant relations and the component is joint-solvable over one
3614        // shared accepted world view.
3615        return Ok(CrossComponentCoupling::JointSolvable);
3616    }
3617
3618    Err(XlogError::UnsupportedEpistemicConstruct {
3619        construct: "cross-component epistemic coupling".to_string(),
3620        context: format!(
3621            "epistemic output heads {:?} are coupled into a single dependency \
3622             component (reasons: {}) through nested modal literals over \
3623             epistemic-derived predicates {:?}; the modal truth of an \
3624             epistemic-derived head depends on another head's accepted world view, \
3625             so a single joint world-view enumeration would mis-evaluate the \
3626             nested modality and an independent split would be unsound, so this \
3627             fails closed",
3628            epistemic_heads,
3629            format_component_merge_reasons(component),
3630            nested_modal_predicates.into_iter().collect::<Vec<_>>(),
3631        ),
3632    })
3633}
3634
3635fn split_component_program(
3636    program: &Program,
3637    component: &EpistemicDependencyComponent,
3638) -> Result<Program> {
3639    let mut component_program = program.clone();
3640    let component_predicates: BTreeSet<&str> =
3641        component.predicates.iter().map(String::as_str).collect();
3642    let component_rule_indices: BTreeSet<usize> = component.rule_indices.iter().copied().collect();
3643    let head_predicates: BTreeSet<&str> = program
3644        .rules
3645        .iter()
3646        .map(|rule| rule.head.predicate.as_str())
3647        .collect();
3648    component_program.rules = program
3649        .rules
3650        .iter()
3651        .enumerate()
3652        .filter_map(|(idx, rule)| {
3653            (component_rule_indices.contains(&idx)
3654                || (rule.body.is_empty()
3655                    && component_predicates.contains(rule.head.predicate.as_str())))
3656            .then_some(rule.clone())
3657        })
3658        .collect();
3659    component_program.constraints = program
3660        .constraints
3661        .iter()
3662        .filter(|constraint| {
3663            let predicates = constraint_predicate_set(constraint);
3664            let has_component_owned_predicate = predicates
3665                .iter()
3666                .any(|predicate| head_predicates.contains(predicate.as_str()));
3667            !has_component_owned_predicate
3668                || predicates
3669                    .iter()
3670                    .all(|predicate| component_predicates.contains(predicate.as_str()))
3671        })
3672        .cloned()
3673        .collect();
3674    Ok(component_program)
3675}
3676
3677#[cfg(test)]
3678mod tests {
3679    use super::*;
3680    use crate::parse_program;
3681
3682    #[test]
3683    fn high_arity_epistemic_adapter_reduction_is_not_wcoj_required() {
3684        let program = parse_program(
3685            r#"
3686            pred case_variant(u32, u32).
3687            pred case_domain_variant(u32, u32, u32).
3688            pred domain_adapter_root(u32, u32, u32).
3689            pred domain_adapter_intervention(u32, u32, u32).
3690            pred domain_candidate_seed(u32, u32, u32, u32).
3691            pred heldout_label_seed(u32, u32).
3692            pred blocked_candidate(u32, u32, u32).
3693            pred generated_candidate(u32, u32, u32, u32, u32).
3694
3695            generated_candidate(Case, Variant, Candidate, Root, Intervention) :-
3696                case_domain_variant(Case, Variant, Domain),
3697                domain_adapter_root(Domain, Candidate, Root),
3698                domain_adapter_intervention(Domain, Candidate, Intervention),
3699                domain_candidate_seed(Domain, Candidate, Root, Intervention),
3700                know domain_candidate_seed(Domain, Candidate, Root, Intervention),
3701                possible case_variant(Case, Variant),
3702                not know heldout_label_seed(Case, Candidate),
3703                not possible blocked_candidate(Case, Variant, Candidate).
3704            "#,
3705        )
3706        .expect("parse high-arity adapter epistemic program");
3707
3708        let plan = plan_epistemic_gpu_execution(&program)
3709            .expect("plan high-arity adapter epistemic program");
3710
3711        assert_eq!(plan.reductions.len(), 1);
3712        assert_eq!(
3713            plan.reductions[0].wcoj_status,
3714            EpistemicWcojReductionStatus::NotWcojCandidate
3715        );
3716    }
3717
3718    #[test]
3719    fn binary_triangle_epistemic_reduction_still_requires_wcoj() {
3720        let program = parse_program(
3721            r#"
3722            pred xy(u32, u32).
3723            pred yz(u32, u32).
3724            pred xz(u32, u32).
3725            pred tri(u32, u32, u32).
3726
3727            tri(X, Y, Z) :-
3728                xy(X, Y),
3729                yz(Y, Z),
3730                xz(X, Z),
3731                know xy(X, Y).
3732            "#,
3733        )
3734        .expect("parse binary triangle epistemic program");
3735
3736        let plan =
3737            plan_epistemic_gpu_execution(&program).expect("plan binary triangle epistemic program");
3738
3739        assert_eq!(plan.reductions.len(), 1);
3740        assert_eq!(
3741            plan.reductions[0].wcoj_status,
3742            EpistemicWcojReductionStatus::RequiresPlannerEligibility
3743        );
3744    }
3745
3746    #[test]
3747    fn binary_eight_clique_epistemic_reduction_requires_wcoj() {
3748        let program = parse_program(
3749            r#"
3750            pred edge(u32, u32).
3751            pred clique8(u32, u32, u32, u32, u32, u32, u32, u32).
3752
3753            clique8(A, B, C, D, E, F, G, H) :-
3754                edge(A, B),
3755                edge(A, C),
3756                edge(A, D),
3757                edge(A, E),
3758                edge(A, F),
3759                edge(A, G),
3760                edge(A, H),
3761                edge(B, C),
3762                edge(B, D),
3763                edge(B, E),
3764                edge(B, F),
3765                edge(B, G),
3766                edge(B, H),
3767                edge(C, D),
3768                edge(C, E),
3769                edge(C, F),
3770                edge(C, G),
3771                edge(C, H),
3772                edge(D, E),
3773                edge(D, F),
3774                edge(D, G),
3775                edge(D, H),
3776                edge(E, F),
3777                edge(E, G),
3778                edge(E, H),
3779                edge(F, G),
3780                edge(F, H),
3781                edge(G, H),
3782                know edge(A, B).
3783            "#,
3784        )
3785        .expect("parse binary eight-clique epistemic program");
3786
3787        let plan = plan_epistemic_gpu_execution(&program)
3788            .expect("plan binary eight-clique epistemic program");
3789
3790        assert_eq!(plan.reductions.len(), 1);
3791        assert_eq!(
3792            plan.reductions[0].wcoj_status,
3793            EpistemicWcojReductionStatus::RequiresPlannerEligibility
3794        );
3795    }
3796}