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}