Skip to main content

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}