Skip to main content

nibli_reason/
lib.rs

1//! nibli-reason (logic/reasoning) engine: FOL assertion and query via demand-driven backward-chaining.
2//!
3//! This is the core inference component of Nibli. It maintains a stateful knowledge
4//! base with a fact index and backward-chaining rule engine:
5//!
6//! - **Fact assertion** — Ground predicates stored as typed `StoredFact` via pluggable `FactStore` backend.
7//!   Universal quantifiers compile to `UniversalRuleRecord` templates for backward-chaining.
8//! - **Entailment queries** — Recursive formula checking via [`check_formula_holds`] with
9//!   demand-driven backward-chaining through universal rules.
10//! - **Proof traces** — [`check_formula_holds_recording`] builds a proof tree recording which
11//!   rule/axiom was applied at each step (19 proof rule variants). Multi-hop derivation
12//!   provenance traces derived facts through universal rule chains via backward-chaining.
13//! - **Witness extraction** — [`find_witnesses`] returns all satisfying entity bindings for
14//!   existential variables.
15//! - **Compute dispatch** — `ComputeNode` predicates are forwarded to the host-provided
16//!   `compute-backend` WIT interface for external evaluation.
17//!
18//! The knowledge base uses `RefCell` (not `Mutex`) — single-threaded WASI. All
19//! mutable state — facts, rules, the predicate-result cache, the compute
20//! dispatch, and the cancel flag — lives PER-INSTANCE on `KnowledgeBaseInner`;
21//! there are no global or thread-local statics, so distinct KBs (e.g. one per
22//! request on the multithreaded server) never interfere.
23
24#![allow(dead_code)]
25
26use nibli_types::error::NibliError;
27use nibli_types::logic::{
28    FactSummary, LogicBuffer, LogicNode, LogicalTerm, ProofRule, ProofStep, ProofTrace,
29    QueryResult, ResourceKind, UnknownReason, WitnessBinding,
30};
31use std::borrow::Cow;
32use std::cell::RefCell;
33use std::collections::{HashMap, HashSet};
34use std::sync::Arc;
35mod compute;
36/// Fact store abstraction (trait + in-memory implementation).
37pub mod fact_store;
38mod materialize;
39mod reasoning;
40mod rules;
41
42pub use materialize::Ineligible;
43
44pub use compute::ComputeRequest;
45
46use compute::*;
47use reasoning::*;
48use rules::*;
49
50/// The built-in arithmetic predicates marked as `ComputeNode` by default —
51/// `product` (×), `sum` (+), `quotient` (÷). The shared default for every
52/// embedder (nibli-engine, nibli-pipeline, nibli-wasm), paired with
53/// `transform_compute_nodes`.
54pub fn default_compute_predicates() -> HashSet<String> {
55    nibli_types::relations::BUILTIN_ARITHMETIC
56        .iter()
57        .map(|s| s.to_string())
58        .collect()
59}
60
61/// Transform registered compute predicates from Predicate → ComputeNode in a logic buffer.
62/// Call this after nibli-semantics compilation and before asserting/querying.
63pub fn transform_compute_nodes(buf: &mut LogicBuffer, compute_preds: &HashSet<String>) {
64    let nodes = std::mem::take(&mut buf.nodes);
65    buf.nodes = nodes
66        .into_iter()
67        .map(|node| match &node {
68            LogicNode::Predicate((rel, _)) if compute_preds.contains(rel.as_str()) => {
69                let LogicNode::Predicate(inner) = node else {
70                    unreachable!("already matched as Predicate in guard")
71                };
72                LogicNode::ComputeNode(inner)
73            }
74            _ => node,
75        })
76        .collect();
77}
78
79pub mod kb;
80pub use kb::KnowledgeBase;
81pub(crate) use kb::*;
82
83/// One predicate's row in [`KnowledgeBase::stratification_report`].
84#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
85pub struct StratumRow {
86    /// Surface relation name — role predicates (`p_x1`) collapsed onto their anchor (`p`).
87    pub predicate: String,
88    /// Stratum level. 0 means nothing negative sits beneath it; each negative edge
89    /// crossed raises the level by one, so a rule may only read `~q` from a STRICTLY
90    /// lower stratum. This is the assignment `proofs/Stratification.lean` proves exists
91    /// whenever `check_stratification` accepted the KB.
92    pub stratum: usize,
93    /// `true` when NO rule concludes this predicate: base / extensional (EDB).
94    /// `false` when at least one rule does: derived / intensional (IDB).
95    pub base: bool,
96    /// Outgoing dependency edges — "this predicate READS that one" — sorted and
97    /// deduplicated.
98    pub edges: Vec<StratumEdge>,
99}
100
101/// One outgoing dependency edge in a [`StratumRow`].
102#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
103pub struct StratumEdge {
104    /// The predicate depended upon (surface name).
105    pub to: String,
106    /// `true` when read under negation-as-failure — the edge that forces a stratum
107    /// boundary. `false` for an ordinary positive dependency.
108    pub negative: bool,
109}
110
111/// Internal methods that return `Result<_, String>` for use by both the WIT boundary and tests.
112impl KnowledgeBase {
113    fn combine_root_results(left: QueryResult, right: QueryResult) -> QueryResult {
114        if left.is_false() || right.is_false() {
115            QueryResult::False
116        } else if left.is_true() && right.is_true() {
117            QueryResult::True
118        } else {
119            // Shared with And/Or so the four-valued non-definitive precedence cannot drift.
120            reasoning::combine_indeterminate(left, right)
121        }
122    }
123
124    /// Assert FOL facts from a logic buffer into the knowledge base.
125    /// Stores the buffer in the fact registry and returns a unique fact ID.
126    fn assert_fact_inner(&self, logic: LogicBuffer, label: String) -> Result<u64, String> {
127        let mut inner = self.inner.borrow_mut();
128        let id = inner.fresh_fact_id();
129        inner.current_assertion_id = Some(id);
130        let result = process_assertion(&mut inner, &logic);
131        // ALWAYS clear: a stale id would mis-attribute the NEXT assertion's rules
132        // in rule_source_map (register_rule reads current_assertion_id).
133        inner.current_assertion_id = None;
134        if let Err(e) = result {
135            // Atomic rollback. A multi-root assertion that fails on a later root
136            // leaves earlier roots' facts/rules in the live store, but the
137            // FactRecord is only inserted on success — so those facts would be
138            // orphaned (un-listable, un-retractable). The failed assertion has no
139            // FactRecord, so rebuilding from the durable registry reproduces the
140            // exact pre-assertion state, discarding the partial mutation.
141            let rb = Self::rebuild_inner(&mut inner);
142            invalidate_pred_cache(&inner);
143            return match rb {
144                Ok(()) => Err(e),
145                Err(re) => Err(format!("{e} (additionally, rollback failed: {re})")),
146            };
147        }
148        inner.fact_registry.insert(
149            id,
150            FactRecord {
151                id,
152                buffer: logic,
153                label,
154                retracted: false,
155            },
156        );
157        invalidate_pred_cache(&inner); // Tabling: KB mutated, clear cached derivations.
158        Ok(id)
159    }
160
161    /// Assert a fact with a pre-assigned ID. Used for replay from persistent store.
162    /// Advances the internal counter past the given ID.
163    pub fn assert_fact_with_id(
164        &self,
165        logic: LogicBuffer,
166        label: String,
167        id: u64,
168    ) -> Result<(), String> {
169        let mut inner = self.inner.borrow_mut();
170        if id >= inner.fact_counter {
171            inner.fact_counter = id + 1;
172        }
173        // Attribute any rule compiled during this replay to THIS fact in
174        // rule_source_map (otherwise a later retract of a replayed rule-producing
175        // fact leaves a stale rule behind).
176        inner.current_assertion_id = Some(id);
177        let result = process_assertion(&mut inner, &logic);
178        inner.current_assertion_id = None;
179        if let Err(e) = result {
180            let rb = Self::rebuild_inner(&mut inner);
181            invalidate_pred_cache(&inner);
182            return match rb {
183                Ok(()) => Err(e),
184                Err(re) => Err(format!("{e} (additionally, rollback failed: {re})")),
185            };
186        }
187        inner.fact_registry.insert(
188            id,
189            FactRecord {
190                id,
191                buffer: logic,
192                label,
193                retracted: false,
194            },
195        );
196        invalidate_pred_cache(&inner);
197        Ok(())
198    }
199
200    /// Retract a previously asserted fact by its ID: mark the registry record
201    /// retracted, then rebuild from the surviving records.
202    ///
203    /// There USED to be an "incremental O(1)" branch here for flat skolem-free
204    /// ground facts. It was retired (2026-08-01, the numbers-join-the-domain
205    /// adversarial review): it was never O(1) — preserving fact multiplicity
206    /// already walked every surviving record — and it could not maintain
207    /// `retract ≡ never-asserted` for the QUANTIFIER DOMAIN. The noted sets
208    /// (`known_entities`/`known_descriptions`/`known_numbers`) are insert-only;
209    /// precise un-noting needs cross-record reference counting PLUS the witness
210    /// entities minted outside record buffers (existential-import
211    /// presuppositions, count extra witnesses), so a retracted flat
212    /// `Adam = Bel.` left both names as quantifier-domain members and a bare
213    /// `all $x: p($x).` reported a counterexample the store no longer contained
214    /// (22/200 sequences diverged the moment the retraction differential gained
215    /// quantified battery rows) — and a lingering NUMBER is worse, satisfying
216    /// arithmetic/comparison bodies with no store backing at all. Replay
217    /// re-derives every noted set exactly; `retract_diff.rs` pins the
218    /// equivalence, and the rebuild is the same primitive `:accept-scoped`
219    /// already trusts.
220    fn retract_fact_inner(&self, id: u64) -> Result<(), String> {
221        let mut inner = self.inner.borrow_mut();
222        match inner.fact_registry.get_mut(&id) {
223            None => return Err(format!("Fact #{} not found", id)),
224            Some(r) if r.retracted => return Ok(()), // idempotent
225            Some(r) => r.retracted = true,
226        }
227        let result = Self::rebuild_inner(&mut inner);
228        invalidate_pred_cache(&inner);
229        result
230    }
231
232    /// Full rebuild from non-retracted facts. Kept as fallback / consistency check.
233    pub fn rebuild(&self) -> Result<(), String> {
234        let mut inner = self.inner.borrow_mut();
235        Self::rebuild_inner(&mut inner)
236    }
237
238    /// Rebuild the KB from all non-retracted facts.
239    /// Preserves fact_registry and fact_counter; resets all derived state.
240    fn rebuild_inner(inner: &mut KnowledgeBaseInner) -> Result<(), String> {
241        // Preserve user-declared arg sorts (set via `set_predicate_sorts`): replay
242        // only re-infers arity+source per predicate, never the sorts, so clearing
243        // `predicate_registry` below would silently drop them.
244        let saved_arg_sorts: Vec<(String, Vec<String>)> = inner
245            .predicate_registry
246            .iter()
247            .filter(|(_, sig)| !sig.arg_sorts.is_empty())
248            .map(|(pred, sig)| (pred.clone(), sig.arg_sorts.clone()))
249            .collect();
250
251        // Reset derived state (interner too — all interned keys become invalid)
252        inner.skolem_counter = 0;
253        inner.known_entities.clear();
254        inner.known_event_entities.clear();
255        inner.known_descriptions.clear();
256        inner.known_numbers.clear();
257        // The member CACHES must go with the sets they were built from, and the
258        // dirty flag must be raised HERE rather than left to replay re-noting:
259        // `note_entity`/`note_number` set it only on fresh insertion, so a
260        // replay of ZERO surviving records (retract the last fact, then query)
261        // notes nothing and a warmed cache would keep serving the
262        // pre-retraction members — a quantified query then reports a
263        // counterexample the store no longer contains.
264        inner.typed_domain_members_cache.clear();
265        inner.typed_non_event_members_cache.clear();
266        inner.domain_members_dirty = true;
267        inner.known_rules.clear();
268        inner.skolem_fn_registry.clear();
269        inner.fact_store.clear();
270        inner.universal_rules.clear();
271        inner.pred_dep_graph.clear();
272        inner.equivalence_parent.clear();
273        inner.equivalence_classes.clear();
274        inner.predicate_registry.clear();
275        inner.arg_position_index.clear();
276        inner.rule_source_map.clear();
277        inner.negative_facts.clear();
278        inner.disjunctive_constraints.clear();
279        // The saturated extensions are derived from the rules and facts being cleared
280        // right above. Cleared HERE rather than left to the callers' pairing with
281        // `invalidate_pred_cache`, because `KnowledgeBase::rebuild` is the one rebuild
282        // entry point that does NOT invalidate — a stale extension surviving it would
283        // answer `~p(x)` from the pre-rebuild knowledge base.
284        *inner.materialized.borrow_mut() = None;
285
286        // Collect non-retracted buffers + their ids ordered by ID (owned, to avoid
287        // a borrow conflict with the mutable replay below).
288        let mut entries: Vec<(&u64, &FactRecord)> = inner
289            .fact_registry
290            .iter()
291            .filter(|(_, r)| !r.retracted)
292            .collect();
293        entries.sort_by_key(|(id, _)| **id);
294        let ids: Vec<u64> = entries.iter().map(|(id, _)| **id).collect();
295        let buffers: Vec<LogicBuffer> = entries.iter().map(|(_, r)| r.buffer.clone()).collect();
296
297        // Replay with diagnostic output + stratification checks suppressed
298        // (inner.rebuilding == true). Collect-and-continue: replay EVERY surviving
299        // fact so the store stays maximally consistent, accumulating errors rather
300        // than silently dropping a fact that fails to replay.
301        inner.rebuilding = true;
302        let mut replay_errors: Vec<(u64, String)> = Vec::new();
303        for (buf, &fid) in buffers.iter().zip(ids.iter()) {
304            if let Err(e) = process_assertion(inner, buf) {
305                replay_errors.push((fid, e));
306            }
307        }
308        inner.rebuilding = false;
309
310        // Restore the preserved sorts into the re-populated registry.
311        for (pred, sorts) in saved_arg_sorts {
312            let arity = sorts.len();
313            inner
314                .predicate_registry
315                .entry(pred)
316                .or_insert_with(|| PredicateSignature {
317                    arity,
318                    source: SignatureSource::Inferred,
319                    arg_sorts: Vec::new(),
320                })
321                .arg_sorts = sorts;
322        }
323
324        if replay_errors.is_empty() {
325            Ok(())
326        } else {
327            let detail = replay_errors
328                .iter()
329                .map(|(id, e)| format!("#{id}: {e}"))
330                .collect::<Vec<_>>()
331                .join("; ");
332            Err(format!("rebuild replay errors: {detail}"))
333        }
334    }
335
336    /// List all active (non-retracted) facts in the KB.
337    fn list_facts_inner(&self) -> Result<Vec<FactSummary>, String> {
338        let inner = self.inner.borrow();
339        let mut facts: Vec<FactSummary> = inner
340            .fact_registry
341            .values()
342            .filter(|r| !r.retracted)
343            .map(|r| FactSummary {
344                id: r.id,
345                label: r.label.clone(),
346                root_count: r.buffer.roots.len() as u32,
347            })
348            .collect();
349        facts.sort_by_key(|f| f.id);
350        Ok(facts)
351    }
352
353    /// Set the backward-chaining depth bound (`max_chain_depth`, default 10) —
354    /// the "Configurable" knob `GUARANTEES.md §Resource Limits` documents.
355    /// Iterative deepening tries 1..=depth; a query whose shallowest proof needs a
356    /// longer chain returns `ResourceExceeded(Depth)`, never FALSE. Practical note:
357    /// deepening cost grows steeply with depth (each level re-explores the shallower
358    /// search — measured ~15×+ per level on linear rule chains), so the bound is a
359    /// soundness/termination contract, not a performance envelope. Values below 1
360    /// are clamped to 1.
361    pub fn set_max_chain_depth(&self, depth: usize) {
362        self.inner.borrow_mut().max_chain_depth = depth.max(1);
363    }
364
365    /// Saturate the relations this query will read under `~`, so the NAF checks below
366    /// are set-membership tests instead of exhaustive proof attempts.
367    ///
368    /// Called ONCE per query, before the iterative-deepening loop — the extension does
369    /// not depend on the depth budget, so re-deriving it per pass would be pure waste.
370    /// Everything here is best-effort: a relation that cannot be saturated is simply
371    /// absent from the completed set, and its NAF takes the ordinary path.
372    ///
373    /// TARGETS. The relations read under `~`: those under a `NotNode` in the query
374    /// buffer, plus the negated conditions and `~` restrictor groups of every
375    /// registered rule. Rule-body NAF is included unconditionally rather than by
376    /// reachability from the query's head, because backward chaining reaches rules
377    /// through the fact store and the equality fallback as well as through the
378    /// dependency graph — an under-approximated target set would silently lose the
379    /// optimisation, and the saturation is scoped by the dependency closure anyway.
380    fn ensure_materialized(&self, logic: &LogicBuffer) {
381        let inner = self.inner.borrow();
382        if !inner.materialization || inner.materialized.borrow().is_some() {
383            return;
384        }
385        let elig = materialize::eligible_relations(&inner);
386        // TARGETS. Every relation the saturator is ALLOWED to complete, not just the ones
387        // read under `~`: since the positive probe in `check_formula_holds_core`'s
388        // `ExistsNode` arm, a completed extension answers ordinary queries too, so
389        // restricting the target set to the NAF cone would leave the positive fast path
390        // permanently cold. `saturate` still scopes the actual work to the dependency
391        // closure of these, and `eligible_relations` has already refused everything it
392        // cannot project — so widening here cannot admit an unsound relation, only more
393        // sound ones.
394        //
395        // The `~`-read relations are unioned in explicitly because a NAF target may be
396        // pure EDB (no rule concludes it, so it is not an `eligible` key) and still needs
397        // to be marked complete from its seed — that is the common `~rotten(x)` case.
398        let mut targets: HashSet<String> = elig.eligible.iter().cloned().collect();
399        materialize::collect_negated_relations(logic, &mut targets);
400        for rule in materialize::distinct_rules(&inner) {
401            for (i, c) in rule.typed_conditions.iter().enumerate() {
402                if rule.negated_condition_indices.contains(&i) {
403                    targets.insert(materialize::surface_relation(c.relation()).to_string());
404                }
405            }
406            for g in &rule.negated_exists_groups {
407                for c in &g.conditions {
408                    targets.insert(materialize::surface_relation(c.relation()).to_string());
409                }
410            }
411        }
412        // The query's own relations: a positive query over a saturable relation should hit
413        // the fast path even when nothing in the KB negates anything.
414        materialize::collect_query_relations(logic, &mut targets);
415        if targets.is_empty() {
416            *inner.materialized.borrow_mut() = Some(materialize::Materialized::empty());
417            return;
418        }
419        let strata = materialize::compute_strata(&inner.pred_dep_graph);
420        let m = materialize::saturate(&inner, &elig, &strata, &targets);
421        *inner.materialized.borrow_mut() = Some(m);
422    }
423
424    /// Single-pass entailment check at the current max_chain_depth.
425    fn run_entailment_check(&self, logic: &LogicBuffer) -> Result<QueryResult, String> {
426        // Enable WITHOUT clearing: the cache is cleared once before the
427        // iterative-deepening loop in query_entailment_inner, then definitive
428        // results persist across depth passes (cross-depth tabling).
429        let mut inner = self.inner.borrow_mut();
430        enable_pred_cache(&inner);
431        inner.ensure_domain_members_cached();
432        let mut overall = QueryResult::True;
433        for &root_id in &logic.roots {
434            let mut subs = HashMap::new();
435            let result = check_formula_holds(logic, root_id, &mut subs, &mut inner, None)?;
436            overall = Self::combine_root_results(overall, result);
437        }
438        Ok(overall)
439    }
440
441    /// Check whether all root formulas in the logic buffer are entailed by the KB.
442    /// Uses iterative deepening: tries depth 1, 2, ..., max_chain_depth.
443    /// Guarantees finding the shallowest proof.
444    fn query_entailment_inner(&self, logic: LogicBuffer) -> Result<QueryResult, String> {
445        // Tabling: clear once, persist across depth iterations.
446        self.ensure_materialized(&logic);
447        let configured_max = {
448            let inner = self.inner.borrow();
449            clear_and_enable_pred_cache(&inner);
450            inner.max_chain_depth
451        };
452        for depth_limit in 1..=configured_max {
453            self.inner.borrow_mut().max_chain_depth = depth_limit;
454            // Restore the configured depth on EVERY exit, including the error
455            // path (e.g. cooperative cancellation), so an aborted query never
456            // leaves a reusable KB pinned at a partial deepening depth.
457            let result = match self.run_entailment_check(&logic) {
458                Ok(result) => result,
459                Err(e) => {
460                    self.inner.borrow_mut().max_chain_depth = configured_max;
461                    return Err(e);
462                }
463            };
464            if !matches!(result, QueryResult::ResourceExceeded(ResourceKind::Depth)) {
465                self.inner.borrow_mut().max_chain_depth = configured_max;
466                return Ok(result);
467            }
468        }
469        self.inner.borrow_mut().max_chain_depth = configured_max;
470        Ok(QueryResult::ResourceExceeded(ResourceKind::Depth))
471    }
472
473    /// Find all satisfying binding sets for existential variables in the query formula.
474    /// Returns one `Vec<WitnessBinding>` per satisfying assignment.
475    fn query_find_inner(&self, logic: LogicBuffer) -> Result<Vec<Vec<WitnessBinding>>, String> {
476        // Surfaced (as an Err) when witness enumeration is CUT at the depth/cycle
477        // horizon: find/count/aggregate must refuse a definitive (under)count rather
478        // than silently report a wrong quantity. See `find_witnesses` /
479        // `find_horizon_hit` — this is the find-path analog of the entailment path's
480        // `ResourceExceeded(Depth)` verdict.
481        //
482        // WHAT THIS MEANS SINCE STRATUM-ORDERED MATERIALISATION. A saturated relation
483        // returns only definitive verdicts, so `witness_search_cut` never fires for a
484        // leaf inside the materialised fragment and this refusal never triggers there —
485        // no code change was needed for that, it falls out. What remains is the genuine
486        // residue: compute predicates (an infinite numeric domain, not a finite set to
487        // saturate) and any relation the eligibility analysis refused. So the refusal
488        // stopped meaning "your search was too deep" and now means "this query reached
489        // the fragment the engine cannot complete" — and the advice changed with it,
490        // because raising the depth limit does nothing for an unsaturable relation.
491        // `KnowledgeBase::materialization_report` names which relations those are.
492        const INCOMPLETE_MSG: &str = "witness enumeration incomplete: a witness leaf could not be decided \
493             (a compute predicate, or a relation outside the materialised fragment), so \
494             find/count/aggregate would undercount — run `:materialize` (or call \
495             `materialization_report`) to see which relations were not saturated and why; \
496             raising the depth limit helps only for a relation the engine falls back to \
497             backward chaining on";
498        self.ensure_materialized(&logic);
499        let mut inner = self.inner.borrow_mut();
500        clear_and_enable_pred_cache(&inner);
501        inner.ensure_domain_members_cached();
502        inner.find_horizon_hit = false;
503        let mut result_sets: Option<Vec<Vec<(String, GroundTerm)>>> = None;
504        for &root_id in &logic.roots {
505            let mut subs = HashMap::new();
506            let witnesses = find_witnesses(&logic, root_id, &mut subs, &mut inner, None)?;
507            match result_sets {
508                None => result_sets = Some(witnesses),
509                Some(prev) => {
510                    if witnesses.is_empty() {
511                        if inner.find_horizon_hit {
512                            return Err(INCOMPLETE_MSG.to_string());
513                        }
514                        return Ok(vec![]);
515                    }
516                    // Join binding sets across roots: shared variables must agree,
517                    // and fresh variables from later roots are preserved.
518                    let mut joined = Vec::new();
519                    for prev_bindings in prev {
520                        for witness_bindings in &witnesses {
521                            if let Some(combined) =
522                                merge_witness_bindings(&prev_bindings, witness_bindings)
523                            {
524                                joined.push(combined);
525                            }
526                        }
527                    }
528                    if joined.is_empty() {
529                        if inner.find_horizon_hit {
530                            return Err(INCOMPLETE_MSG.to_string());
531                        }
532                        return Ok(vec![]);
533                    }
534                    result_sets = Some(joined);
535                }
536            }
537        }
538        // Enumeration finished — but if any witness leaf was cut at the depth/cycle
539        // horizon, the result is an under-count, not a definitive one. Refuse it.
540        if inner.find_horizon_hit {
541            return Err(INCOMPLETE_MSG.to_string());
542        }
543        let mut binding_sets = result_sets.unwrap_or_default();
544        // Determinism + dedup: witness enumeration touches HashSet-backed
545        // candidate collections, so the order binding sets arrive in is
546        // hasher-seed dependent, and the SAME solution can arrive via distinct
547        // candidates (an Or-overlap where one entity satisfies both disjuncts,
548        // equivalence-class expansion, or the shared entailment/find candidate
549        // superset). Sort the outer list by each set's canonical key (its
550        // sorted (var, term) pairs) so `[Find]` output is byte-reproducible
551        // across runs and processes, THEN drop adjacent canonical duplicates so
552        // `count_witnesses`/`aggregate` count each distinct binding exactly once
553        // (an inflated count would be a hallucinated quantity). Comparison is at
554        // GroundTerm level — distinct terms never collapse; intra-set binding
555        // order (structural, inner-to-outer) is preserved for display.
556        // ENTITY-LEVEL identity (GUARANTEES §Aggregation): tuples binding an
557        // ENTITY variable to a existential-import presupposition witness are dropped
558        // entirely — a phantom entity a rule presupposed satisfies ∃/∀ but is
559        // not an enumerable "thing". Entity variables = everything except the
560        // `_ev*` EVENT vars (description vars `_v{n}` carry answer entities).
561        binding_sets.retain(|bindings| {
562            !bindings.iter().any(|(var, gt)| {
563                !var.starts_with("_ev")
564                    && matches!(gt, GroundTerm::Constant(name)
565                        if inner.presupposition_witnesses.contains(name.as_str()))
566            })
567        });
568        // The DEDUP key is the binding set projected onto ENTITY variables —
569        // `_ev*` event vars are derivation bookkeeping and must not multiply
570        // results (pre-change, one dog answered `?? da gerku` once per
571        // derivation event) — with each term du-CANONICALIZED so two names for
572        // one entity count once. The sort key appends the full raw key so the
573        // total order — and therefore WHICH tuple survives dedup — stays
574        // byte-reproducible regardless of hasher-seed-dependent arrival order;
575        // the survivor's display terms are real asserted names, not
576        // canonicalized rewrites.
577        let entity_key = |bindings: &Vec<(String, GroundTerm)>| {
578            let mut key: Vec<(String, GroundTerm)> = bindings
579                .iter()
580                .filter(|(var, _)| !var.starts_with("_ev"))
581                .map(|(var, gt)| {
582                    (
583                        var.clone(),
584                        find_canonical_readonly(&inner.equivalence_parent, gt),
585                    )
586                })
587                .collect();
588            key.sort();
589            key
590        };
591        let full_key = |bindings: &Vec<(String, GroundTerm)>| {
592            let mut key = bindings.clone();
593            key.sort();
594            key
595        };
596        binding_sets.sort_by_cached_key(|b| (entity_key(b), full_key(b)));
597        binding_sets.dedup_by_key(|bindings| entity_key(bindings));
598        Ok(binding_sets
599            .into_iter()
600            .map(|bindings| {
601                bindings
602                    .into_iter()
603                    .map(|(var, gt)| WitnessBinding {
604                        variable: var,
605                        term: witness_term_to_logical_term(&gt),
606                    })
607                    .collect()
608            })
609            .collect())
610    }
611
612    /// Single-pass entailment check with proof trace at the current max_chain_depth.
613    fn run_entailment_check_with_proof(
614        &self,
615        logic: &LogicBuffer,
616    ) -> Result<(QueryResult, ProofTrace), String> {
617        // Enable WITHOUT clearing: cleared once before the iterative-deepening
618        // loop in query_entailment_with_proof_inner; definitive results persist
619        // across depth passes (cross-depth tabling).
620        let mut inner = self.inner.borrow_mut();
621        enable_pred_cache(&inner);
622        inner.ensure_domain_members_cached();
623        let mut steps: Vec<ProofStep> = Vec::new();
624        let mut memo: HashMap<String, u32> = HashMap::new();
625        let mut root_children: Vec<u32> = Vec::new();
626        let mut overall = QueryResult::True;
627        for &root_id in &logic.roots {
628            let mut subs = HashMap::new();
629            // ONE walk per root: the recording evaluator returns the authoritative
630            // four-valued verdict AND builds the proof trace, so the trace's
631            // per-node `holds` is natively `verdict.is_true()` — no separate
632            // untraced pass and no root `holds` reconciliation needed.
633            let (result, step_idx) = check_formula_holds_recording(
634                logic, root_id, &mut subs, &mut inner, &mut steps, None, &mut memo,
635            )?;
636            overall = Self::combine_root_results(overall, result);
637            root_children.push(step_idx);
638        }
639        let root = if root_children.len() == 1 {
640            root_children[0]
641        } else {
642            let idx = steps.len() as u32;
643            steps.push(ProofStep {
644                rule: ProofRule::Conjunction,
645                holds: overall.is_true(),
646                children: root_children,
647            });
648            idx
649        };
650        let naf_dependent = steps
651            .iter()
652            .any(|s| matches!(s.rule, ProofRule::Negation) && s.holds);
653        // A FALSE verdict is closed-world ("not derivable from the KB") UNLESS a
654        // numeric/arithmetic compute DECIDED it (e.g. `5 dunli 3` is genuinely false).
655        // The dual of `naf_dependent`: under open-world semantics it would be Unknown.
656        let cwa_false = overall.is_false()
657            && !steps.iter().any(|s| {
658                !s.holds
659                    && matches!(
660                        &s.rule,
661                        ProofRule::ComputeCheck { method, .. }
662                            if method == "numeric" || method == "arithmetic"
663                    )
664            });
665        Ok((
666            overall,
667            ProofTrace {
668                steps,
669                root,
670                naf_dependent,
671                cwa_false,
672            },
673        ))
674    }
675
676    /// Check entailment with proof trace using iterative deepening.
677    fn query_entailment_with_proof_inner(
678        &self,
679        logic: LogicBuffer,
680    ) -> Result<(QueryResult, ProofTrace), String> {
681        // Same saturation the untraced path uses — the NAF probe stays on, and its trace
682        // shape is unaffected (`emit_derived` records a `Negation` leaf per group without
683        // re-evaluating it, so `naf_dependent` still computes correctly).
684        self.ensure_materialized(&logic);
685        // The POSITIVE lookup, however, is lowered for the whole traced query — BOTH
686        // phases. A lookup has no derivation to record, and gating it per-sink would let
687        // the untraced phase-1 probe resolve at depth 1 while phase 2 rebuilt the trace by
688        // backward chaining at that same depth and failed to reach it, turning a TRUE into
689        // `ResourceExceeded(Depth)`. Restored on every exit below, error paths included.
690        self.inner.borrow().positive_lookup.set(false);
691        // Tabling: clear once, persist across phases.
692        let configured_max = {
693            let inner = self.inner.borrow();
694            clear_and_enable_pred_cache(&inner);
695            inner.max_chain_depth
696        };
697        // Phase 1: find the resolving depth with the CHEAP untraced walk — no proof
698        // trace is built (then discarded) on the probe passes. The costly part of a
699        // proof query is the ProofStep-tree construction, which (unlike the verdict,
700        // which the predicate cache amortizes across depths) is NOT cross-depth-
701        // cached, so the old per-depth loop rebuilt D-1 partial traces only to throw
702        // them away. If no depth resolves, `resolving_depth` stays `configured_max`
703        // so Phase 2 builds the deepest trace (matching the old `last_trace`).
704        let mut resolving_depth = configured_max;
705        for depth_limit in 1..=configured_max {
706            self.inner.borrow_mut().max_chain_depth = depth_limit;
707            // Restore the configured depth on the error path too (see
708            // query_entailment_inner) — explicit `match`, NOT `?`, so a cancelled
709            // query never leaves the KB pinned at a partial deepening depth.
710            let result = match self.run_entailment_check(&logic) {
711                Ok(r) => r,
712                Err(e) => {
713                    let inner = self.inner.borrow();
714                    inner.positive_lookup.set(true);
715                    drop(inner);
716                    self.inner.borrow_mut().max_chain_depth = configured_max;
717                    return Err(e);
718                }
719            };
720            if !matches!(result, QueryResult::ResourceExceeded(ResourceKind::Depth)) {
721                resolving_depth = depth_limit;
722                break;
723            }
724        }
725        // Phase 2: build the proof trace ONCE at the resolving depth. The predicate
726        // cache (warmed by Phase 1) makes this build's verdict sub-checks cheap; the
727        // trace is byte-identical to the former per-depth build because the trace
728        // descent never shortcuts on the verdict cache and the fact store is
729        // set-idempotent for the only state it reads (`typed_fact_is_asserted`).
730        self.inner.borrow_mut().max_chain_depth = resolving_depth;
731        let out = self.run_entailment_check_with_proof(&logic);
732        {
733            let inner = self.inner.borrow();
734            inner.positive_lookup.set(true);
735        }
736        self.inner.borrow_mut().max_chain_depth = configured_max;
737        out
738    }
739}
740
741fn merge_witness_bindings(
742    left: &[(String, GroundTerm)],
743    right: &[(String, GroundTerm)],
744) -> Option<Vec<(String, GroundTerm)>> {
745    let mut combined = left.to_vec();
746    for (var, val) in right {
747        match combined
748            .iter()
749            .find(|(existing_var, _)| existing_var == var)
750        {
751            Some((_, existing_val)) if existing_val != val => return None,
752            Some(_) => {}
753            None => combined.push((var.clone(), val.clone())),
754        }
755    }
756    Some(combined)
757}
758
759/// Public API for native callers (nibli-pipeline, nibli-engine).
760/// Uses nibli-semantics's logic types directly — no bridge conversion needed.
761impl KnowledgeBase {
762    /// Create a new knowledge base with the default in-memory fact store.
763    pub fn new() -> Self {
764        KnowledgeBase {
765            inner: RefCell::new(KnowledgeBaseInner::new()),
766        }
767    }
768
769    /// Create a KB with a custom fact store backend (e.g., persistent redb).
770    pub fn with_store(store: Box<dyn fact_store::FactStore>) -> Self {
771        let mut inner = KnowledgeBaseInner::new();
772        inner.fact_store = store;
773        KnowledgeBase {
774            inner: RefCell::new(inner),
775        }
776    }
777
778    /// Install a cooperative cancellation flag. When the flag is set to `true`,
779    /// the next central reasoning checkpoint aborts the in-flight query via the
780    /// `Err` channel (the verdict variants are untouched). The native nibli-server
781    /// watchdog sets the flag when a request's wall-clock budget elapses, freeing
782    /// the blocking thread instead of letting a pathological query run to
783    /// completion. No clock is read inside the engine, so the WASI sandbox
784    /// guarantee is preserved; nibli-host/nibli-pipeline never install a flag.
785    pub fn set_cancel_flag(&self, flag: std::sync::Arc<std::sync::atomic::AtomicBool>) {
786        self.inner.borrow_mut().cancel = Some(flag);
787    }
788
789    /// Remove any installed cancellation flag (queries run unbounded again).
790    pub fn clear_cancel_flag(&self) {
791        self.inner.borrow_mut().cancel = None;
792    }
793
794    /// Enable/disable informational stdout diagnostics (`[Rule]`/`[Skolem]`/
795    /// `[Constraint] Registered`). Default OFF — a silent library; the
796    /// server/validate/tavla stay quiet. nibli-pipeline (the nibli-host REPL) and the native
797    /// `nibli` REPL opt in. Configuration, not derived state — survives `reset()`.
798    pub fn set_verbose(&self, verbose: bool) {
799        self.inner.borrow_mut().verbose = verbose;
800    }
801
802    /// Whether diagnostic verbosity is enabled.
803    pub fn is_verbose(&self) -> bool {
804        self.inner.borrow().verbose
805    }
806
807    /// Enable/disable STRICT MODE (default OFF — permissive warn-and-insert,
808    /// the documented v1 behavior). When on, an arity mismatch or an
809    /// integrity-constraint violation REJECTS the offending fact and fails the
810    /// assertion (`Err`) ATOMICALLY — the failed assertion's partial mutations
811    /// are rolled back via the registry rebuild, exactly like any other
812    /// assertion error. Facts inserted internally (forward chaining, compute
813    /// auto-assert) are also rejected loudly but cannot fail a user call.
814    /// Configuration, not derived state — survives `reset()`; inert during
815    /// retraction-replay rebuilds.
816    pub fn set_strict(&self, strict: bool) {
817        self.inner.borrow_mut().strict = strict;
818    }
819
820    /// Whether strict mode is enabled.
821    pub fn is_strict(&self) -> bool {
822        self.inner.borrow().strict
823    }
824
825    /// Enable/disable EXISTENTIAL-IMPORT MODE (default ON — the v0.1 xorlo
826    /// behavior, kept byte-identical). When on, a description universal
827    /// (`animal(every dog).`) mints a presupposition witness so `∃x. dog(x)`
828    /// holds. Set OFF for the clean-core profile (`some` = plain classical ∃,
829    /// no phantom entity injected — NIBLI_KR §14.4 item 3). Configuration, not
830    /// derived state — survives `reset()`.
831    pub fn set_existential_import(&self, on: bool) {
832        self.inner.borrow_mut().existential_import = on;
833    }
834
835    /// Whether existential-import (xorlo witness minting) is enabled.
836    pub fn is_existential_import(&self) -> bool {
837        self.inner.borrow().existential_import
838    }
839
840    /// Enable/disable STRATUM-ORDERED MATERIALISATION (default ON — see
841    /// [`crate::materialize`]). When on, the relations a query reads under `~` are
842    /// saturated bottom-up in stratum order before the query runs, and each NAF check
843    /// becomes a set-membership test instead of an exhaustive proof attempt. When off,
844    /// every NAF takes the backward-chaining path — byte-identical to the pre-2026-07-31
845    /// engine, which is what the ON/OFF differential in `nibli-verify` compares against.
846    ///
847    /// Configuration, not derived state — survives `reset()`. Turning it OFF drops any
848    /// existing saturation immediately, so the switch takes effect on the next query
849    /// rather than at the next mutation.
850    pub fn set_materialization(&self, on: bool) {
851        let mut inner = self.inner.borrow_mut();
852        inner.materialization = on;
853        *inner.materialized.borrow_mut() = None;
854    }
855
856    /// Whether stratum-ordered materialisation is enabled.
857    pub fn is_materialization(&self) -> bool {
858        self.inner.borrow().materialization
859    }
860
861    /// What the last query's saturation actually covered: `(completed relations, why
862    /// each refused relation was not)`, both sorted for reproducible output.
863    ///
864    /// This exists because the optimisation is INVISIBLE when it fails. A knowledge base
865    /// whose `~p(x)` still takes seconds has no other way to learn that `p` fell out of
866    /// the materialisable fragment, or which of its dependencies did. Empty until a
867    /// query has run (the saturation is built lazily) and after any mutation.
868    pub fn materialization_report(&self) -> (Vec<String>, Vec<(String, String)>) {
869        let inner = self.inner.borrow();
870        let m = inner.materialized.borrow();
871        let Some(m) = m.as_ref() else {
872            return (Vec::new(), Vec::new());
873        };
874        let mut complete: Vec<String> = m.complete.iter().cloned().collect();
875        complete.sort();
876        let mut refused: Vec<(String, String)> = m
877            .refused
878            .iter()
879            .filter(|(rel, _)| !m.complete.contains(*rel))
880            .map(|(rel, why)| (rel.clone(), why.reason()))
881            .collect();
882        refused.sort();
883        (complete, refused)
884    }
885
886    /// The KB's STRATIFICATION as machine-readable data: every predicate with its
887    /// stratum, whether it is base (EDB) or derived (IDB), and its outgoing dependency
888    /// edges marked positive or negative.
889    ///
890    /// Exists so a consuming project does not have to re-implement the stratifier to
891    /// read it. A second implementation — a regex over `.nibli` text, say — is a second
892    /// thing to keep in sync with this one, and it will drift; anything presented as
893    /// *"this order was derived by the engine"* has to come from the engine that
894    /// enforces it. Read-only and verdict-inert: it reports `pred_dep_graph`, which
895    /// `register_rule` already maintains and `check_stratification` already gates.
896    ///
897    /// **Surface projection.** The graph the engine stratifies is keyed on
898    /// event-decomposed relation names — the anchor `false` alongside its role
899    /// predicates `false_x1`, `false_x2`. Those are one atom, so they always carry
900    /// identical dependency sets and therefore always land in the same stratum
901    /// (pinned by `strata_surface_projection_is_lossless`). The report collapses each
902    /// role onto its anchor, because that is the name a KB author wrote and the only
903    /// name a reader can check. A self-edge that survives the collapse is GENUINE
904    /// recursion, not a decomposition artifact: a rule never reads the roles of its own
905    /// conclusion, so `p -> p_x1` edges do not exist to begin with.
906    ///
907    /// Deterministic by construction: rows sorted by predicate, edges sorted, duplicates
908    /// (four raw edges collapsing onto one surface edge) removed — safe to diff across
909    /// runs.
910    pub fn stratification_report(&self) -> Vec<StratumRow> {
911        use std::collections::{BTreeMap, BTreeSet};
912
913        let inner = self.inner.borrow();
914        let strata = materialize::compute_strata(&inner.pred_dep_graph);
915
916        // Anything a rule concludes is DERIVED, whatever else is true of it. Keyed on the
917        // raw conclusion relation, so project it the same way as the nodes.
918        let derived: BTreeSet<&str> = inner
919            .universal_rules
920            .keys()
921            .map(|k| materialize::surface_relation(k))
922            .collect();
923
924        let mut level: BTreeMap<&str, usize> = BTreeMap::new();
925        for (raw, lvl) in &strata {
926            let surface = materialize::surface_relation(raw);
927            // `max` is defensive only — see the lossless-projection pin above.
928            let slot = level.entry(surface).or_insert(*lvl);
929            *slot = (*slot).max(*lvl);
930        }
931        // `pred_dep_graph`'s keys are a STRICT SUBSET of the rule heads: a conditionless
932        // rule pushes no edges, so its head never becomes a node. Such a head is still a
933        // derived predicate and must appear, at stratum 0 — it depends on nothing, so
934        // nothing can raise it. Omitting it would drop a whole predicate from a dump whose
935        // purpose is to be complete.
936        for head in derived.iter() {
937            level.entry(head).or_insert(0);
938        }
939
940        let mut edges: BTreeMap<&str, BTreeSet<(&str, bool)>> = BTreeMap::new();
941        for (head, deps) in &inner.pred_dep_graph {
942            let h = materialize::surface_relation(head);
943            let bucket = edges.entry(h).or_default();
944            for (dep, is_neg) in deps {
945                bucket.insert((materialize::surface_relation(dep), *is_neg));
946            }
947        }
948
949        level
950            .into_iter()
951            .map(|(predicate, stratum)| StratumRow {
952                stratum,
953                base: !derived.contains(predicate),
954                edges: edges
955                    .get(predicate)
956                    .map(|s| {
957                        s.iter()
958                            .map(|(to, negative)| StratumEdge {
959                                to: (*to).to_string(),
960                                negative: *negative,
961                            })
962                            .collect()
963                    })
964                    .unwrap_or_default(),
965                predicate: predicate.to_string(),
966            })
967            .collect()
968    }
969
970    /// Declare `relation` DERIVED-ONLY (intensional / IDB): thereafter it may be
971    /// concluded by a rule but never asserted directly — a direct ground
972    /// assertion is rejected and the whole assertion unwinds atomically.
973    ///
974    /// The KB-level spelling is `derived_only("<relation>").`, which routes here;
975    /// this is the programmatic twin. Declaring is IDEMPOTENT and one-way within
976    /// a session: there is deliberately no `undeclare`, since a relation that
977    /// could be re-opened at runtime would give back exactly the capability the
978    /// declaration exists to remove. Reopen it by editing the KB.
979    ///
980    /// Declaring does NOT retroactively remove facts already asserted, and it is
981    /// a DECLARATION, not derived state — it survives `reset()` and retraction
982    /// replay.
983    pub fn declare_derived(&self, relation: &str) {
984        self.inner
985            .borrow_mut()
986            .derived_only
987            .insert(relation.to_string());
988    }
989
990    /// Declare `relation` ADMITTED base vocabulary. The FIRST such declaration
991    /// CLOSES this knowledge base's vocabulary: thereafter a ground assertion of
992    /// any relation not admitted is rejected, atomically, the way `derived_only`
993    /// rejects. While nothing has been declared the KB is OPEN, which is the
994    /// default and what every v0.1 knowledge base gets.
995    ///
996    /// The KB-level spelling is `admits("<relation>").`; this is the programmatic
997    /// twin. It is the DUAL of [`Self::declare_derived`] — that one says a relation
998    /// may not be asserted, this one says which relations may — and the pair
999    /// together is what lets a document claim its record has exactly these entries
1000    /// and have the engine hold it to that.
1001    ///
1002    /// ORDER IS LOAD-BEARING and enforced: the whole admits block must precede
1003    /// every ordinary assertion, because a declaration that arrives later would
1004    /// silently grandfather everything above it. Declaring is idempotent and
1005    /// one-way within a session, for the same reason `declare_derived` is: a
1006    /// vocabulary that could be re-opened at runtime gives back exactly the
1007    /// capability the declaration exists to remove.
1008    pub fn declare_admitted(&self, relation: &str) {
1009        self.inner
1010            .borrow_mut()
1011            .admitted
1012            .insert(relation.to_string());
1013    }
1014
1015    /// Whether `relation` is admitted base vocabulary. Note an OPEN knowledge base
1016    /// (nothing declared) returns `false` for everything while still admitting
1017    /// everything — ask [`Self::vocabulary_is_closed`] first.
1018    pub fn is_admitted(&self, relation: &str) -> bool {
1019        self.inner.borrow().admitted.contains(relation)
1020    }
1021
1022    /// Whether this KB has closed its vocabulary at all.
1023    pub fn vocabulary_is_closed(&self) -> bool {
1024        !self.inner.borrow().admitted.is_empty()
1025    }
1026
1027    /// The admitted base vocabulary, sorted. Empty when the KB is open.
1028    pub fn admitted_relations(&self) -> Vec<String> {
1029        let mut v: Vec<String> = self.inner.borrow().admitted.iter().cloned().collect();
1030        v.sort();
1031        v
1032    }
1033
1034    /// Whether `relation` is declared derived-only.
1035    pub fn is_derived_only(&self, relation: &str) -> bool {
1036        self.inner.borrow().derived_only.contains(relation)
1037    }
1038
1039    /// Every relation declared derived-only, sorted — the KB's closure list, for
1040    /// tests and for surfaces that want to show it.
1041    pub fn derived_only_relations(&self) -> Vec<String> {
1042        let mut v: Vec<String> = self.inner.borrow().derived_only.iter().cloned().collect();
1043        v.sort();
1044        v
1045    }
1046
1047    /// Register this KB's external compute dispatch (per-instance — replaces the
1048    /// old thread-local `register_compute_dispatch`, which the multithreaded
1049    /// server could never register because each tokio blocking-pool worker had
1050    /// its own `None` thread-local). Built-in arithmetic (pilji/sumji/dilcu) is
1051    /// always evaluated locally; everything else is forwarded to `eval`/
1052    /// `batch_eval`.
1053    ///
1054    /// TRUST BOUNDARY: a `true` reply is auto-asserted as a ground fact mid-query
1055    /// that downstream universal rules can chain on, so a malicious or MITM
1056    /// backend can seed arbitrary predicates. The backend is part of the trusted
1057    /// computing base — run it on localhost or a network segment you control.
1058    /// (Auto-asserted compute facts are non-durable: no FactRecord, never
1059    /// replayed by rebuild.)
1060    pub fn set_compute_dispatch(
1061        &self,
1062        eval: crate::compute::EvalFn,
1063        batch_eval: crate::compute::BatchEvalFn,
1064    ) {
1065        let mut inner = self.inner.borrow_mut();
1066        inner.compute_eval = Some(eval);
1067        inner.compute_batch_eval = Some(batch_eval);
1068    }
1069
1070    /// Assert a compiled FOL formula into the knowledge base. Returns the fact ID.
1071    pub fn assert_fact(&self, logic: LogicBuffer, label: String) -> Result<u64, NibliError> {
1072        // The assert IS the reasoning stage: by the time this runs the buffer has
1073        // already passed nibli-semantics, so every failure here (stratification, fail-closed
1074        // rule compilation, the zero-ingest guard, rebuild replay) is reasoning-layer.
1075        // The layer contract is Syntax=nibli-kr / Semantic=nibli-semantics / Reasoning=nibli-reason.
1076        self.assert_fact_inner(logic, label)
1077            .map_err(NibliError::Reasoning)
1078    }
1079
1080    /// Run a query under temporary assumptions without mutating the real KB.
1081    /// Clones the KB, asserts all assumptions into the clone, runs the callback,
1082    /// and discards the clone. The original KB is untouched.
1083    ///
1084    /// Supports multiple independent hypotheticals (each gets its own snapshot)
1085    /// and nesting (the callback receives a `&KnowledgeBase` with `with_assumptions`).
1086    pub fn with_assumptions<F, R>(&self, assumptions: &[LogicBuffer], f: F) -> Result<R, NibliError>
1087    where
1088        F: FnOnce(&KnowledgeBase) -> R,
1089    {
1090        let snapshot = self.inner.borrow().clone();
1091        let temp_kb = KnowledgeBase {
1092            inner: RefCell::new(snapshot),
1093        };
1094        for buf in assumptions {
1095            temp_kb.assert_fact(buf.clone(), "assumption".into())?;
1096        }
1097        Ok(f(&temp_kb))
1098    }
1099
1100    /// Register an integrity constraint: a set of facts that must NOT all hold simultaneously.
1101    /// Checked after every fact insertion (permissive mode: warns on violation).
1102    pub fn register_constraint(&self, label: String, conjuncts: Vec<kb::StoredFact>) {
1103        let predicates: Vec<String> = conjuncts.iter().map(|c| c.relation().to_string()).collect();
1104        let mut inner = self.inner.borrow_mut();
1105        inner.integrity_constraints.push(kb::IntegrityConstraint {
1106            label,
1107            conjuncts,
1108            predicates,
1109        });
1110    }
1111
1112    /// Check whether a formula is entailed by the knowledge base (four-valued result).
1113    pub fn query_entailment(&self, logic: LogicBuffer) -> Result<QueryResult, NibliError> {
1114        self.query_entailment_inner(logic)
1115            .map_err(NibliError::Reasoning)
1116    }
1117
1118    /// Find all satisfying witness binding sets for existential variables in the formula.
1119    pub fn query_find(&self, logic: LogicBuffer) -> Result<Vec<Vec<WitnessBinding>>, NibliError> {
1120        self.query_find_inner(logic).map_err(NibliError::Reasoning)
1121    }
1122
1123    /// Count the number of distinct witness binding sets satisfying the formula.
1124    pub fn count_witnesses(&self, logic: LogicBuffer) -> Result<usize, NibliError> {
1125        self.query_find(logic).map(|bindings| bindings.len())
1126    }
1127
1128    /// Aggregate numeric values of a named variable across all witness binding sets.
1129    /// Returns `None` if no numeric witnesses found for the variable.
1130    pub fn aggregate(
1131        &self,
1132        logic: LogicBuffer,
1133        variable: &str,
1134        op: nibli_types::logic::AggregateOp,
1135    ) -> Result<Option<f64>, NibliError> {
1136        let bindings = self.query_find(logic)?;
1137        let values: Vec<f64> = bindings
1138            .iter()
1139            .filter_map(|binding_set| {
1140                binding_set
1141                    .iter()
1142                    .find(|b| b.variable == variable)
1143                    .and_then(|b| match &b.term {
1144                        LogicalTerm::Number(n) => Some(*n),
1145                        _ => None,
1146                    })
1147            })
1148            .collect();
1149        if values.is_empty() {
1150            return Ok(None);
1151        }
1152        use nibli_types::logic::AggregateOp;
1153        let result = match op {
1154            AggregateOp::Sum => values.iter().sum(),
1155            AggregateOp::Min => values.iter().cloned().reduce(f64::min).unwrap_or(0.0),
1156            AggregateOp::Max => values.iter().cloned().reduce(f64::max).unwrap_or(0.0),
1157            AggregateOp::Avg => values.iter().sum::<f64>() / values.len() as f64,
1158        };
1159        Ok(Some(result))
1160    }
1161
1162    /// Check entailment and return a proof trace showing the full derivation chain.
1163    pub fn query_entailment_with_proof(
1164        &self,
1165        logic: LogicBuffer,
1166    ) -> Result<(QueryResult, ProofTrace), NibliError> {
1167        self.query_entailment_with_proof_inner(logic)
1168            .map_err(NibliError::Reasoning)
1169    }
1170
1171    /// Clear all facts, rules, indexes, and derived state.
1172    pub fn reset(&self) -> Result<(), NibliError> {
1173        let mut inner = self.inner.borrow_mut();
1174        inner.reset();
1175        invalidate_pred_cache(&inner); // Tabling: KB cleared.
1176        Ok(())
1177    }
1178
1179    /// Retract a fact by ID. Uses incremental removal for ground facts,
1180    /// full rebuild for facts that compiled into rules.
1181    pub fn retract_fact(&self, id: u64) -> Result<(), NibliError> {
1182        self.retract_fact_inner(id).map_err(NibliError::Reasoning)
1183    }
1184
1185    /// List all active (non-retracted) facts with their IDs and labels.
1186    pub fn list_facts(&self) -> Result<Vec<FactSummary>, NibliError> {
1187        self.list_facts_inner().map_err(NibliError::Reasoning)
1188    }
1189
1190    /// Mark all rules concluding the given predicate as forward-chaining enabled.
1191    /// Forward-enabled rules fire eagerly on fact assertion when all conditions
1192    /// are directly asserted in the fact store.
1193    ///
1194    /// FAIL CLOSED: a rule with a negation-as-failure condition (a flat negated
1195    /// condition or a `poi na <predicate>` group) is NOT forward-enabled — it stays
1196    /// backward-only, where it is sound (backward chaining re-evaluates `¬Q` at
1197    /// query time). Forward chaining + NAF has no truth maintenance: a
1198    /// forward-derived conclusion would never be retracted when a later assertion
1199    /// makes the negated dependency true. Positive (negation-free) rules enable
1200    /// normally; `forward = false` (disabling) always applies.
1201    pub fn set_rule_forward(&self, conclusion_predicate: &str, forward: bool) {
1202        let mut inner = self.inner.borrow_mut();
1203        let rebuilding = inner.rebuilding;
1204        if let Some(rules) = inner.universal_rules.get_mut(conclusion_predicate) {
1205            for rule in rules.iter_mut() {
1206                if forward
1207                    && (!rule.negated_condition_indices.is_empty()
1208                        || !rule.negated_exists_groups.is_empty())
1209                {
1210                    if !rebuilding {
1211                        eprintln!(
1212                            "[Forward] rule '{}' has a negation-as-failure condition; \
1213                             keeping it backward-only (forward chaining + NAF has no \
1214                             truth maintenance).",
1215                            rule.label
1216                        );
1217                    }
1218                    continue;
1219                }
1220                // Arc::get_mut only succeeds if there's one strong reference.
1221                // If shared, clone-on-write.
1222                if let Some(r) = Arc::get_mut(rule) {
1223                    r.forward = forward;
1224                } else {
1225                    let mut cloned = (**rule).clone();
1226                    cloned.forward = forward;
1227                    *rule = Arc::new(cloned);
1228                }
1229            }
1230        }
1231    }
1232
1233    /// Set priority for all rules concluding the given predicate.
1234    /// Higher priority = tried first during backward/forward chaining.
1235    /// Default is 0. Rules with higher priority override lower-priority ones
1236    /// (defeasible reasoning / exception hierarchies).
1237    pub fn set_rule_priority(&self, conclusion_predicate: &str, priority: u32) {
1238        let mut inner = self.inner.borrow_mut();
1239        if let Some(rules) = inner.universal_rules.get_mut(conclusion_predicate) {
1240            for rule in rules.iter_mut() {
1241                if let Some(r) = Arc::get_mut(rule) {
1242                    r.priority = priority;
1243                } else {
1244                    let mut cloned = (**rule).clone();
1245                    cloned.priority = priority;
1246                    *rule = Arc::new(cloned);
1247                }
1248            }
1249            // Re-establish the descending-priority order the backward-chain read
1250            // path relies on (`matching_rules_typed` borrows the bucket as-is).
1251            sort_rule_bucket(rules);
1252        }
1253    }
1254
1255    /// Declare that an entity belongs to a sort.
1256    /// e.g., `declare_entity_sort("adam", "person")` means adam is a person.
1257    pub fn declare_entity_sort(&self, entity: &str, sort: &str) {
1258        let mut inner = self.inner.borrow_mut();
1259        inner
1260            .entity_sorts
1261            .insert(entity.to_string(), sort.to_string());
1262    }
1263
1264    /// Declare a subsort relationship: child ⊂ parent.
1265    /// e.g., `declare_subsort("person", "animal")` means every person is an animal.
1266    /// Transitive: if person ⊂ animal and animal ⊂ entity, then person is compatible with entity.
1267    pub fn declare_subsort(&self, child: &str, parent: &str) {
1268        let mut inner = self.inner.borrow_mut();
1269        inner
1270            .sort_hierarchy
1271            .entry(child.to_string())
1272            .or_default()
1273            .insert(parent.to_string());
1274    }
1275
1276    /// Set expected sorts for a predicate's arguments.
1277    /// e.g., `set_predicate_sorts("gerku", vec!["animal", ""])` means gerku's x1 must be
1278    /// an "animal" sort, x2 has no sort constraint.
1279    /// Empty string = no constraint for that position.
1280    pub fn set_predicate_sorts(&self, predicate: &str, arg_sorts: Vec<String>) {
1281        let mut inner = self.inner.borrow_mut();
1282        if let Some(sig) = inner.predicate_registry.get_mut(predicate) {
1283            sig.arg_sorts = arg_sorts;
1284        } else {
1285            inner.predicate_registry.insert(
1286                predicate.to_string(),
1287                PredicateSignature {
1288                    arity: arg_sorts.len(),
1289                    source: SignatureSource::Inferred,
1290                    arg_sorts,
1291                },
1292            );
1293        }
1294    }
1295
1296    /// Enable tracing for a predicate. When the predicate is encountered
1297    /// during backward chaining, diagnostic output is printed showing
1298    /// depth, rule matches, and results.
1299    pub fn trace_predicate(&self, predicate: &str) {
1300        self.inner
1301            .borrow_mut()
1302            .traced_predicates
1303            .insert(predicate.to_string());
1304    }
1305
1306    /// Disable tracing for a predicate.
1307    pub fn untrace_predicate(&self, predicate: &str) {
1308        self.inner.borrow_mut().traced_predicates.remove(predicate);
1309    }
1310
1311    /// List all currently traced predicates.
1312    pub fn traced_predicates(&self) -> Vec<String> {
1313        self.inner
1314            .borrow()
1315            .traced_predicates
1316            .iter()
1317            .cloned()
1318            .collect()
1319    }
1320
1321    /// Scan the KB for contradictions. Returns human-readable descriptions.
1322    ///
1323    /// **Category 4 (negation)** uses a two-tier check: (a) store membership of
1324    /// the positive counterpart (asserted facts), then (b) a *cheap middle* —
1325    /// after dropping the inner borrow, each unmatched asserted `~P` is re-run
1326    /// as a positive entailment query, so a **rule-derived** positive also
1327    /// flags (e.g. `travel(every person where ~prisoner)` + `person(Kilo)` +
1328    /// `~travel(Kilo)`). This is not full closure consistency (integrity §1/§6
1329    /// and disjunctive antecedents stay store-bound by design — re-entrancy /
1330    /// false-flag conservatism; see
1331    /// `test_mixed_conclusion_conservative_p_check_misses_derived_antecedent`).
1332    /// Vampire/clingo remain the fragment-level closure oracles.
1333    ///
1334    /// Checks:
1335    /// 1. Integrity constraint violations (conjuncts that all hold in the store)
1336    /// 2. Predicate arity inconsistencies across asserted facts
1337    /// 3. Equality-expanded integrity violations (`equals` / du union-find)
1338    /// 4. Negation contradictions — asserted `~P` whose positive holds in the
1339    ///    store **or** is derivable via backward chaining
1340    /// 5. Inequality contradictions (`~equals(X,Y)` vs union-find equivalence)
1341    /// 6. Disjunctive-conclusion constraints — antecedent P by store membership
1342    ///    only (conservative miss on derived P)
1343    pub fn check_contradictions(&self) -> Vec<String> {
1344        let mut violations = Vec::new();
1345        // Negative groups that fail the store-membership leg of §4 — re-checked
1346        // via query after the borrow ends (cheap middle for derived positives).
1347        let mut derived_negation_candidates: Vec<Vec<StoredFact>> = Vec::new();
1348
1349        let inner = self.inner.borrow();
1350
1351        // 1. Check integrity constraints.
1352        for constraint in &inner.integrity_constraints {
1353            let all_hold = constraint
1354                .conjuncts
1355                .iter()
1356                .all(|c| inner.fact_store.contains(c));
1357            if all_hold {
1358                let facts: Vec<String> = constraint
1359                    .conjuncts
1360                    .iter()
1361                    .map(|c| c.to_display_string())
1362                    .collect();
1363                violations.push(format!(
1364                    "Integrity violation '{}': {} all hold",
1365                    constraint.label,
1366                    facts.join(" ∧ ")
1367                ));
1368            }
1369        }
1370
1371        // 2. Check predicate arity consistency across the fact store.
1372        // The predicate registry tracks first-seen arity. Scan all facts for mismatches.
1373        let mut arity_map: HashMap<String, usize> = HashMap::new();
1374        for fact in inner.fact_store.all_facts() {
1375            let rel = fact.relation().to_string();
1376            let arity = fact.inner().args.len();
1377            match arity_map.get(&rel) {
1378                Some(&expected) if expected != arity => {
1379                    violations.push(format!(
1380                        "Arity inconsistency: '{}' has facts with {} and {} arguments",
1381                        rel, expected, arity
1382                    ));
1383                }
1384                None => {
1385                    arity_map.insert(rel, arity);
1386                }
1387                _ => {}
1388            }
1389        }
1390
1391        // 3. Check equality-induced constraint violations.
1392        // If du(a,b) and a constraint says "deny P(a) ∧ Q(a)", but P(a) and Q(b) are
1393        // asserted (which means Q(a) holds via equivalence), flag it.
1394        if !inner.equivalence_parent.is_empty() && !inner.integrity_constraints.is_empty() {
1395            for constraint in &inner.integrity_constraints {
1396                // For each conjunct, expand by equivalence class and check all combos.
1397                let expanded: Vec<Vec<StoredFact>> = constraint
1398                    .conjuncts
1399                    .iter()
1400                    .map(|c| {
1401                        let gf = c.inner();
1402                        let equiv_args: Vec<Vec<GroundTerm>> = gf
1403                            .args
1404                            .iter()
1405                            .map(|arg| {
1406                                get_equivalence_class_readonly(
1407                                    &inner.equivalence_parent,
1408                                    &inner.equivalence_classes,
1409                                    arg,
1410                                )
1411                            })
1412                            .collect();
1413                        // Generate all argument combinations.
1414                        let mut variants = Vec::new();
1415                        fn cartesian(
1416                            sets: &[Vec<GroundTerm>],
1417                            idx: usize,
1418                            current: &mut Vec<GroundTerm>,
1419                            out: &mut Vec<Vec<GroundTerm>>,
1420                        ) {
1421                            if idx == sets.len() {
1422                                out.push(current.clone());
1423                                return;
1424                            }
1425                            for val in &sets[idx] {
1426                                current.push(val.clone());
1427                                cartesian(sets, idx + 1, current, out);
1428                                current.pop();
1429                            }
1430                        }
1431                        let mut buf = Vec::new();
1432                        cartesian(&equiv_args, 0, &mut buf, &mut variants);
1433                        variants
1434                            .into_iter()
1435                            .map(|args| {
1436                                StoredFact::with_tense_from(
1437                                    GroundFact::new(gf.relation.clone(), args),
1438                                    c,
1439                                )
1440                            })
1441                            .collect()
1442                    })
1443                    .collect();
1444
1445                // Check if any combination of expanded conjuncts all hold.
1446                fn check_combos(
1447                    expanded: &[Vec<StoredFact>],
1448                    idx: usize,
1449                    store: &dyn crate::fact_store::FactStore,
1450                ) -> bool {
1451                    if idx == expanded.len() {
1452                        return true; // All conjuncts satisfied.
1453                    }
1454                    expanded[idx].iter().any(|variant| {
1455                        store.contains(variant) && check_combos(expanded, idx + 1, store)
1456                    })
1457                }
1458
1459                if check_combos(&expanded, 0, &*inner.fact_store) {
1460                    let facts: Vec<String> = constraint
1461                        .conjuncts
1462                        .iter()
1463                        .map(|c| c.to_display_string())
1464                        .collect();
1465                    let msg = format!(
1466                        "Equality-expanded integrity violation '{}': {} (via du equivalence)",
1467                        constraint.label,
1468                        facts.join(" ∧ ")
1469                    );
1470                    if !violations.contains(&msg) {
1471                        violations.push(msg);
1472                    }
1473                }
1474            }
1475        }
1476
1477        // 4. Explicitly asserted negative facts (`na <predicate>`) whose positive
1478        //    counterpart holds. Each negation is a template group with event
1479        //    arguments generalized to pattern variables (see
1480        //    `record_negative_ground_fact`). Leg (a): one consistent binding
1481        //    satisfies EVERY template against the **asserted** fact store
1482        //    (whole-group requirement prevents false positives from unrelated
1483        //    events sharing a predicate). Leg (b): after this borrow ends, groups
1484        //    that miss the store are re-checked via `query_entailment` so a
1485        //    **derived** positive also flags. Flat `du` inequalities go to §5.
1486        //    Query semantics (NAF/CWA) are unaffected — negatives never enter
1487        //    the positive store.
1488        fn flat_equals_pair(group: &[StoredFact]) -> Option<(&GroundTerm, &GroundTerm)> {
1489            if group.len() == 1 {
1490                if let StoredFact::Bare(gf) = &group[0] {
1491                    if gf.relation == "equals" && gf.args.len() == 2 {
1492                        return Some((&gf.args[0], &gf.args[1]));
1493                    }
1494                }
1495            }
1496            None
1497        }
1498
1499        for group in &inner.negative_facts {
1500            if flat_equals_pair(group).is_some() {
1501                continue;
1502            }
1503            if negative_group_holds(group, &*inner.fact_store) {
1504                let facts: Vec<String> = group.iter().map(|f| f.to_display_string()).collect();
1505                let msg = format!(
1506                    "Negation contradiction: ¬({}) was asserted, but the positive \
1507                     counterpart is also asserted",
1508                    facts.join(" ∧ ")
1509                );
1510                if !violations.contains(&msg) {
1511                    violations.push(msg);
1512                }
1513            } else {
1514                // Cheap middle: try derivation after the borrow drops.
1515                derived_negation_candidates.push(group.clone());
1516            }
1517        }
1518
1519        // 5. Asserted inequalities (`na du`). A flat `na du(X, Y)` is contradicted
1520        //    when X and Y are equivalent under the du union-find — catching both
1521        //    a directly-asserted `du(X, Y)` and transitive equality
1522        //    (`du(X, Z) ∧ du(Z, Y)`) that a store-membership check would miss.
1523        //    (Reflexive `na du(a, a)` is correctly always a contradiction.)
1524        for group in &inner.negative_facts {
1525            if let Some((x, y)) = flat_equals_pair(group) {
1526                let rx = find_canonical_readonly(&inner.equivalence_parent, x);
1527                let ry = find_canonical_readonly(&inner.equivalence_parent, y);
1528                if rx == ry {
1529                    let msg = format!(
1530                        "Inequality contradiction: ¬({}) was asserted, but the terms are \
1531                         equivalent under du",
1532                        group[0].to_display_string()
1533                    );
1534                    if !violations.contains(&msg) {
1535                        violations.push(msg);
1536                    }
1537                }
1538            }
1539        }
1540
1541        // 6. Disjunctive-conclusion constraints `¬(P ∧ ¬Q ∧ ¬R)` (from a rule with a
1542        //    disjunctive head, `ro lo X cu Q ja R`). Flag a contradiction when, for some
1543        //    binding, ALL P-conditions hold in the positive store AND EVERY disjunct is
1544        //    explicitly denied (a stored `na <predicate>` covers it). A disjunct is never
1545        //    DERIVED (unsound in a Horn engine — `R` might hold instead); the positive
1546        //    use is served by a disjunctive QUERY. P uses store-membership only (via
1547        //    `solve_group_bindings` over `fact_store`): a rule-DERIVED P does NOT trigger
1548        //    this — sound + conservative (it can only MISS a contradiction, never falsely
1549        //    flag one). The check holds `self.inner.borrow()` and stays store-bound by
1550        //    design (re-entering the query engine here would be a borrow / re-entrancy
1551        //    hazard). Pinned by
1552        //    `test_mixed_conclusion_conservative_p_check_misses_derived_antecedent`.
1553        for dc in &inner.disjunctive_constraints {
1554            let bindings = solve_group_bindings(&dc.conditions, &*inner.fact_store);
1555            let violated = bindings.iter().any(|b| {
1556                dc.disjuncts.iter().all(|disj| {
1557                    let substituted: Vec<StoredFact> =
1558                        disj.iter().map(|f| substitute_fact(f, b)).collect();
1559                    disjunct_explicitly_denied(&substituted, &inner.negative_facts)
1560                })
1561            });
1562            if violated {
1563                let msg = format!(
1564                    "Disjunctive constraint violated '{}': the antecedent holds but every \
1565                     disjunct is explicitly denied (na)",
1566                    dc.label
1567                );
1568                if !violations.contains(&msg) {
1569                    violations.push(msg);
1570                }
1571            }
1572        }
1573
1574        // Drop `inner` before re-entering the query engine (borrow / re-entrancy).
1575        drop(inner);
1576
1577        // 4b. Cheap middle: asserted `~P` vs *derivable* positive.
1578        for group in derived_negation_candidates {
1579            let Some(buf) = negative_group_to_query_buffer(&group) else {
1580                continue;
1581            };
1582            match self.query_entailment_inner(buf) {
1583                Ok(r) if r.is_true() => {
1584                    let facts: Vec<String> = group.iter().map(|f| f.to_display_string()).collect();
1585                    let msg = format!(
1586                        "Negation contradiction: ¬({}) was asserted, but the positive \
1587                         counterpart is derivable",
1588                        facts.join(" ∧ ")
1589                    );
1590                    if !violations.contains(&msg) {
1591                        violations.push(msg);
1592                    }
1593                }
1594                _ => {}
1595            }
1596        }
1597
1598        // Determinism: §2 (arity) iterates `all_facts()` and §4/§5 iterate the
1599        // `negative_facts` HashSet, so the violation order is otherwise
1600        // hasher-seed dependent. A single global sort fixes the order of every
1601        // section at once (ordering only — the SET of violations is unchanged).
1602        violations.sort();
1603        violations
1604    }
1605}
1606
1607/// Convert a negative-fact template group into a positive entailment query.
1608/// Pattern variables (generalized event Skolems) become existentially quantified
1609/// logic variables so a later contrary (or derived) positive with a different
1610/// event Skolem still matches — same intent as `negative_group_holds` over the store.
1611fn negative_group_to_query_buffer(group: &[StoredFact]) -> Option<LogicBuffer> {
1612    if group.is_empty() {
1613        return None;
1614    }
1615    fn ground_term_to_logical(t: &GroundTerm) -> LogicalTerm {
1616        match t {
1617            GroundTerm::Constant(s) => LogicalTerm::Constant(s.clone()),
1618            GroundTerm::Number(bits) => LogicalTerm::Number(f64::from_bits(*bits)),
1619            GroundTerm::Description(s) => LogicalTerm::Description(s.clone()),
1620            GroundTerm::Unspecified => LogicalTerm::Unspecified,
1621            GroundTerm::PatternVar(s) => LogicalTerm::Variable(s.clone()),
1622            // Dependent Skolems rarely appear in negative templates; treat as opaque constants.
1623            GroundTerm::SkolemFn(name, _) => LogicalTerm::Constant(name.clone()),
1624            GroundTerm::DepPair(_, _) => LogicalTerm::Unspecified,
1625        }
1626    }
1627
1628    let mut nodes: Vec<LogicNode> = Vec::new();
1629    let mut pattern_vars: Vec<String> = Vec::new();
1630    let mut leaf_ids: Vec<u32> = Vec::new();
1631
1632    for fact in group {
1633        let gf = fact.inner();
1634        for arg in &gf.args {
1635            if let GroundTerm::PatternVar(s) = arg {
1636                if !pattern_vars.iter().any(|v| v == s) {
1637                    pattern_vars.push(s.clone());
1638                }
1639            }
1640        }
1641        let args: Vec<LogicalTerm> = gf.args.iter().map(ground_term_to_logical).collect();
1642        let pred_id = nodes.len() as u32;
1643        nodes.push(LogicNode::Predicate((gf.relation.clone(), args)));
1644        let wrapped = match fact {
1645            StoredFact::Bare(_) => pred_id,
1646            StoredFact::Past(_) => {
1647                let id = nodes.len() as u32;
1648                nodes.push(LogicNode::PastNode(pred_id));
1649                id
1650            }
1651            StoredFact::Present(_) => {
1652                let id = nodes.len() as u32;
1653                nodes.push(LogicNode::PresentNode(pred_id));
1654                id
1655            }
1656            StoredFact::Future(_) => {
1657                let id = nodes.len() as u32;
1658                nodes.push(LogicNode::FutureNode(pred_id));
1659                id
1660            }
1661            StoredFact::Obligatory(_) => {
1662                let id = nodes.len() as u32;
1663                nodes.push(LogicNode::ObligatoryNode(pred_id));
1664                id
1665            }
1666            StoredFact::Permitted(_) => {
1667                let id = nodes.len() as u32;
1668                nodes.push(LogicNode::PermittedNode(pred_id));
1669                id
1670            }
1671        };
1672        leaf_ids.push(wrapped);
1673    }
1674
1675    let mut root = leaf_ids[0];
1676    for &id in &leaf_ids[1..] {
1677        let and_id = nodes.len() as u32;
1678        nodes.push(LogicNode::AndNode((root, id)));
1679        root = and_id;
1680    }
1681    // Outermost ∃ for each pattern var (event slots) so free variables are bound.
1682    for pvar in pattern_vars.into_iter().rev() {
1683        let ex_id = nodes.len() as u32;
1684        nodes.push(LogicNode::ExistsNode((pvar, root)));
1685        root = ex_id;
1686    }
1687
1688    Some(LogicBuffer {
1689        nodes,
1690        roots: vec![root],
1691    })
1692}
1693
1694#[cfg(test)]
1695mod tests;