nibli_reason/materialize.rs
1//! Stratum-ordered materialisation: saturate the extension of a relation bottom-up
2//! so negation-as-failure becomes a LOOKUP instead of an exhaustive proof attempt.
3//!
4//! # Why this module exists
5//!
6//! `~p(x)` is answered by trying to prove `p(x)` and failing ([`crate::reasoning`]'s
7//! `NotNode` arm, the flat negated-condition inversion, and `eval_negated_exists_group`
8//! all bottom out in `check_predicate_in_kb_typed`). When `p` is concluded by a wide
9//! multi-variable rule, each negated occurrence pays for a domain cartesian.
10//!
11//! Stratification already tells us that cannot be necessary: `check_stratification`
12//! ([`crate::rules`]) proves a valid stratum ordering EXISTS — negated relations can be
13//! completed before the relations that read them. The engine used that ordering only to
14//! REJECT unstratifiable programs and then threw the assignment away. This module keeps
15//! it and evaluates with it.
16//!
17//! # Why the obvious version does not work
18//!
19//! The compiled program is not function-free Datalog. Neo-Davidsonian decomposition
20//! means `false($x)` compiles to `∃ev. false(ev) ∧ false_x1(ev, $x)`, and an `∃` in a
21//! rule consequent under a `∀` becomes a DEPENDENT SKOLEM FUNCTION in the head
22//! ([`crate::rules`]'s `dependent_skolems` / `skolem_fn_registry`). Essentially every
23//! `∀`-rule therefore has a function symbol in its head, so a naive bottom-up fixpoint
24//! is not guaranteed to terminate — and an eligibility rule of "no Skolem heads" would
25//! admit nothing at all.
26//!
27//! The escape is the same one `nibli-verify`'s ASP translator takes to keep clingo's
28//! grounding finite: REGROUP the decomposition back to function-free surface atoms.
29//! `∃ev. rel(ev) ∧ rel_x1(ev,a1) ∧ … ∧ rel_xN(ev,aN)` projects to `rel(a1,…,aN)`,
30//! which is sound here because an event variable has no cross-atom identity — it only
31//! ties the roles of ONE atom together. Eliminating it keeps the Herbrand base finite,
32//! so saturation terminates: no rule can invent a term.
33//!
34//! That projection is deliberately REIMPLEMENTED here rather than shared with
35//! `nibli-verify/src/asp.rs`. The ASP oracle checks this engine's NAF verdicts against
36//! clingo's perfect model; an oracle that shared its regrouping code with the engine it
37//! checks would stop being independent exactly where NAF soundness is decided. The two
38//! implementations can drift — and the clingo differential is what fires when they do.
39//!
40//! # Fail-closed
41//!
42//! Everything here is an OPTIMISATION with one unsound failure mode: if a saturation
43//! under-derives, `~p(x)` flips from FALSE to a wrong TRUE. So a relation is admitted
44//! only when every rule that can conclude it is provably projectable and every relation
45//! beneath it is admitted too ([`eligible_relations`]). Anything not admitted is simply
46//! absent from the completed set and keeps today's backward-chaining behaviour exactly.
47
48use std::collections::{HashMap, HashSet};
49
50use crate::kb::{
51 GroundTerm, KnowledgeBaseInner, NegatedExistsGroup, StoredFact, UniversalRuleRecord,
52};
53
54/// Stratum 0 is the EDB / pure-positive layer. A relation read under `~` by a stratum-`n`
55/// rule sits at stratum `< n`, so its extension is complete before that rule is evaluated.
56pub(super) type Strata = HashMap<String, usize>;
57
58/// Assign every relation in the dependency graph a stratum index.
59///
60/// The condensation of [`crate::rules::compute_sccs`] is a DAG (SCCs are maximal, so no
61/// cycle survives contraction). Label each component by the longest path into it,
62/// counting a negative edge as +1 and a positive edge as +0 — the textbook stratification.
63/// Members of one SCC share a stratum by construction, which is exactly right: a positive
64/// cycle is evaluated as one mutually-recursive block.
65///
66/// TOTALITY. This terminates and produces a finite label for every node precisely when
67/// `check_stratification` returned `Ok` — a negative edge inside an SCC would make the
68/// "+1 within a component" demand unsatisfiable, and that is the case the engine already
69/// rejects at rule-registration time. Since the graph on a live KB has always passed that
70/// check, this cannot fail; it is nonetheless written to be total on ANY graph (a negative
71/// intra-SCC edge is absorbed rather than looped on), because a panic here would turn a
72/// read-side optimisation into a crash.
73///
74/// Determinism: `compute_sccs` already sorts its node scan, each node's neighbour list,
75/// and each component's members, so the partition is canonical regardless of `HashMap`
76/// layout or rule-registration order. The relaxation below is order-independent anyway
77/// (it iterates to a fixpoint), so the labelling is reproducible run to run.
78pub(super) fn compute_strata(graph: &HashMap<String, Vec<(String, bool)>>) -> Strata {
79 let sccs = crate::rules::compute_sccs(graph);
80
81 // node → its component index.
82 let mut comp_of: HashMap<&str, usize> = HashMap::new();
83 for (i, scc) in sccs.iter().enumerate() {
84 for node in scc {
85 comp_of.insert(node.as_str(), i);
86 }
87 }
88
89 // Condensation edges, carrying the strongest (negative wins) label between components.
90 // Self-edges are dropped: an intra-SCC edge cannot raise the stratum of its own
91 // component, and a negative one is the unstratifiable case the registration gate
92 // already refused.
93 let mut cond_edges: Vec<Vec<(usize, bool)>> = vec![Vec::new(); sccs.len()];
94 for (head, deps) in graph {
95 let Some(&h) = comp_of.get(head.as_str()) else {
96 continue;
97 };
98 for (dep, is_neg) in deps {
99 let Some(&d) = comp_of.get(dep.as_str()) else {
100 continue;
101 };
102 if d != h {
103 // Edge head → dep means "head reads dep", so dep must be no LATER
104 // than head; store it as a constraint on `h` keyed by `d`.
105 cond_edges[h].push((d, *is_neg));
106 }
107 }
108 }
109
110 // Longest-path relaxation to a fixpoint. Bounded by |components| passes: each pass
111 // either raises at least one label or the labels are stable, and no label can exceed
112 // the number of components (a strictly longer chain would revisit a component, which
113 // the condensation makes impossible).
114 let mut level: Vec<usize> = vec![0; sccs.len()];
115 for _ in 0..=sccs.len() {
116 let mut changed = false;
117 for h in 0..sccs.len() {
118 for &(d, is_neg) in &cond_edges[h] {
119 let want = level[d] + usize::from(is_neg);
120 if want > level[h] {
121 level[h] = want;
122 changed = true;
123 }
124 }
125 }
126 if !changed {
127 break;
128 }
129 }
130
131 let mut out = Strata::new();
132 for (i, scc) in sccs.iter().enumerate() {
133 for node in scc {
134 out.insert(node.clone(), level[i]);
135 }
136 }
137 out
138}
139
140/// Why a relation was NOT admitted for materialisation. Surfaced by
141/// `KnowledgeBase::materialization_report` — without it a knowledge base cannot tell
142/// whether it actually got the lookup, only that its query is still slow.
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub enum Ineligible {
145 /// A rule concluding it has a Skolem function surviving the event projection —
146 /// the head invents a term, so saturation may not terminate.
147 SkolemHead(String),
148 /// A rule's conditions do not partition into per-atom role groups (an event
149 /// variable is shared across groups, or appears in an individual position), so the
150 /// `∃ev` projection would change what the rule means.
151 NotProjectable(String),
152 /// A head variable does not occur in a positive body literal, so the rule is not
153 /// range-restricted and its saturation is not finite.
154 NotRangeRestricted(String),
155 /// A condition dispatches to the compute backend / arithmetic. Its domain is not
156 /// enumerable, so its extension is not a finite set to saturate.
157 ComputeCondition(String),
158 /// A RULE TEMPLATE carries a tense or deontic flavour. Rule firing is
159 /// flavour-polymorphic (`apply_tense_to_fact`); v1 does not reproduce that.
160 Flavoured(String),
161 /// A STORED FACT of this relation carries a flavour. Distinct from `Flavoured` so the
162 /// report cannot tell a reader to go looking for a `past` in a rule when it is in the
163 /// data (or vice versa) — different place, different repair.
164 FlavouredFact,
165 /// The KB has a non-empty `du`-equivalence, so fact lookup is modulo union-find and
166 /// a plain set-membership test would miss equivalent variants.
167 Equality,
168 /// A `~P` restrictor group that does not project cleanly.
169 NegatedGroup(String),
170 /// Admitted on its own merits, but something it depends on was not.
171 DependsOn(String),
172 /// The stored facts skip a role place (`rel_x1` present, `rel_x2` missing), so there
173 /// is no whole surface atom to project.
174 RoleGap,
175 /// Stored facts of this relation disagree on how many role places they carry, so
176 /// there is no single surface arity to probe against.
177 ArityClash,
178 /// An abstraction TYPING relation (`__abs_<hash>` or the `event(·)` anchor beside it).
179 /// The projection eliminates the referent, so these carry no surface extension —
180 /// refused rather than omitted so they cannot be mistaken for pure EDB.
181 AbstractionTyping,
182}
183
184impl Ineligible {
185 /// One-line explanation, for `materialization_report`.
186 pub fn reason(&self) -> String {
187 match self {
188 Ineligible::SkolemHead(r) => {
189 format!("rule '{r}' has a Skolem function in its head after projection")
190 }
191 Ineligible::NotProjectable(r) => {
192 format!("rule '{r}' conditions do not partition into per-atom role groups")
193 }
194 Ineligible::NotRangeRestricted(r) => {
195 format!("rule '{r}' is not range-restricted (a head variable is unbound)")
196 }
197 Ineligible::ComputeCondition(r) => {
198 format!("rule '{r}' has a compute-backend condition (domain not enumerable)")
199 }
200 Ineligible::Flavoured(r) => {
201 format!("rule '{r}' carries a tense/deontic flavour (not reproduced in v1)")
202 }
203 Ineligible::FlavouredFact => {
204 "a stored fact of it carries a tense/deontic flavour (not reproduced in v1)"
205 .to_string()
206 }
207 Ineligible::Equality => {
208 "the KB has `=` equivalence classes (lookup is modulo union-find)".to_string()
209 }
210 Ineligible::NegatedGroup(r) => {
211 format!("rule '{r}' has a `~` restrictor group that does not project cleanly")
212 }
213 Ineligible::DependsOn(d) => format!("depends on '{d}', which is not materialisable"),
214 Ineligible::RoleGap => {
215 "its stored facts skip a role place — no whole surface atom to project".to_string()
216 }
217 Ineligible::ArityClash => {
218 "its stored facts disagree on arity — no single surface shape to probe".to_string()
219 }
220 Ineligible::AbstractionTyping => {
221 "an abstraction typing marker — the projection eliminates its referent".to_string()
222 }
223 }
224 }
225}
226
227// ─── The event projection ─────────────────────────────────────────────────────
228//
229// Neo-Davidsonian decomposition turns a surface atom into an anchor plus one role
230// atom per place, all sharing a fresh event term:
231//
232// teaches(Esa, Fin).
233// ⇒ teaches(ev) ∧ teaches_x1(ev, esa) ∧ teaches_x2(ev, fin) ∧ teaches_x3..x5(ev, _)
234//
235// and in a rule head the event term is a dependent Skolem FUNCTION
236// (`SkolemFn("sk_12", x__v2)`), which is exactly what would make a bottom-up fixpoint
237// invent terms forever. Projecting the event away — keeping only the role VALUES —
238// restores a function-free atom `teaches#(esa, fin, _, _, _)` over a fixed finite
239// domain, so saturation terminates.
240//
241// The projection is sound only while the event variable has no cross-atom identity:
242// it must tie the roles of ONE atom together and appear nowhere else. Every check
243// below exists to enforce that, and to REFUSE (never silently mistranslate) when it
244// does not hold.
245
246/// A projected atom: a surface relation and its role values, event eliminated.
247/// Values may contain `PatternVar`s when this came from a rule template.
248#[derive(Clone, Debug, PartialEq, Eq)]
249pub(super) struct Atom {
250 pub(super) relation: String,
251 pub(super) values: Vec<GroundTerm>,
252}
253
254/// Why a projection was refused. Carried into [`Ineligible`] by the caller, which
255/// knows the rule label.
256#[derive(Clone, Debug, PartialEq, Eq)]
257pub(super) enum ProjectErr {
258 /// An atom is neither an anchor `R(ev)` nor a role `R_xN(ev, v)`.
259 NotRoleShaped,
260 /// A group has no anchor atom, so we cannot name the surface relation.
261 NoAnchor,
262 /// Role places are not the contiguous run `x1..xN`.
263 GappedRoles,
264 /// The event term occurs in a role VALUE — it has cross-atom identity, so
265 /// eliminating it would change what the rule means.
266 EventEscapes,
267 /// Two anchors share one event term (`R(ev) ∧ S(ev)`) — same objection.
268 AmbiguousAnchor,
269 /// A tense/deontic flavour: rule firing is flavour-polymorphic and v1 does not
270 /// reproduce that.
271 Flavoured,
272 /// A `SkolemFn`/`DepPair` survives in a role value (not just the event slot).
273 SkolemInValue,
274}
275
276/// The surface relation a decomposed predicate name belongs to: `teaches_x2` and the
277/// anchor `teaches` both answer `"teaches"`.
278pub(super) fn surface_relation(name: &str) -> &str {
279 split_role(name).map(|(b, _)| b).unwrap_or(name)
280}
281
282/// Split a role predicate name into `(base, place)`: `teaches_x2` → `("teaches", 2)`.
283/// Returns `None` for a name that is not role-shaped. A genuine corpus relation
284/// literally named `foo_x1` cannot be mistaken for a role of `foo`, because a group is
285/// only formed when the ANCHOR `foo(ev)` shares the same event term.
286fn split_role(name: &str) -> Option<(&str, usize)> {
287 let (base, idx) = name.rsplit_once("_x")?;
288 if base.is_empty() {
289 return None;
290 }
291 let place: usize = idx.parse().ok()?;
292 if place == 0 {
293 None
294 } else {
295 Some((base, place))
296 }
297}
298
299/// True for a term the projection must never leave in a value position.
300fn is_function_term(t: &GroundTerm) -> bool {
301 matches!(t, GroundTerm::SkolemFn(_, _) | GroundTerm::DepPair(_, _))
302}
303
304/// Project a set of decomposed atoms into surface atoms, one per event group.
305///
306/// Returns the projected atoms in a deterministic order (by relation, then by the
307/// order their anchor appeared), or the first structural objection found. Atoms that
308/// are FLAT (no event group — e.g. the `equals` built-in) are returned separately, so
309/// the caller can decide whether it knows how to evaluate them.
310#[allow(clippy::type_complexity)]
311fn project_atoms(atoms: &[StoredFact]) -> Result<(Vec<Atom>, Vec<StoredFact>), ProjectErr> {
312 project_atoms_inner(atoms).map(|(atoms, flat, _)| (atoms, flat))
313}
314
315/// As [`project_atoms`], additionally returning the ABSTRACTION MARKER relations that were
316/// suppressed. The caller must refuse those explicitly — see [`ProjectedRule::suppressed`].
317fn project_atoms_inner(
318 atoms: &[StoredFact],
319) -> Result<(Vec<Atom>, Vec<StoredFact>, Vec<String>), ProjectErr> {
320 // Every atom must be Bare: a Past/Obligatory template fires flavour-polymorphically
321 // and v1 does not model that. (This is also what keeps the GDPR-style
322 // `obligated_by(every X, event { … })` / `permitted(…)` rules out — they compile to
323 // Obligatory/Permitted stored facts, so only the plain `entitled` shape reaches the
324 // abstraction handling below.)
325 if atoms.iter().any(|a| !matches!(a, StoredFact::Bare(_))) {
326 return Err(ProjectErr::Flavoured);
327 }
328
329 // ── ABSTRACTION PRE-PASS ──
330 //
331 // `entitled(every person, event { P() }).` compiles to a head carrying an abstraction
332 // referent: `event(sk_1(x))` and `__abs_<hash>(sk_1(x))` — TWO arity-1 atoms on one
333 // event term — plus `entitled_x2(sk_3(x), sk_1(x))`, the referent in a role VALUE.
334 // Untreated those are `AmbiguousAnchor` and `SkolemInValue`, which is why every
335 // abstraction-bearing rule was outside the saturation.
336 //
337 // The identity that crosses compiles is the marker RELATION NAME, not any term: it is
338 // `__abs_{fnv1a(canonical body):016x}`, byte-identical wherever the same body appears,
339 // and the engine matches abstractions by that marker rather than by re-deriving
340 // content. So the referent projects to the marker name as an opaque CONSTANT — the
341 // same move `nibli-verify/src/asp.rs`'s `abs_const_of` makes for clingo, reimplemented
342 // here rather than shared so the oracle stays independent.
343 //
344 // Collapsing `sk_1(adam)` and `sk_1(bel)` onto one constant is sound ONLY while the
345 // referent is used by exactly one role atom; more than one would be genuine cross-atom
346 // identity that the collapse would erase. Enforced below, as asp.rs enforces it.
347 let mut abs_const: HashMap<&GroundTerm, String> = HashMap::new();
348 let mut suppressed: Vec<String> = Vec::new();
349 for a in atoms {
350 let gf = a.inner();
351 if gf.args.len() == 1
352 && gf
353 .relation
354 .starts_with(crate::kb::ABSTRACTION_MARKER_PREFIX)
355 {
356 abs_const.insert(&gf.args[0], gf.relation.clone());
357 suppressed.push(gf.relation.clone());
358 }
359 }
360 if !abs_const.is_empty() {
361 // The referent may fill at most one role slot across the whole atom set.
362 for (referent, _) in abs_const.iter() {
363 let uses = atoms
364 .iter()
365 .filter(|a| {
366 let gf = a.inner();
367 gf.args.len() == 2 && &gf.args[1] == *referent
368 })
369 .count();
370 if uses > 1 {
371 return Err(ProjectErr::EventEscapes);
372 }
373 }
374 // The `event(·)` typing anchor rides in the same bucket and is suppressed with it.
375 for a in atoms {
376 let gf = a.inner();
377 if gf.args.len() == 1
378 && abs_const.contains_key(&gf.args[0])
379 && !gf
380 .relation
381 .starts_with(crate::kb::ABSTRACTION_MARKER_PREFIX)
382 {
383 suppressed.push(gf.relation.clone());
384 }
385 }
386 }
387 let referent_const = |t: &GroundTerm| -> Option<GroundTerm> {
388 abs_const.get(t).map(|m| GroundTerm::Constant(m.clone()))
389 };
390
391 // Bucket by event term (argument 0). `order` keeps first-seen order so the output
392 // is reproducible without sorting by a term type that has no natural key.
393 let mut anchor_of: HashMap<&GroundTerm, &str> = HashMap::new();
394 let mut roles_of: HashMap<&GroundTerm, Vec<(usize, &GroundTerm, &str)>> = HashMap::new();
395 let mut order: Vec<&GroundTerm> = Vec::new();
396 let mut flat: Vec<StoredFact> = Vec::new();
397
398 for a in atoms {
399 let gf = a.inner();
400 // Suppress the whole marker bucket — both `__abs_<hash>(ref)` and the `event(ref)`
401 // typing anchor. It is abstraction TYPING, not a surface atom.
402 if gf.args.len() == 1 && abs_const.contains_key(&gf.args[0]) {
403 continue;
404 }
405 match gf.args.len() {
406 1 => {
407 let ev = &gf.args[0];
408 if anchor_of.insert(ev, gf.relation.as_str()).is_some() {
409 return Err(ProjectErr::AmbiguousAnchor);
410 }
411 if !roles_of.contains_key(ev) {
412 order.push(ev);
413 roles_of.entry(ev).or_default();
414 }
415 }
416 2 => match split_role(&gf.relation) {
417 Some((_, place)) => {
418 let ev = &gf.args[0];
419 if !roles_of.contains_key(ev) {
420 order.push(ev);
421 }
422 roles_of.entry(ev).or_default().push((
423 place,
424 &gf.args[1],
425 gf.relation.as_str(),
426 ));
427 }
428 // An arity-2 non-role atom is FLAT (`equals(a, b)`), not part of any
429 // event group.
430 None => flat.push(a.clone()),
431 },
432 // Arity 0 or ≥3 is not a decomposed shape at all.
433 _ => flat.push(a.clone()),
434 }
435 }
436
437 let mut out = Vec::with_capacity(order.len());
438 for ev in order {
439 let Some(&base) = anchor_of.get(ev) else {
440 return Err(ProjectErr::NoAnchor);
441 };
442 let mut roles = roles_of.remove(ev).unwrap_or_default();
443 // Every role must belong to THIS anchor's relation.
444 for (_, _, rel) in &roles {
445 match split_role(rel) {
446 Some((b, _)) if b == base => {}
447 _ => return Err(ProjectErr::NotRoleShaped),
448 }
449 }
450 roles.sort_by_key(|(place, _, _)| *place);
451 // Contiguous x1..xN, no gaps, no duplicates.
452 for (i, (place, _, _)) in roles.iter().enumerate() {
453 if *place != i + 1 {
454 return Err(ProjectErr::GappedRoles);
455 }
456 }
457 let mut values = Vec::with_capacity(roles.len());
458 for (_, v, _) in roles {
459 // The event must not leak into a value: that would be cross-atom identity,
460 // which the projection cannot preserve.
461 if v == ev {
462 return Err(ProjectErr::EventEscapes);
463 }
464 // An abstraction referent in a value slot becomes its opaque marker constant.
465 // This is what dissolves the `SkolemInValue` refusal for `entitled_x2`.
466 if let Some(c) = referent_const(v) {
467 values.push(c);
468 continue;
469 }
470 if is_function_term(v) {
471 return Err(ProjectErr::SkolemInValue);
472 }
473 values.push(v.clone());
474 }
475 out.push(Atom {
476 relation: base.to_string(),
477 values,
478 });
479 }
480 suppressed.sort();
481 suppressed.dedup();
482 Ok((out, flat, suppressed))
483}
484
485/// Project a `~P` restrictor group. Its templates are one event group by construction
486/// (`detect_negated_exists_group` only admits that shape), so exactly one atom must
487/// come out and nothing may be left flat.
488fn project_negated_group(group: &NegatedExistsGroup) -> Result<Atom, ProjectErr> {
489 let (mut atoms, flat) = project_atoms(&group.conditions)?;
490 if atoms.len() != 1 || !flat.is_empty() {
491 return Err(ProjectErr::NotRoleShaped);
492 }
493 Ok(atoms.remove(0))
494}
495
496/// A rule rewritten as function-free surface Datalog. `None` for any rule the
497/// projection refuses — the caller turns that into an [`Ineligible`] with the reason.
498pub(super) struct ProjectedRule {
499 pub(super) label: String,
500 /// Positive body atoms, joined left to right.
501 pub(super) positive: Vec<Atom>,
502 /// Negated body atoms, checked by lookup once the positives have bound everything.
503 pub(super) negative: Vec<Atom>,
504 /// Flat built-in conditions we know how to decide, with their negation flag.
505 /// Today this is exactly `equals` (see [`BUILTIN_RELATIONS`]).
506 pub(super) builtins: Vec<(StoredFact, bool)>,
507 /// Head atoms — one per conclusion group. A rule may conclude several relations.
508 pub(super) head: Vec<Atom>,
509 /// Abstraction TYPING relations the projection suppressed — the `__abs_<hash>` marker
510 /// and the `event(·)` anchor riding in its bucket.
511 ///
512 /// These MUST be refused explicitly by the caller, never merely omitted. `is_edb` is
513 /// `!rules.contains_key && !refused.contains_key`, and `saturate` marks an EDB relation
514 /// complete straight from its (empty) seed — so a suppressed marker left unrefused
515 /// would answer `~event(x)` TRUE where backward chaining derives `event(sk_1(adam))`
516 /// from the rule and answers FALSE. A definitive wrong verdict.
517 pub(super) suppressed: Vec<String>,
518}
519
520/// Flat conditions the saturator can decide itself, without a stored extension.
521/// `equals` is decidable from the term structure alone (reflexivity), and the
522/// union-find path is excluded separately by [`Ineligible::Equality`], so a plain
523/// structural comparison is exact here.
524const BUILTIN_RELATIONS: &[&str] = &[nibli_types::relations::IDENTITY];
525
526fn is_builtin(rel: &str) -> bool {
527 BUILTIN_RELATIONS.contains(&rel)
528}
529
530/// Rewrite one compiled rule as function-free surface Datalog.
531pub(super) fn project_rule(rule: &UniversalRuleRecord) -> Result<ProjectedRule, Ineligible> {
532 let label = rule.label.clone();
533 let flav = |e: ProjectErr| -> Ineligible {
534 match e {
535 ProjectErr::Flavoured => Ineligible::Flavoured(label.clone()),
536 ProjectErr::SkolemInValue => Ineligible::SkolemHead(label.clone()),
537 _ => Ineligible::NotProjectable(label.clone()),
538 }
539 };
540
541 // Split the conditions into the positively- and negatively-flagged halves FIRST:
542 // a flat negated literal and a positive one project identically, but they must not
543 // be merged into one event group by accident.
544 let mut pos_conds: Vec<StoredFact> = Vec::new();
545 let mut neg_conds: Vec<StoredFact> = Vec::new();
546 for (i, c) in rule.typed_conditions.iter().enumerate() {
547 if rule.negated_condition_indices.contains(&i) {
548 neg_conds.push(c.clone());
549 } else {
550 pos_conds.push(c.clone());
551 }
552 }
553
554 let (positive, pos_flat) = project_atoms(&pos_conds).map_err(&flav)?;
555 let (neg_flat_atoms, neg_flat) = project_atoms(&neg_conds).map_err(&flav)?;
556
557 let mut builtins: Vec<(StoredFact, bool)> = Vec::new();
558 for (f, negated) in pos_flat
559 .into_iter()
560 .map(|f| (f, false))
561 .chain(neg_flat.into_iter().map(|f| (f, true)))
562 {
563 if !is_builtin(f.relation()) {
564 // A flat condition we cannot decide — most often a compute predicate,
565 // whose domain is not enumerable.
566 return Err(Ineligible::ComputeCondition(label.clone()));
567 }
568 builtins.push((f, negated));
569 }
570
571 let mut negative = neg_flat_atoms;
572 for g in &rule.negated_exists_groups {
573 match project_negated_group(g) {
574 Ok(a) => negative.push(a),
575 Err(ProjectErr::Flavoured) => return Err(Ineligible::Flavoured(label)),
576 Err(_) => return Err(Ineligible::NegatedGroup(label)),
577 }
578 }
579
580 // The head. `project_atoms` rejects a `SkolemFn` in a VALUE position but not in the
581 // event slot, which is exactly right: the dependent Skolem that every `∀`-rule head
582 // carries lives in the event slot and is what the projection eliminates.
583 let (head, head_flat, suppressed) =
584 project_atoms_inner(&rule.typed_conclusions).map_err(&flav)?;
585 if !head_flat.is_empty() || head.is_empty() {
586 return Err(Ineligible::NotProjectable(label));
587 }
588 // A rule that DERIVES a built-in would invalidate the built-in evaluator below,
589 // which decides `equals` from term structure alone. Refuse rather than evaluate a
590 // relation two different ways in one saturation.
591 if head.iter().any(|a| is_builtin(&a.relation)) {
592 return Err(Ineligible::NotProjectable(label));
593 }
594
595 // RANGE RESTRICTION. Every variable in a head value, in a negated atom, or in a
596 // built-in must be bound by a positive body atom — otherwise the rule ranges over
597 // terms the saturation never enumerates and its extension would be under-derived,
598 // which is the one way this optimisation can turn a NAF FALSE into a wrong TRUE.
599 let mut bound: HashSet<&str> = HashSet::new();
600 for a in &positive {
601 for v in &a.values {
602 if let GroundTerm::PatternVar(n) = v {
603 bound.insert(n.as_str());
604 }
605 }
606 }
607 let unbound = |vals: &[GroundTerm]| -> bool {
608 vals.iter()
609 .any(|v| matches!(v, GroundTerm::PatternVar(n) if !bound.contains(n.as_str())))
610 };
611 if head.iter().any(|a| unbound(&a.values))
612 || negative.iter().any(|a| unbound(&a.values))
613 || builtins
614 .iter()
615 .any(|(f, _)| unbound(f.inner().args.as_slice()))
616 {
617 return Err(Ineligible::NotRangeRestricted(label));
618 }
619
620 Ok(ProjectedRule {
621 label,
622 positive,
623 negative,
624 builtins,
625 head,
626 suppressed,
627 })
628}
629
630/// Every distinct rule in the KB, once. `universal_rules` indexes the SAME `Arc` under
631/// every relation the rule concludes, so a naive iteration would visit a multi-headed
632/// rule several times.
633pub(super) fn distinct_rules(
634 inner: &KnowledgeBaseInner,
635) -> Vec<&std::sync::Arc<UniversalRuleRecord>> {
636 let mut seen: HashSet<*const UniversalRuleRecord> = HashSet::new();
637 let mut keys: Vec<&String> = inner.universal_rules.keys().collect();
638 keys.sort();
639 let mut out = Vec::new();
640 for k in keys {
641 for r in &inner.universal_rules[k] {
642 if seen.insert(std::sync::Arc::as_ptr(r)) {
643 out.push(r);
644 }
645 }
646 }
647 out
648}
649
650/// The outcome of the eligibility analysis: which surface relations may be saturated,
651/// and why each of the others may not.
652pub(super) struct Eligibility {
653 pub(super) eligible: HashSet<String>,
654 pub(super) refused: HashMap<String, Ineligible>,
655 /// The projected form of every rule that survived, keyed by head relation.
656 pub(super) rules: HashMap<String, Vec<std::sync::Arc<ProjectedRule>>>,
657}
658
659/// Decide which surface relations can be saturated bottom-up.
660///
661/// Two passes. First, project every rule: a rule that refuses poisons every relation it
662/// concludes. Second, close DOWNWARD — a relation stays eligible only while every
663/// relation its surviving rules read is itself eligible, pure EDB, or a built-in. The
664/// closure is a shrinking fixpoint, so it is order-independent and terminates.
665pub(super) fn eligible_relations(inner: &KnowledgeBaseInner) -> Eligibility {
666 let mut refused: HashMap<String, Ineligible> = HashMap::new();
667 let mut rules: HashMap<String, Vec<std::sync::Arc<ProjectedRule>>> = HashMap::new();
668
669 // The `du` union-find makes fact lookup modulo equivalence classes; a plain
670 // set-membership test on a projected tuple would miss an equivalent variant, so a
671 // NAF answered by lookup could wrongly report "no witness". Refuse the whole KB.
672 if !inner.equivalence_parent.is_empty() {
673 for r in distinct_rules(inner) {
674 for c in &r.typed_conclusions {
675 refused.insert(c.relation().to_string(), Ineligible::Equality);
676 }
677 }
678 return Eligibility {
679 eligible: HashSet::new(),
680 refused,
681 rules,
682 };
683 }
684
685 for r in distinct_rules(inner) {
686 match project_rule(r) {
687 Ok(pr) => {
688 // Abstraction TYPING relations the projection suppressed are REFUSED, not
689 // omitted. `is_edb` is "no rule and not refused", and `saturate` marks an
690 // EDB relation complete straight from its seed — so an omitted marker
691 // would be complete over an EMPTY extension, and `~event(x)` would answer
692 // TRUE where backward chaining derives `event(sk_1(adam))` from this very
693 // rule and answers FALSE. A definitive wrong verdict, silently.
694 for rel in &pr.suppressed {
695 refused
696 .entry(rel.clone())
697 .or_insert(Ineligible::AbstractionTyping);
698 }
699 let pr = std::sync::Arc::new(pr);
700 for h in &pr.head {
701 rules
702 .entry(h.relation.clone())
703 .or_default()
704 .push(pr.clone());
705 }
706 }
707 Err(why) => {
708 // Name every relation this rule could have concluded. The conclusion
709 // templates are decomposed, so the surface name is the anchor's — but a
710 // refused projection may not have found one, so fall back to stripping
711 // the role suffix.
712 for c in &r.typed_conclusions {
713 let rel = split_role(c.relation())
714 .map(|(b, _)| b.to_string())
715 .unwrap_or_else(|| c.relation().to_string());
716 refused.entry(rel).or_insert_with(|| why.clone());
717 }
718 }
719 }
720 }
721
722 // Candidate set: every relation with at least one surviving rule, minus the refused.
723 let mut eligible: HashSet<String> = rules
724 .keys()
725 .filter(|r| !refused.contains_key(*r))
726 .cloned()
727 .collect();
728
729 // A relation is pure EDB when nothing can DERIVE it: no surviving rule concludes it
730 // AND no refused rule concluded it either. The second half is load-bearing — a
731 // relation whose only rule failed to project has no entry in `rules`, and calling
732 // that EDB would silently read just its asserted facts and miss every derived one,
733 // which is exactly the under-derivation that turns a NAF FALSE into a wrong TRUE.
734 fn is_edb(
735 rel: &str,
736 rules: &HashMap<String, Vec<std::sync::Arc<ProjectedRule>>>,
737 refused: &HashMap<String, Ineligible>,
738 ) -> bool {
739 !rules.contains_key(rel) && !refused.contains_key(rel)
740 }
741
742 // Downward closure to a fixpoint. Bounded by |eligible| passes: each pass either
743 // removes at least one relation or stops.
744 loop {
745 let mut drop_rel: Option<(String, String)> = None;
746 'outer: for rel in &eligible {
747 for pr in rules.get(rel).into_iter().flatten() {
748 for dep in pr.positive.iter().chain(pr.negative.iter()) {
749 if eligible.contains(&dep.relation) || is_edb(&dep.relation, &rules, &refused) {
750 continue;
751 }
752 drop_rel = Some((rel.clone(), dep.relation.clone()));
753 break 'outer;
754 }
755 }
756 }
757 match drop_rel {
758 Some((rel, dep)) => {
759 eligible.remove(&rel);
760 refused.entry(rel).or_insert(Ineligible::DependsOn(dep));
761 }
762 None => break,
763 }
764 }
765
766 Eligibility {
767 eligible,
768 refused,
769 rules,
770 }
771}
772
773// ─── Saturation ───────────────────────────────────────────────────────────────
774
775/// Extensions of the projected relations: surface relation → set of role-value tuples.
776pub(super) type Extensions = HashMap<String, HashSet<Vec<GroundTerm>>>;
777
778/// Total derived-tuple budget for one saturation.
779///
780/// Saturation is an OPTIMISATION. A KB whose least model is enormous would spend more
781/// time saturating than the backward search it replaces, so the budget is a
782/// stop-loss, not a correctness device: exceeding it abandons the stratum and leaves
783/// its relations INCOMPLETE, which means every NAF over them falls back to today's
784/// path. Deliberately generous — the shipped corpora derive in the hundreds.
785const MAX_MATERIALIZED_TUPLES: usize = 2_000_000;
786
787/// The result of a saturation: which relations were completed, their extensions, and
788/// why each of the others was not.
789pub(super) struct Materialized {
790 pub(super) ext: Extensions,
791 pub(super) complete: HashSet<String>,
792 pub(super) refused: HashMap<String, Ineligible>,
793 /// Projected arity per relation — how many role places its tuples carry.
794 pub(super) arity: HashMap<String, usize>,
795}
796
797impl Materialized {
798 pub(super) fn empty() -> Self {
799 Materialized {
800 ext: Extensions::new(),
801 complete: HashSet::new(),
802 refused: HashMap::new(),
803 arity: HashMap::new(),
804 }
805 }
806
807 /// May a probe of `arity` role places read this relation's extension as complete?
808 ///
809 /// The arity check is not belt-and-braces. A probe with FEWER places than the stored
810 /// tuples would miss every tuple and read as "nothing derived" — but the engine's own
811 /// group check only tests the atoms the template actually carries, so it WOULD find
812 /// that witness. That mismatch is a wrong definitive NAF TRUE. KR text cannot produce
813 /// it (arity is fixed by the corpus), but `nibli-import` and the programmatic API can,
814 /// so the guard is structural rather than trusting the front-end.
815 ///
816 /// An EMPTY extension records no arity and answers at every width — "nothing derived"
817 /// is correct however many places the probe carries, and that is the case the whole
818 /// optimisation turns on (`~false($t)` when nobody has been voided).
819 pub(super) fn is_complete_for(&self, relation: &str, arity: usize) -> bool {
820 self.complete.contains(relation) && self.arity.get(relation).is_none_or(|&a| a == arity)
821 }
822
823 /// Membership in a COMPLETE extension. The caller must have checked
824 /// [`Self::is_complete_for`] first — an absent relation here is "nothing derived",
825 /// not "not saturated", and confusing the two is how a NAF gets a wrong TRUE.
826 pub(super) fn contains(&self, relation: &str, tuple: &[GroundTerm]) -> bool {
827 self.ext
828 .get(relation)
829 .is_some_and(|set| set.contains(tuple))
830 }
831}
832
833/// Project the fact store into surface tuples — the EDB seed.
834///
835/// Returns the seed plus the set of relations that carry a tense/deontic flavour
836/// anywhere in the store. Those are excluded rather than refusing the whole KB: a
837/// flavoured `past P(x)` and a bare `P(x)` are DIFFERENT facts to the engine, and a
838/// projection that dropped the flavour would merge them.
839fn seed_edb(inner: &KnowledgeBaseInner) -> (Extensions, HashMap<String, Ineligible>) {
840 let mut anchors: HashSet<(String, GroundTerm)> = HashSet::new();
841 let mut roles: HashMap<(String, GroundTerm), Vec<(usize, GroundTerm)>> = HashMap::new();
842 // Relation -> why its stored facts cannot be projected. Three distinct causes share
843 // this map, and they must NOT share a message: a flavour, a role-index gap, and an
844 // arity clash are different repairs, and a report that called all three "tense" would
845 // send the reader looking for a `past` that is not there.
846 let mut unseedable: HashMap<String, Ineligible> = HashMap::new();
847
848 for f in inner.fact_store.all_facts() {
849 let gf = f.inner();
850 let bare = matches!(f, StoredFact::Bare(_));
851 match gf.args.len() {
852 1 => {
853 if !bare {
854 unseedable.insert(gf.relation.clone(), Ineligible::FlavouredFact);
855 continue;
856 }
857 anchors.insert((gf.relation.clone(), gf.args[0].clone()));
858 }
859 2 => {
860 if let Some((base, place)) = split_role(&gf.relation) {
861 if !bare {
862 unseedable.insert(base.to_string(), Ineligible::FlavouredFact);
863 continue;
864 }
865 roles
866 .entry((base.to_string(), gf.args[0].clone()))
867 .or_default()
868 .push((place, gf.args[1].clone()));
869 }
870 // A flat arity-2 fact (`equals`) is not a projected relation.
871 }
872 _ => {}
873 }
874 }
875
876 let mut ext = Extensions::new();
877 for (rel, ev) in anchors {
878 if unseedable.contains_key(&rel) {
879 continue;
880 }
881 let mut rs = roles.remove(&(rel.clone(), ev)).unwrap_or_default();
882 rs.sort_by_key(|(p, _)| *p);
883 // Contiguous x1..xN. A gap means the store holds a partially-retracted or
884 // hand-built decomposition we cannot read as one surface atom — skip the group
885 // and mark the relation flavoured-style ineligible via the caller's checks
886 // rather than inventing a tuple with a hole in it.
887 if rs.iter().enumerate().any(|(i, (p, _))| *p != i + 1) {
888 unseedable.insert(rel, Ineligible::RoleGap);
889 continue;
890 }
891 let tuple: Vec<GroundTerm> = rs.into_iter().map(|(_, v)| v).collect();
892 ext.entry(rel).or_default().insert(tuple);
893 }
894 // ARITY AGREEMENT. Two stored atoms of one relation with different place counts mean
895 // there is no single surface arity to project onto, so a probe of either width would
896 // silently miss the other width's tuples. KR text cannot spell this (arity comes from
897 // the corpus), but RDF import and the programmatic API can — exclude the relation.
898 for (rel, tuples) in &ext {
899 let mut widths = tuples.iter().map(Vec::len);
900 let first = widths.next().unwrap_or(0);
901 if widths.any(|w| w != first) {
902 unseedable.insert(rel.clone(), Ineligible::ArityClash);
903 }
904 }
905 // A relation found to be gap-shaped or arity-clashing after some of its tuples were
906 // already seeded must not keep those partial tuples.
907 for rel in unseedable.keys() {
908 ext.remove(rel);
909 }
910 (ext, unseedable)
911}
912
913/// Bind a projected atom's template values against a concrete tuple.
914/// Returns the extended bindings, or `None` on mismatch.
915fn bind_tuple(
916 template: &[GroundTerm],
917 tuple: &[GroundTerm],
918 bindings: &HashMap<String, GroundTerm>,
919) -> Option<HashMap<String, GroundTerm>> {
920 if template.len() != tuple.len() {
921 return None;
922 }
923 let mut out = bindings.clone();
924 for (t, v) in template.iter().zip(tuple.iter()) {
925 match t {
926 GroundTerm::PatternVar(n) => match out.get(n) {
927 Some(prev) if prev != v => return None,
928 Some(_) => {}
929 None => {
930 out.insert(n.clone(), v.clone());
931 }
932 },
933 other if other == v => {}
934 _ => return None,
935 }
936 }
937 Some(out)
938}
939
940/// Substitute bindings into a template value list. Returns `None` if any variable is
941/// still unbound — range restriction should make that unreachable, so it is a
942/// fail-closed assertion rather than an expected path.
943fn ground_values(
944 template: &[GroundTerm],
945 bindings: &HashMap<String, GroundTerm>,
946) -> Option<Vec<GroundTerm>> {
947 template
948 .iter()
949 .map(|t| match t {
950 GroundTerm::PatternVar(n) => bindings.get(n).cloned(),
951 other => Some(other.clone()),
952 })
953 .collect()
954}
955
956/// Decide a flat built-in condition under the current bindings.
957///
958/// Only `equals` today. Eligibility guarantees an empty `du` union-find, so identity is
959/// exactly structural equality — the same answer `check_predicate_in_kb_typed`'s
960/// reflexivity arm gives, with no equivalence classes to consult.
961fn builtin_holds(fact: &StoredFact, bindings: &HashMap<String, GroundTerm>) -> Option<bool> {
962 let gf = fact.inner();
963 if gf.relation != nibli_types::relations::IDENTITY {
964 return None;
965 }
966 let args = ground_values(&gf.args, bindings)?;
967 // A `du` atom is arity 2 in the flat shape the engine stores; anything else is not
968 // the identity predicate we know how to decide.
969 if args.len() != 2 {
970 return None;
971 }
972 Some(args[0] == args[1])
973}
974
975/// Evaluate one projected rule, appending every head tuple it derives.
976///
977/// `delta_pos` is the semi-naive marker: when `Some(i)`, positive atom `i` is joined
978/// against `delta` (the tuples discovered in the previous round) instead of the full
979/// extension, so a round only re-derives what the previous round could have enabled.
980/// When `None` the rule is evaluated against the full extensions (the seeding round).
981fn eval_rule(
982 pr: &ProjectedRule,
983 ext: &Extensions,
984 delta: &Extensions,
985 delta_pos: Option<usize>,
986 out: &mut Vec<(String, Vec<GroundTerm>)>,
987) {
988 fn walk(
989 pr: &ProjectedRule,
990 ext: &Extensions,
991 delta: &Extensions,
992 delta_pos: Option<usize>,
993 i: usize,
994 bindings: HashMap<String, GroundTerm>,
995 out: &mut Vec<(String, Vec<GroundTerm>)>,
996 ) {
997 if i == pr.positive.len() {
998 // Built-ins first: they are the cheapest and often the most selective
999 // (`~($a = $b)` cuts the diagonal out of a self-join).
1000 for (f, negated) in &pr.builtins {
1001 match builtin_holds(f, &bindings) {
1002 Some(holds) if holds != *negated => {}
1003 // Either the built-in fails, or we could not decide it. An
1004 // undecidable built-in must kill the derivation, never be assumed
1005 // true: this saturation's whole value is that a MISSING tuple means
1006 // "not derivable".
1007 _ => return,
1008 }
1009 }
1010 // Negated atoms: a lookup into a strictly-lower, already-complete stratum.
1011 for n in &pr.negative {
1012 let Some(t) = ground_values(&n.values, &bindings) else {
1013 return;
1014 };
1015 if ext.get(&n.relation).is_some_and(|s| s.contains(&t)) {
1016 return;
1017 }
1018 }
1019 for h in &pr.head {
1020 if let Some(t) = ground_values(&h.values, &bindings) {
1021 out.push((h.relation.clone(), t));
1022 }
1023 }
1024 return;
1025 }
1026 let atom = &pr.positive[i];
1027 let source = if delta_pos == Some(i) { delta } else { ext };
1028 let Some(tuples) = source.get(&atom.relation) else {
1029 return;
1030 };
1031 for tuple in tuples {
1032 if let Some(b) = bind_tuple(&atom.values, tuple, &bindings) {
1033 walk(pr, ext, delta, delta_pos, i + 1, b, out);
1034 }
1035 }
1036 }
1037 walk(pr, ext, delta, delta_pos, 0, HashMap::new(), out);
1038}
1039
1040/// Saturate `targets` and everything they depend on, stratum by stratum.
1041///
1042/// This is the whole point of the module: when it returns, every relation in
1043/// `complete` has its FULL extension in `ext`, so `~p(x)` is answered by asking whether
1044/// a tuple is in a set — no proof attempt, no depth bound, no domain cartesian.
1045pub(super) fn saturate(
1046 inner: &KnowledgeBaseInner,
1047 elig: &Eligibility,
1048 strata: &Strata,
1049 targets: &HashSet<String>,
1050) -> Materialized {
1051 // EQUALITY GUARD — repeated here, not only in `eligible_relations`.
1052 //
1053 // `eligible_relations` refuses every RULE-derived relation when a `du` union-find
1054 // exists, but that is not enough on its own: the loop below also marks a rule-less
1055 // EDB relation complete straight from its seed, and a seed is a set of stored tuples
1056 // with no equivalence expansion. With `Ara = Bel` and a stored `rotten(Bel)`, the
1057 // seed for `rotten` omits `rotten(Ara)` — so `~rotten(Ara)` would look like "no
1058 // witness" and answer TRUE where backward chaining, which expands equivalence
1059 // variants in `typed_fact_is_asserted`, correctly answers FALSE. That is a WRONG
1060 // definitive verdict, the exact failure this module must never produce.
1061 //
1062 // (Caught by `equality_classes_refuse_the_whole_kb`, which is why that test asserts
1063 // on the verdicts and not merely on the report.)
1064 if !inner.equivalence_parent.is_empty() {
1065 let mut refused = elig.refused.clone();
1066 for rel in targets {
1067 refused.entry(rel.clone()).or_insert(Ineligible::Equality);
1068 }
1069 return Materialized {
1070 ext: Extensions::new(),
1071 complete: HashSet::new(),
1072 refused,
1073 arity: HashMap::new(),
1074 };
1075 }
1076
1077 let (mut ext, unseedable) = seed_edb(inner);
1078 let mut refused = elig.refused.clone();
1079 for (rel, why) in &unseedable {
1080 refused.entry(rel.clone()).or_insert_with(|| why.clone());
1081 }
1082
1083 // Dependency closure of the targets over eligible relations. A target that is not
1084 // eligible simply never enters, and its NAF keeps today's behaviour.
1085 let mut wanted: HashSet<String> = HashSet::new();
1086 let mut stack: Vec<String> = targets.iter().cloned().collect();
1087 stack.sort();
1088 while let Some(rel) = stack.pop() {
1089 if unseedable.contains_key(&rel) || !wanted.insert(rel.clone()) {
1090 continue;
1091 }
1092 for pr in elig.rules.get(&rel).into_iter().flatten() {
1093 for dep in pr.positive.iter().chain(pr.negative.iter()) {
1094 stack.push(dep.relation.clone());
1095 }
1096 }
1097 }
1098 // Only relations we are actually allowed to saturate.
1099 let saturable: HashSet<&String> = wanted
1100 .iter()
1101 .filter(|r| elig.eligible.contains(*r))
1102 .collect();
1103
1104 // Ascending stratum order. A relation with no rules is EDB: already seeded, so it
1105 // is complete the moment we know nothing can derive more of it.
1106 let mut by_stratum: Vec<(usize, String)> = wanted
1107 .iter()
1108 .map(|r| (strata.get(r).copied().unwrap_or(0), r.clone()))
1109 .collect();
1110 by_stratum.sort();
1111
1112 let mut complete: HashSet<String> = HashSet::new();
1113 let mut budget = MAX_MATERIALIZED_TUPLES;
1114 let mut idx = 0usize;
1115 while idx < by_stratum.len() {
1116 let level = by_stratum[idx].0;
1117 let mut rels: Vec<&String> = Vec::new();
1118 while idx < by_stratum.len() && by_stratum[idx].0 == level {
1119 rels.push(&by_stratum[idx].1);
1120 idx += 1;
1121 }
1122
1123 // Every rule concluding a relation in this stratum, once.
1124 // Pass 1 — EDB. A relation nothing can derive is complete the moment its seed is
1125 // in, and it must be settled BEFORE the dependency check below, because a derived
1126 // relation in this same stratum may read it.
1127 for rel in &rels {
1128 if unseedable.contains_key(*rel) || saturable.contains(*rel) {
1129 continue;
1130 }
1131 if !elig.rules.contains_key(*rel) && !refused.contains_key(*rel) {
1132 complete.insert((*rel).clone());
1133 }
1134 }
1135
1136 // Pass 2 — the derived relations of this stratum.
1137 let mut derived_here: Vec<&String> = rels
1138 .iter()
1139 .filter(|rel| !unseedable.contains_key(**rel) && saturable.contains(**rel))
1140 .copied()
1141 .collect();
1142
1143 // Pass 3 — DEPENDENCY CHECK, and the reason this loop is three passes.
1144 //
1145 // `eligible_relations` closed downward over relations it could not PROJECT, but a
1146 // relation can also become unusable later, when `seed_edb` refuses its stored
1147 // facts (a `past` fact, a role gap, an arity clash). Those refusals are invisible
1148 // to the eligibility analysis, so without this pass a rule reading `~rotten` would
1149 // still be saturated while `rotten`'s extension was ABSENT — and an absent
1150 // extension reads as "nothing derived", so the negated condition passes and the
1151 // head is derived for everyone. That is a definitive wrong TRUE, and it is exactly
1152 // what the ON/OFF differential caught on `mat_seed4` / `mat_seed35`.
1153 //
1154 // A dependency is acceptable if it is already complete (a lower stratum, or the
1155 // EDB pass above) or is being computed alongside us in this stratum's fixpoint.
1156 // Shrinking to a fixpoint: dropping one relation can invalidate another.
1157 loop {
1158 let mut drop_idx: Option<(usize, String)> = None;
1159 'scan: for (i, rel) in derived_here.iter().enumerate() {
1160 for pr in elig.rules.get(*rel).into_iter().flatten() {
1161 for dep in pr.positive.iter().chain(pr.negative.iter()) {
1162 if complete.contains(&dep.relation)
1163 || derived_here.iter().any(|r| **r == dep.relation)
1164 {
1165 continue;
1166 }
1167 drop_idx = Some((i, dep.relation.clone()));
1168 break 'scan;
1169 }
1170 }
1171 }
1172 match drop_idx {
1173 Some((i, dep)) => {
1174 let rel = derived_here.remove(i);
1175 refused
1176 .entry(rel.clone())
1177 .or_insert(Ineligible::DependsOn(dep));
1178 }
1179 None => break,
1180 }
1181 }
1182
1183 let mut stratum_rules: Vec<&std::sync::Arc<ProjectedRule>> = Vec::new();
1184 let mut seen: HashSet<*const ProjectedRule> = HashSet::new();
1185 for rel in &derived_here {
1186 for pr in elig.rules.get(*rel).into_iter().flatten() {
1187 if seen.insert(std::sync::Arc::as_ptr(pr)) {
1188 stratum_rules.push(pr);
1189 }
1190 }
1191 }
1192 if derived_here.is_empty() {
1193 continue;
1194 }
1195
1196 // Semi-naive fixpoint for this stratum.
1197 //
1198 // Round 0 evaluates every rule against the full extensions (which already hold
1199 // the EDB seed plus every completed lower stratum). Later rounds join each rule
1200 // once per positive position against the PREVIOUS round's delta, so a tuple
1201 // combination is only revisited when one of its inputs is new.
1202 let mut delta: Extensions = Extensions::new();
1203 let mut round = 0usize;
1204 let mut overflowed = false;
1205 loop {
1206 let mut produced: Vec<(String, Vec<GroundTerm>)> = Vec::new();
1207 for pr in &stratum_rules {
1208 if round == 0 {
1209 eval_rule(pr, &ext, &delta, None, &mut produced);
1210 } else {
1211 for pos in 0..pr.positive.len() {
1212 // Skip positions whose relation gained nothing last round —
1213 // the join would be over an empty delta.
1214 if delta
1215 .get(&pr.positive[pos].relation)
1216 .is_none_or(HashSet::is_empty)
1217 {
1218 continue;
1219 }
1220 eval_rule(pr, &ext, &delta, Some(pos), &mut produced);
1221 }
1222 }
1223 }
1224 let mut next: Extensions = Extensions::new();
1225 for (rel, tuple) in produced {
1226 if ext.get(&rel).is_some_and(|s| s.contains(&tuple)) {
1227 continue;
1228 }
1229 if budget == 0 {
1230 overflowed = true;
1231 break;
1232 }
1233 if next.entry(rel).or_default().insert(tuple) {
1234 budget -= 1;
1235 }
1236 }
1237 if overflowed {
1238 break;
1239 }
1240 let grew = next.values().any(|s| !s.is_empty());
1241 for (rel, set) in &next {
1242 ext.entry(rel.clone())
1243 .or_default()
1244 .extend(set.iter().cloned());
1245 }
1246 delta = next;
1247 if !grew {
1248 break;
1249 }
1250 round += 1;
1251 }
1252
1253 if overflowed {
1254 // Stop-loss. Leave this stratum's relations INCOMPLETE — and every later
1255 // stratum too, since their negated lookups would read a partial extension.
1256 for rel in derived_here {
1257 refused
1258 .entry(rel.clone())
1259 .or_insert_with(|| Ineligible::DependsOn("the materialisation budget".into()));
1260 }
1261 break;
1262 }
1263 for rel in derived_here {
1264 complete.insert(rel.clone());
1265 }
1266 }
1267
1268 // Anything wanted but never completed is reported, so `materialization_report` can
1269 // say why a query is still paying for a proof search.
1270 for rel in &wanted {
1271 if !complete.contains(rel) {
1272 refused
1273 .entry(rel.clone())
1274 .or_insert_with(|| Ineligible::DependsOn("an unsaturated dependency".into()));
1275 }
1276 }
1277
1278 // One projected arity per relation, taken from its saturated tuples.
1279 //
1280 // Recorded ONLY when tuples exist and agree on a width. An EMPTY extension records
1281 // nothing, and that is deliberate rather than a gap: "nothing derived" is the answer
1282 // at every width, and it is also the most important case the optimisation has —
1283 // `~false($t)` when nobody has been voided is exactly an empty extension, and it must
1284 // answer TRUE, not fall back. A DISAGREEING width records nothing either, and the
1285 // relation loses its `complete` status below: there is no single surface arity to
1286 // probe against, so a probe of one width would silently miss the other's tuples.
1287 let mut arity: HashMap<String, usize> = HashMap::new();
1288 let mut clashing: HashSet<String> = HashSet::new();
1289 for (rel, tuples) in &ext {
1290 let mut widths = tuples.iter().map(Vec::len);
1291 let Some(first) = widths.next() else { continue };
1292 if widths.all(|w| w == first) {
1293 arity.insert(rel.clone(), first);
1294 } else {
1295 clashing.insert(rel.clone());
1296 }
1297 }
1298 let complete: HashSet<String> = complete
1299 .into_iter()
1300 .filter(|rel| !clashing.contains(rel))
1301 .collect();
1302 for rel in &clashing {
1303 refused
1304 .entry(rel.clone())
1305 .or_insert_with(|| Ineligible::Flavoured(format!("mixed arities for '{rel}'")));
1306 }
1307
1308 Materialized {
1309 ext,
1310 complete,
1311 refused,
1312 arity,
1313 }
1314}
1315
1316/// Every surface relation occurring under a `NotNode` in a compiled buffer — the
1317/// query-side half of the materialisation target set.
1318///
1319/// Deliberately over-approximating: it collects every predicate reachable from any
1320/// negation, not just the immediate ones. Naming a relation that turns out not to need
1321/// saturating costs a little work; MISSING one only costs the optimisation, and neither
1322/// can change a verdict.
1323pub(super) fn collect_negated_relations(
1324 buffer: &nibli_types::logic::LogicBuffer,
1325 out: &mut HashSet<String>,
1326) {
1327 use nibli_types::logic::LogicNode;
1328 fn walk(
1329 buffer: &nibli_types::logic::LogicBuffer,
1330 id: u32,
1331 under_not: bool,
1332 out: &mut HashSet<String>,
1333 seen: &mut HashSet<u32>,
1334 ) {
1335 if !seen.insert(id) {
1336 return;
1337 }
1338 let Some(node) = buffer.nodes.get(id as usize) else {
1339 return;
1340 };
1341 match node {
1342 LogicNode::Predicate((rel, _)) | LogicNode::ComputeNode((rel, _)) => {
1343 if under_not {
1344 out.insert(surface_relation(rel).to_string());
1345 }
1346 }
1347 LogicNode::NotNode(inner) => walk(buffer, *inner, true, out, seen),
1348 LogicNode::AndNode((l, r)) | LogicNode::OrNode((l, r)) => {
1349 walk(buffer, *l, under_not, out, seen);
1350 walk(buffer, *r, under_not, out, seen);
1351 }
1352 LogicNode::ExistsNode((_, body))
1353 | LogicNode::ForAllNode((_, body))
1354 | LogicNode::CountNode((_, _, body)) => walk(buffer, *body, under_not, out, seen),
1355 LogicNode::PastNode(b)
1356 | LogicNode::PresentNode(b)
1357 | LogicNode::FutureNode(b)
1358 | LogicNode::ObligatoryNode(b)
1359 | LogicNode::PermittedNode(b) => walk(buffer, *b, under_not, out, seen),
1360 }
1361 }
1362 for &root in &buffer.roots {
1363 // A fresh `seen` per root: sub-buffers share one node arena
1364 // (`LogicBuffer::split_roots`), so a node reachable from two roots under
1365 // different polarity must be visited for each.
1366 walk(buffer, root, false, out, &mut HashSet::new());
1367 }
1368}
1369
1370/// Every surface relation mentioned anywhere in a compiled buffer — the query-side target
1371/// set for the POSITIVE fast path.
1372///
1373/// Unlike [`collect_negated_relations`] this ignores polarity: a positive query over a
1374/// saturable relation should hit the lookup too, and a relation named here that turns out
1375/// not to be saturable is simply dropped by the eligibility filter.
1376pub(super) fn collect_query_relations(
1377 buffer: &nibli_types::logic::LogicBuffer,
1378 out: &mut HashSet<String>,
1379) {
1380 use nibli_types::logic::LogicNode;
1381 for node in &buffer.nodes {
1382 if let LogicNode::Predicate((rel, _)) = node {
1383 out.insert(surface_relation(rel).to_string());
1384 }
1385 }
1386}
1387
1388/// Project a `~P` group under a rule's current bindings into a ground surface tuple —
1389/// the probe [`crate::reasoning::eval_negated_exists_group`] uses to replace its
1390/// candidate sweep with a set membership test.
1391///
1392/// Returns `None` whenever the shortcut does not apply (unprojectable group, a value
1393/// still unbound, a flavour), and the caller then takes the ordinary search path. Never
1394/// guesses.
1395pub(super) fn probe_negated_group(
1396 group: &NegatedExistsGroup,
1397 bindings: &HashMap<String, GroundTerm>,
1398) -> Option<(String, Vec<GroundTerm>)> {
1399 let atom = project_negated_group(group).ok()?;
1400 let tuple = ground_values(&atom.values, bindings)?;
1401 if tuple.iter().any(|t| matches!(t, GroundTerm::PatternVar(_))) {
1402 return None;
1403 }
1404 Some((atom.relation, tuple))
1405}
1406
1407/// Project a POSITIVE `∃ev. rel(ev) ∧ rel_x1(ev,a) ∧ …` buffer subtree into a ground
1408/// surface tuple — the probe `check_formula_holds_core`'s `ExistsNode` arm uses to answer
1409/// from a complete extension instead of sweeping candidates.
1410///
1411/// The negated twin ([`probe_negated_group`]) starts from a rule's already-compiled
1412/// `NegatedExistsGroup`; this one starts from raw buffer nodes, so it must do its own
1413/// flattening — and that flattening has to REFUSE, not drop.
1414///
1415/// # Why the obvious helper is wrong
1416///
1417/// `rules::collect_ground_facts` has exactly this signature shape and looks reusable. It
1418/// is not. It is the ASSERT-path walker: an `Or`/`Not` conjunct silently contributes
1419/// nothing (its `build_stored_fact_from_node` returns `None`), and an `∃` whose variable
1420/// is unbound vanishes. Dropping a conjunct WEAKENS the goal, so the projected tuple is
1421/// more general than the query was — and a hit then answers TRUE for something the full
1422/// conjunction makes FALSE. That is fail-OPEN, the one direction this module exists to
1423/// prevent. Hence the explicit `_ => return None` below, modelled on
1424/// `compute::try_evaluate_numeric_group`'s flattener.
1425pub(super) fn probe_positive_group(
1426 buffer: &nibli_types::logic::LogicBuffer,
1427 body_id: u32,
1428 exists_var: &str,
1429 subs: &HashMap<String, GroundTerm>,
1430) -> Option<(String, Vec<GroundTerm>)> {
1431 use nibli_types::logic::LogicNode;
1432
1433 // Flatten the And-tree, REFUSING anything that is not And/Predicate. A `ComputeNode`
1434 // is refused too: its relation is never saturated (`Ineligible::ComputeCondition`), so
1435 // admitting it could only produce a tuple for a relation with no complete extension.
1436 let mut conjuncts: Vec<u32> = Vec::new();
1437 let mut stack = vec![body_id];
1438 while let Some(id) = stack.pop() {
1439 match buffer.nodes.get(id as usize)? {
1440 LogicNode::AndNode((l, r)) => {
1441 stack.push(*l);
1442 stack.push(*r);
1443 }
1444 LogicNode::Predicate(_) => conjuncts.push(id),
1445 _ => return None,
1446 }
1447 }
1448 if conjuncts.is_empty() {
1449 return None;
1450 }
1451
1452 // The event variable must NOT already be bound: this arm is the existential probe, and
1453 // a bound `ev` means the caller is asking about one specific event, which the
1454 // projection cannot answer (it eliminated event identity).
1455 if subs.contains_key(exists_var) {
1456 return None;
1457 }
1458
1459 // Build one `StoredFact` per conjunct with the event variable left as a PatternVar, so
1460 // `project_atoms` can bucket by it exactly as it does for a rule template.
1461 let mut ev_subs = subs.clone();
1462 ev_subs.insert(
1463 exists_var.to_string(),
1464 GroundTerm::PatternVar(exists_var.to_string()),
1465 );
1466 let mut atoms: Vec<StoredFact> = Vec::with_capacity(conjuncts.len());
1467 for id in conjuncts {
1468 atoms.push(crate::rules::build_stored_fact_from_node(
1469 buffer, id, &ev_subs, None,
1470 )?);
1471 }
1472
1473 // One event group, nothing left flat — the same acceptance `project_negated_group`
1474 // demands. Anything else (two groups, a stray `equals`) is not a single relation's
1475 // extension and must fall through to the ordinary search.
1476 let (mut projected, flat) = project_atoms(&atoms).ok()?;
1477 if projected.len() != 1 || !flat.is_empty() {
1478 return None;
1479 }
1480 let atom = projected.remove(0);
1481 let tuple = ground_values(&atom.values, subs)?;
1482 if tuple.iter().any(|t| matches!(t, GroundTerm::PatternVar(_))) {
1483 return None;
1484 }
1485 Some((atom.relation, tuple))
1486}
1487
1488#[cfg(test)]
1489mod strata_tests {
1490 use super::*;
1491
1492 fn g(edges: &[(&str, &str, bool)]) -> HashMap<String, Vec<(String, bool)>> {
1493 let mut m: HashMap<String, Vec<(String, bool)>> = HashMap::new();
1494 for (h, d, n) in edges {
1495 m.entry(h.to_string())
1496 .or_default()
1497 .push((d.to_string(), *n));
1498 }
1499 m
1500 }
1501
1502 #[test]
1503 fn edb_only_graph_is_all_stratum_zero() {
1504 let s = compute_strata(&g(&[("b", "a", false), ("c", "b", false)]));
1505 assert_eq!(s.get("a"), Some(&0));
1506 assert_eq!(s.get("b"), Some(&0));
1507 assert_eq!(s.get("c"), Some(&0));
1508 }
1509
1510 #[test]
1511 fn a_negative_edge_raises_the_reader_one_stratum() {
1512 // reward ⟵ ~false : `false` must be complete before `reward` is evaluated.
1513 let s = compute_strata(&g(&[
1514 ("reward", "false", true),
1515 ("false", "capture", false),
1516 ]));
1517 assert_eq!(s.get("capture"), Some(&0));
1518 assert_eq!(s.get("false"), Some(&0));
1519 assert_eq!(s.get("reward"), Some(&1));
1520 }
1521
1522 #[test]
1523 fn negative_edges_stack_along_a_chain() {
1524 let s = compute_strata(&g(&[("c", "b", true), ("b", "a", true)]));
1525 assert_eq!(s.get("a"), Some(&0));
1526 assert_eq!(s.get("b"), Some(&1));
1527 assert_eq!(s.get("c"), Some(&2));
1528 }
1529
1530 #[test]
1531 fn the_longest_negative_path_wins_not_the_first_found() {
1532 // d reads c (positive) and a (negative); c reads b (negative) reads a (negative).
1533 // The long way round is 2 negative hops, so d must sit at stratum 2, not 1.
1534 let s = compute_strata(&g(&[
1535 ("d", "c", false),
1536 ("d", "a", true),
1537 ("c", "b", true),
1538 ("b", "a", true),
1539 ]));
1540 assert_eq!(s.get("a"), Some(&0));
1541 assert_eq!(s.get("d"), Some(&2));
1542 }
1543
1544 #[test]
1545 fn a_positive_cycle_shares_one_stratum() {
1546 // Mutual positive recursion is ONE evaluation block, not a chain.
1547 let s = compute_strata(&g(&[
1548 ("p", "q", false),
1549 ("q", "p", false),
1550 ("r", "p", true),
1551 ]));
1552 assert_eq!(s.get("p"), s.get("q"));
1553 assert_eq!(s.get("r"), Some(&(s["p"] + 1)));
1554 }
1555
1556 /// A leaf predicate is an edge TARGET but never a graph key. `compute_sccs` includes
1557 /// edge targets in its node set, so it must still get a stratum — otherwise the
1558 /// eligibility closure would treat every EDB relation as unknown and admit nothing.
1559 #[test]
1560 fn condition_only_leaf_predicates_are_labelled() {
1561 let s = compute_strata(&g(&[("head", "leaf", false)]));
1562 assert_eq!(s.get("leaf"), Some(&0));
1563 }
1564
1565 /// Totality guard: the registration gate rejects this shape, but a panic in a
1566 /// read-side optimisation would be far worse than a meaningless-but-finite label.
1567 #[test]
1568 fn a_negative_self_loop_terminates_rather_than_diverging() {
1569 let s = compute_strata(&g(&[("p", "p", true)]));
1570 assert_eq!(s.get("p"), Some(&0));
1571 }
1572
1573 /// `saturate` orders its work by `(stratum, relation name)`, so the saturation
1574 /// sequence is byte-reproducible across runs and processes regardless of HashMap
1575 /// layout. That ordering is only meaningful if the labels themselves are stable.
1576 #[test]
1577 fn labels_are_stable_across_repeated_computation() {
1578 let graph = g(&[("c", "b", true), ("b", "a", true), ("z", "a", false)]);
1579 let first = compute_strata(&graph);
1580 for _ in 0..8 {
1581 assert_eq!(compute_strata(&graph), first);
1582 }
1583 let mut order: Vec<(usize, &str)> = first.iter().map(|(k, v)| (*v, k.as_str())).collect();
1584 order.sort();
1585 assert_eq!(order, vec![(0, "a"), (0, "z"), (1, "b"), (2, "c")]);
1586 }
1587}