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