Skip to main content

nibli_engine/
lib.rs

1//! Native nibli engine library: calls nibli-kr/nibli-semantics/nibli-reason directly as Rust crates.
2//! No WASM, no Wasmtime — full stack traces for debugging.
3
4use std::cell::RefCell;
5use std::path::Path;
6
7use nibli_store::NibliStore;
8
9pub use nibli_reason::ComputeRequest as EngineComputeRequest;
10pub use nibli_types::logic::{
11    AggregateOp as EngineAggregateOp, FactSummary as EngineFactSummary,
12    LogicBuffer as EngineLogicBuffer, LogicNode as EngineLogicNode,
13    LogicalTerm as EngineLogicalTerm, QueryResult as EngineQueryResult,
14    ResourceKind as EngineResourceKind, UnknownReason as EngineUnknownReason,
15    WitnessBinding as EngineWitnessBinding,
16};
17
18/// The pipeline's typed error (`Syntax`/`Semantic`/`Reasoning`/`Backend`),
19/// re-exported so embedders, tests, and the server can pattern-match the error
20/// CLASS instead of string-parsing the `[Xxx Error]` Display prefix.
21pub use nibli_types::error::NibliError as EngineError;
22use nibli_types::logic;
23
24mod compute_client;
25
26// ═══════════════════════════════════════════════════════════════════════
27// PROOF TRACE CONVERSION
28// ═══════════════════════════════════════════════════════════════════════
29//
30// The canonical proof types ARE the wire types now (serde-derived in nibli-types),
31// so there is no canonical->wire conversion; `nibli-protocol` only supplies JSON
32// helpers. Readable rendering lives in `nibli-render`. Term display is an inherent
33// method on the canonical `LogicalTerm` enum.
34
35pub fn display_term(term: &EngineLogicalTerm) -> String {
36    term.trace_display()
37}
38
39pub fn display_query_result(result: &EngineQueryResult) -> String {
40    match result.detail_label() {
41        Some(detail) => format!("{} ({})", result.status_label(), detail),
42        None => result.status_label().to_string(),
43    }
44}
45
46// ═══════════════════════════════════════════════════════════════════════
47// ENGINE WRAPPER
48// ═══════════════════════════════════════════════════════════════════════
49
50pub struct NibliEngine {
51    /// The shared compile/assert/query core (nibli-session) — the same
52    /// CoreSession the pipeline/wasm/ui surfaces wrap, so native and WASM
53    /// agree BY CONSTRUCTION.
54    core: nibli_session::CoreSession,
55    store: RefCell<Option<NibliStore>>,
56}
57
58impl Default for NibliEngine {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl NibliEngine {
65    /// Access the underlying KnowledgeBase for sort/constraint declarations.
66    pub fn kb(&self) -> &nibli_reason::KnowledgeBase {
67        self.core.kb()
68    }
69
70    /// Install a cooperative cancellation flag on the underlying reasoning core.
71    /// When the flag is raised, an in-flight query aborts via the error channel
72    /// (returned as a `String` error from the query methods). The native
73    /// nibli-server watchdog uses this to free a blocking thread when a request's
74    /// wall-clock budget elapses, instead of letting a pathological query run to
75    /// completion. No clock is read inside the engine.
76    pub fn set_cancel_flag(&self, flag: std::sync::Arc<std::sync::atomic::AtomicBool>) {
77        self.core.kb().set_cancel_flag(flag);
78    }
79
80    /// Remove any installed cancellation flag.
81    pub fn clear_cancel_flag(&self) {
82        self.core.kb().clear_cancel_flag();
83    }
84
85    /// Enable/disable the engine's informational stdout diagnostics
86    /// (`[Rule]`/`[Skolem]`/`[Constraint] Registered`). Default OFF — nibli-engine
87    /// is a silent library, so the server/validate/tavla do not spam stdout on a
88    /// per-query corpus re-assertion. Interactive callers (the native `nibli`
89    /// REPL) opt in. Configuration — survives `reset()`.
90    pub fn set_verbose(&self, verbose: bool) {
91        self.core.kb().set_verbose(verbose);
92    }
93
94    /// Enable/disable STRICT MODE (default off — permissive warn-and-insert):
95    /// when on, an arity mismatch or integrity-constraint violation REJECTS the
96    /// offending fact and fails the assertion. Like `set_verbose`, the library
97    /// stays permissive by default; embedders opt in programmatically (the
98    /// runtime surfaces read `NIBLI_STRICT=1` — nibli-host forwards it into the
99    /// guest, where `nibli-pipeline::Session::new` applies it).
100    pub fn set_strict(&self, strict: bool) {
101        self.core.kb().set_strict(strict);
102    }
103
104    /// Enable/disable EXISTENTIAL-IMPORT MODE (default ON — the v0.1 xorlo
105    /// behavior). OFF gives the clean-core `some` = plain classical ∃ profile:
106    /// a description universal no longer mints a presupposition witness. The
107    /// runtime surfaces read `NIBLI_EXISTENTIAL_IMPORT=0` (nibli-host forwards it
108    /// into the guest, where `nibli-pipeline::Session::new` applies it).
109    pub fn set_existential_import(&self, on: bool) {
110        self.core.kb().set_existential_import(on);
111    }
112
113    /// Enable/disable STRATUM-ORDERED MATERIALISATION (default ON). When on, the
114    /// relations a query reads under `~` are saturated bottom-up in stratum order and
115    /// each NAF check becomes a set-membership test. OFF restores the pure
116    /// backward-chaining path. The runtime surfaces read `NIBLI_MATERIALIZE=0`
117    /// (nibli-host forwards it into the guest, where `nibli-pipeline::Session::new`
118    /// applies it).
119    pub fn set_materialization(&self, on: bool) {
120        self.core.kb().set_materialization(on);
121    }
122
123    /// What the last query's saturation covered: `(completed, [(relation, why not)])`.
124    /// The only way to see whether a slow `~p(x)` actually got the lookup.
125    pub fn materialization_report(&self) -> (Vec<String>, Vec<(String, String)>) {
126        self.core.kb().materialization_report()
127    }
128
129    /// Register this engine's external compute dispatch (per-instance). Without
130    /// it, external predicates (e.g. `tenfa`/`dugri`) return an error; built-in
131    /// arithmetic (pilji/sumji/dilcu) works regardless. Replaces the old
132    /// thread-local registration that the multithreaded server could not use.
133    /// See `nibli_reason::KnowledgeBase::set_compute_dispatch` for the trust boundary.
134    pub fn set_compute_dispatch(
135        &self,
136        eval: fn(&str, &[EngineLogicalTerm]) -> Result<bool, String>,
137        batch_eval: fn(&[nibli_reason::ComputeRequest]) -> Vec<Result<bool, String>>,
138    ) {
139        self.core.kb().set_compute_dispatch(eval, batch_eval);
140    }
141
142    /// Enable external compute dispatch to a Python-style JSON-Lines backend at
143    /// `addr` (e.g. `"127.0.0.1:5555"`). Wires the native TCP client as this
144    /// engine's compute dispatch, so registered external predicates (e.g.
145    /// `tenfa`/`dugri`) are evaluated by the backend; built-in arithmetic
146    /// (pilji/sumji/dilcu) is still resolved in-engine. Opt-in — engines that do
147    /// not call this leave external compute unregistered (`set_compute_dispatch`
148    /// isolation preserved). The address is stored per-thread; in the
149    /// multithreaded server each `spawn_blocking` worker connects lazily and
150    /// reuses its connection. Register the external predicate names separately
151    /// via `register_compute_predicate`. Trust boundary: the backend is a
152    /// plaintext, unauthenticated peer in the trusted computing base.
153    pub fn enable_compute_backend(&self, addr: &str) {
154        compute_client::set_addr(addr);
155        self.core.kb().set_compute_dispatch(
156            compute_client::native_eval_fn,
157            compute_client::native_batch_eval_fn,
158        );
159    }
160
161    /// Create an engine without persistence (existing behavior).
162    pub fn new() -> Self {
163        NibliEngine {
164            core: nibli_session::CoreSession::new(),
165            store: RefCell::new(None),
166        }
167    }
168
169    /// Create an engine with disk persistence at the given path.
170    /// Opens a `RedbFactStore` for typed fact persistence and replays
171    /// the legacy `NibliStore` (LogicBuffer-level) for backward compatibility.
172    pub fn open(db_path: &Path) -> Result<Self, String> {
173        let mut store = NibliStore::open(db_path, "local".to_string())
174            .map_err(|e| format!("Store error: {e}"))?;
175
176        // Upgrade a legacy v2 registry to v3. Engine-written DBs hold bare
177        // `LogicBuffer` payloads (never `StoredAssertion::Text`), so this is a
178        // version restamp only — NOT `migrate_v2_text_rows` (decoding a bare
179        // buffer as a `StoredAssertion` is a category error). Any genuinely
180        // undecodable row still fails closed in `replay_from_store` below.
181        if store.needs_migration() {
182            store
183                .finalize_v3()
184                .map_err(|e| format!("Store error: {e}"))?;
185        }
186
187        // Open persistent typed fact store alongside the legacy store.
188        let typed_db_path = db_path.with_extension("typed.redb");
189        let mut typed_store = nibli_store::typed_store::RedbFactStore::open(&typed_db_path)
190            .map_err(|e| format!("TypedStore error: {e}"))?;
191
192        // The fact REGISTRY (the store opened above) is the durable source of
193        // truth: retraction tombstones live there, and remote merges land
194        // there. The typed store is only the KB's write-through mirror — a
195        // store-level retraction never touches its rows, so an eagerly loaded
196        // mirror resurrects retracted facts (query-visible even though
197        // list-facts is empty). Clear the mirror and let the registry replay
198        // below rebuild it from the active records.
199        {
200            use nibli_reason::fact_store::FactStore as _;
201            typed_store.clear();
202        }
203
204        let engine = NibliEngine {
205            core: nibli_session::CoreSession::with_kb(nibli_reason::KnowledgeBase::with_store(
206                Box::new(typed_store),
207            )),
208            store: RefCell::new(Some(store)),
209        };
210        engine.replay_from_store()?;
211        Ok(engine)
212    }
213
214    /// Replay all persisted facts into the in-memory KB.
215    fn replay_from_store(&self) -> Result<(), String> {
216        let store = self.store.borrow();
217        let Some(store) = store.as_ref() else {
218            return Ok(()); // No store configured — nothing to replay.
219        };
220        let facts = store
221            .all_active_facts()
222            .map_err(|e| format!("Store error: {e}"))?;
223        for fact in &facts {
224            let buf: logic::LogicBuffer = postcard::from_bytes(&fact.payload)
225                .map_err(|e| format!("Deserialize error: {e}"))?;
226            self.core
227                .kb()
228                .assert_fact_with_id(buf, fact.label.clone(), fact.id)
229                .map_err(|e| format!("Replay error (fact {}): {e}", fact.id))?;
230        }
231        Ok(())
232    }
233
234    /// Validate KR text without asserting — returns Ok if it parses and compiles.
235    pub fn validate(&self, text: &str) -> Result<(), String> {
236        self.compile_text(text)
237            .map(|_| ())
238            .map_err(|e| e.to_string())
239    }
240
241    /// Register a predicate name for external compute dispatch.
242    pub fn register_compute_predicate(&mut self, name: String) {
243        self.core.register_compute_predicate(name);
244    }
245
246    fn compile_text(&self, input: &str) -> Result<logic::LogicBuffer, EngineError> {
247        // The SOLE text→AST seam — every public text method funnels through
248        // here, delegating to the SHARED chain (nibli-session), the same core
249        // the WASM surfaces wrap; `EngineError` is the re-exported `NibliError`.
250        self.core.compile_text(input)
251    }
252
253    /// Reset the knowledge base, clearing all facts and rules.
254    pub fn reset(&self) {
255        self.core.kb().reset().ok();
256        if let Ok(mut store) = self.store.try_borrow_mut()
257            && let Some(s) = store.as_mut()
258        {
259            let _ = s.clear();
260        }
261    }
262
263    /// Parse KR text, compile to FOL, and assert into the knowledge base.
264    ///
265    /// A bare-`.i` multi-sentence text becomes N INDEPENDENT facts — one per root —
266    /// each with its own id, store record, and retraction (connectives compile to a
267    /// single root and stay one fact). Returns the minted ids in root order. A
268    /// single-sentence text yields exactly one id.
269    pub fn assert_text(&self, text: &str) -> Result<Vec<u64>, EngineError> {
270        let mut store = self.store.try_borrow_mut().map_err(|_| {
271            EngineError::Reasoning("Store error: persistence state is already borrowed".to_string())
272        })?;
273
274        // No-store path: the shared core's assert loop IS this behavior.
275        let Some(s) = store.as_mut() else {
276            return Ok(self
277                .core
278                .assert_text(text)?
279                .into_iter()
280                .map(|(id, _)| id)
281                .collect());
282        };
283
284        // Store write-through path (engine-specific): the durable registry
285        // mints the ids, so the per-root loop runs here with the store in the
286        // middle — compile through the shared chain, then per root: persist
287        // FIRST, then assert under the store's id.
288        let buf = self.compile_text(text)?;
289        let label = text.to_string();
290        let parts = buf.split_roots();
291        let mut ids = Vec::with_capacity(parts.len());
292        for sub in parts {
293            let payload = postcard::to_allocvec(&sub)
294                .map_err(|e| EngineError::Reasoning(format!("Serialize error: {e}")))?;
295            let fact_id = s
296                .next_fact_id()
297                .map_err(|e| EngineError::Reasoning(format!("Store error: {e}")))?;
298            s.insert_fact(fact_id, label.clone(), payload)
299                .map_err(|e| EngineError::Reasoning(format!("Store error: {e}")))?;
300            // nibli-reason's `assert_fact_with_id` returns String (it predates the typed
301            // KB API); the assert IS the reasoning stage, so classify as Reasoning
302            // (the old `Semantic` here was a mislabel).
303            self.core
304                .kb()
305                .assert_fact_with_id(sub, label.clone(), fact_id)
306                .map_err(EngineError::Reasoning)?;
307            ids.push(fact_id);
308        }
309        Ok(ids)
310    }
311
312    /// Assert a fact directly by relation name and arguments, bypassing text
313    /// parsing. Delegates to the shared core (label `":assert {relation}"`;
314    /// event-decomposed to the surface shape — see
315    /// `CoreSession::assert_fact_direct`).
316    pub fn assert_fact_direct(
317        &self,
318        relation: String,
319        args: Vec<EngineLogicalTerm>,
320    ) -> Result<u64, EngineError> {
321        self.core.assert_fact_direct(&relation, &args, None)
322    }
323
324    /// Parse KR query, run entailment check, return result + formatted proof + JSON proof.
325    pub fn query_text_with_proof(
326        &self,
327        text: &str,
328    ) -> Result<(EngineQueryResult, String, String), EngineError> {
329        let (result, trace) = self.core.query_text_with_proof(text)?;
330        // `trace` IS the wire `ProofTrace` (canonical == wire now) — no conversion.
331        let formatted = nibli_render::render_proof_text(&trace, nibli_render::Register::Spec);
332        let json = nibli_protocol::proof_trace_to_json(&trace);
333        Ok((result, formatted, json))
334    }
335
336    /// Parse a KR query, run the entailment check, and return the typed
337    /// result together with the raw wire [`nibli_protocol::ProofTrace`] — for
338    /// callers/tests that need structured proof access (the plain-English "why"
339    /// summary, the collapsed macro-DAG view) rather than the pre-formatted text.
340    pub fn query_text_raw_proof(
341        &self,
342        text: &str,
343    ) -> Result<(EngineQueryResult, nibli_protocol::ProofTrace), EngineError> {
344        self.core.query_text_with_proof(text)
345    }
346
347    /// Evaluate a KR query against the KB and return the typed query result.
348    pub fn query_holds(&self, text: &str) -> Result<EngineQueryResult, EngineError> {
349        self.core.query_text(text)
350    }
351
352    /// Parse a KR query and extract all satisfying witness bindings.
353    pub fn query_find_text(
354        &self,
355        text: &str,
356    ) -> Result<Vec<Vec<EngineWitnessBinding>>, EngineError> {
357        self.core.query_find_text(text)
358    }
359
360    /// Count the number of distinct witness binding sets satisfying a KR query.
361    /// Exposes `nibli_reason::KnowledgeBase::count_witnesses` at the embedding level.
362    pub fn count_witnesses_text(&self, text: &str) -> Result<usize, EngineError> {
363        self.core.count_witnesses_text(text)
364    }
365
366    /// Aggregate the numeric values bound to `variable` across all witness binding
367    /// sets of a KR query, applying `op` (Sum/Min/Max/Avg). Returns `Ok(None)`
368    /// when no numeric witnesses are found. Exposes `nibli_reason::KnowledgeBase::aggregate`.
369    pub fn aggregate_text(
370        &self,
371        text: &str,
372        variable: &str,
373        op: nibli_types::logic::AggregateOp,
374    ) -> Result<Option<f64>, EngineError> {
375        self.core.aggregate_text(text, variable, op)
376    }
377
378    /// Compile KR text to the typed FOL `LogicBuffer` without asserting.
379    ///
380    /// Returns the IR directly — the caller renders it (e.g. via
381    /// `nibli_render::render_logic_tree` / `render_logic_buffer`). No
382    /// S-expression string is produced.
383    pub fn compile_debug(&self, text: &str) -> Result<EngineLogicBuffer, EngineError> {
384        self.compile_text(text)
385    }
386
387    /// List all active (non-retracted) facts with their IDs and labels.
388    pub fn list_facts(&self) -> Result<Vec<EngineFactSummary>, EngineError> {
389        self.core.kb().list_facts()
390    }
391
392    /// Retract a fact by ID and rebuild derived state.
393    ///
394    /// When persistence is configured, the retraction is also written through to
395    /// the on-disk store as a tombstone, so a subsequent `open()` does NOT replay
396    /// (resurrect) the retracted fact. The in-memory KB is retracted first (this
397    /// validates the ID and rebuilds derived state); the durable tombstone is only
398    /// written if that succeeds, keeping both layers consistent.
399    pub fn retract_fact(&self, id: u64) -> Result<(), EngineError> {
400        self.core.kb().retract_fact(id)?;
401
402        let mut store = self.store.try_borrow_mut().map_err(|_| {
403            EngineError::Reasoning("Store error: persistence state is already borrowed".to_string())
404        })?;
405        if let Some(s) = store.as_mut() {
406            // Idempotent at the store layer: retracting an already-tombstoned or
407            // never-persisted-but-known fact is fine. A NotFound here means the id
408            // lives only in the in-memory KB (e.g. assert_fact_direct, which bypasses
409            // the store) — that is not a durability failure, so swallow it.
410            match s.retract_fact(id) {
411                Ok(()) => {}
412                Err(nibli_store::StoreError::NotFound(_)) => {}
413                Err(e) => return Err(EngineError::Reasoning(format!("Store error: {e}"))),
414            }
415        }
416        Ok(())
417    }
418
419    /// Scan for contradictions (asserted store + derived positives for `~P`;
420    /// not a full closure proof — see
421    /// [`nibli_reason::KnowledgeBase::check_contradictions`]).
422    pub fn check_contradictions(&self) -> Vec<String> {
423        self.core.kb().check_contradictions()
424    }
425
426    /// Enable tracing for a predicate (interactive debugging).
427    pub fn trace_predicate(&self, predicate: &str) {
428        self.core.kb().trace_predicate(predicate);
429    }
430
431    /// Disable tracing for a predicate.
432    pub fn untrace_predicate(&self, predicate: &str) {
433        self.core.kb().untrace_predicate(predicate);
434    }
435
436    /// List all currently traced predicates.
437    pub fn traced_predicates(&self) -> Vec<String> {
438        self.core.kb().traced_predicates()
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::NibliEngine;
445    use std::fs;
446    use std::path::{Path, PathBuf};
447
448    fn temp_db_path(name: &str) -> PathBuf {
449        let dir = std::env::temp_dir().join("nibli_engine_tests");
450        fs::create_dir_all(&dir).unwrap();
451        dir.join(format!("{name}.redb"))
452    }
453
454    fn cleanup(path: &Path) {
455        let _ = fs::remove_file(path);
456    }
457
458    /// The persisted payload is now `nibli_types::logic::LogicBuffer` serialized
459    /// directly via serde/postcard (the `StoredLogicBuffer` mirror was deleted).
460    /// This pins that round-trip over every node + term variant — the property the
461    /// replay path (`replay_from_store`) and the write path (`assert_text`) depend on.
462    #[test]
463    fn logic_buffer_serde_postcard_roundtrip_covers_all_variants() {
464        use nibli_types::logic::{LogicBuffer, LogicNode, LogicalTerm};
465
466        let buf = LogicBuffer {
467            nodes: vec![
468                LogicNode::Predicate((
469                    "gerku".into(),
470                    vec![
471                        LogicalTerm::Constant("adam".into()),
472                        LogicalTerm::Variable("x".into()),
473                        LogicalTerm::Description("le-dog".into()),
474                        LogicalTerm::Unspecified,
475                    ],
476                )),
477                LogicNode::Predicate(("danlu".into(), vec![LogicalTerm::Constant("adam".into())])),
478                LogicNode::AndNode((0, 1)),
479                LogicNode::ExistsNode(("x".into(), 2)),
480                LogicNode::PastNode(0),
481                LogicNode::NotNode(1),
482                LogicNode::ForAllNode(("y".into(), 5)),
483                LogicNode::ComputeNode((
484                    "product".into(),
485                    vec![LogicalTerm::Number(3.0), LogicalTerm::Number(4.0)],
486                )),
487                LogicNode::CountNode(("z".into(), 2, 0)),
488                LogicNode::OrNode((0, 1)),
489                LogicNode::PresentNode(0),
490                LogicNode::FutureNode(0),
491                LogicNode::ObligatoryNode(0),
492                LogicNode::PermittedNode(0),
493            ],
494            roots: vec![2, 3],
495        };
496
497        let bytes = postcard::to_allocvec(&buf).unwrap();
498        let decoded: LogicBuffer = postcard::from_bytes(&bytes).unwrap();
499        assert_eq!(buf, decoded);
500    }
501
502    #[test]
503    fn persistent_assert_does_not_mutate_kb_when_store_is_unavailable() {
504        let path = temp_db_path("atomic_assert_store_busy");
505        cleanup(&path);
506
507        let engine = NibliEngine::open(&path).expect("Persistent engine should open");
508        let _borrow = engine.store.borrow();
509
510        let err = engine
511            .assert_text("big(some dog).")
512            .expect_err("Store borrow conflict should abort assertion");
513        assert!(
514            err.to_string().contains("Store error"),
515            "Expected store error, got: {err}"
516        );
517        assert!(
518            engine
519                .query_holds("big(some dog).")
520                .expect("Query should still run")
521                .is_false(),
522            "Failed persistent assertions must not leak into the live KB"
523        );
524
525        drop(_borrow);
526        let store = engine.store.borrow();
527        let facts = store
528            .as_ref()
529            .unwrap()
530            .all_active_facts()
531            .expect("Store should remain empty");
532        assert!(
533            facts.is_empty(),
534            "Failed persistent assertions must not leak into the store"
535        );
536
537        drop(store);
538        cleanup(&path);
539    }
540}