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(>),
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;