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