nibli_types/logic.rs
1//! First-Order Logic types produced by the nibli-semantics compiler and consumed by nibli-reason.
2//!
3//! Flat index-based representation: `LogicBuffer` contains a `nodes` array
4//! of `LogicNode` variants, referenced by `u32` indices.
5
6/// A logical term — the typed representation of an FOL argument.
7#[derive(Clone, Debug, PartialEq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
10pub enum LogicalTerm {
11 /// A bound or free variable (e.g., Skolem variables, universally quantified vars).
12 Variable(String),
13 /// A ground constant (e.g., entity names from `la`).
14 Constant(String),
15 /// An opaque description reference (from `le` determiner).
16 Description(String),
17 /// Unspecified placeholder (from `zo'e`).
18 Unspecified,
19 /// Numeric literal (from `li` + PA).
20 Number(f64),
21}
22
23impl LogicalTerm {
24 /// Human-readable rendering of a logical term (UI labels / witness display).
25 /// Ported from the former `nibli-protocol` wire-term display.
26 pub fn display(&self) -> String {
27 match self {
28 LogicalTerm::Constant(s) => s.clone(),
29 LogicalTerm::Number(n) => format!("{n}"),
30 LogicalTerm::Variable(s) => s.clone(),
31 LogicalTerm::Description(s) => format!("the_{s}"),
32 LogicalTerm::Unspecified => "(unspecified)".to_string(),
33 }
34 }
35
36 /// Compact textual rendering used in CLI proof traces.
37 /// Ported from the former `nibli-protocol` wire-term `trace_display`.
38 pub fn trace_display(&self) -> String {
39 match self {
40 LogicalTerm::Constant(s) => s.clone(),
41 LogicalTerm::Number(n) => {
42 if *n == (*n as i64) as f64 {
43 format!("{}", *n as i64)
44 } else {
45 format!("{n}")
46 }
47 }
48 LogicalTerm::Variable(s) => format!("?{s}"),
49 LogicalTerm::Description(s) => format!("the {s}"),
50 LogicalTerm::Unspecified => "something".to_string(),
51 }
52 }
53}
54
55/// A node in the flat logic graph. Each variant corresponds to an FOL constructor.
56/// Nodes reference children by `u32` index into the `LogicBuffer.nodes` array.
57#[derive(Clone, Debug, PartialEq)]
58#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
59pub enum LogicNode {
60 /// Ground or quantified predicate. Fields: (relation-name, argument-terms).
61 Predicate((String, Vec<LogicalTerm>)),
62 /// A predicate dispatched to an external compute backend for evaluation.
63 ComputeNode((String, Vec<LogicalTerm>)),
64 /// Conjunction: left ∧ right. Fields: (left-node-id, right-node-id).
65 AndNode((u32, u32)),
66 /// Disjunction: left ∨ right. Fields: (left-node-id, right-node-id).
67 OrNode((u32, u32)),
68 /// Negation: ¬inner. Payload: inner-node-id.
69 NotNode(u32),
70 /// Existential quantifier: ∃var. body. Fields: (variable-name, body-node-id).
71 ExistsNode((String, u32)),
72 /// Universal quantifier: ∀var. body. Fields: (variable-name, body-node-id).
73 ForAllNode((String, u32)),
74 /// Past tense wrapper (pu). Payload: inner-node-id.
75 PastNode(u32),
76 /// Present tense wrapper (ca). Payload: inner-node-id.
77 PresentNode(u32),
78 /// Future tense wrapper (ba). Payload: inner-node-id.
79 FutureNode(u32),
80 /// Deontic obligation wrapper (ei/bilga). Payload: inner-node-id.
81 ObligatoryNode(u32),
82 /// Deontic permission wrapper (e'e/curmi). Payload: inner-node-id.
83 PermittedNode(u32),
84 /// Exactly N entities satisfy the body. Fields: (variable-name, count, body-node-id).
85 CountNode((String, u32, u32)),
86}
87
88/// Flat logic buffer: a `nodes` array plus root indices.
89#[derive(Clone, Debug, PartialEq)]
90#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
91pub struct LogicBuffer {
92 pub nodes: Vec<LogicNode>,
93 pub roots: Vec<u32>,
94}
95
96impl LogicBuffer {
97 /// Split a multi-root buffer into one independent single-root buffer per root,
98 /// so an `.i`-separated multi-sentence compile becomes N independently
99 /// assertable / retractable facts.
100 ///
101 /// The split is exactly the `roots` boundary: nibli-semantics emits **one root per bare
102 /// `.i` sentence**, but a **single root** (an `AndNode`/`OrNode`) for logical
103 /// connectives (`.ije`/`.ija`/`ge…gi`). So bare `.i` splits into N buffers while
104 /// a connective stays as one compound fact — automatically, no text parsing.
105 ///
106 /// Share-nodes strategy: each sub-buffer reuses the full `nodes` arena and
107 /// exposes a single root. Unreachable nodes belonging to sibling roots are inert
108 /// because every consumer traverses only from `roots` (see
109 /// `nibli_reason::process_assertion`). No index remapping, so no risk of a
110 /// mis-remapped child edge (notably `CountNode`'s middle field is a COUNT, not a
111 /// node index). `roots.len() <= 1` returns a single clone (identity) so the
112 /// single-sentence path is unchanged.
113 pub fn split_roots(&self) -> Vec<LogicBuffer> {
114 if self.roots.len() <= 1 {
115 return vec![self.clone()];
116 }
117 self.roots
118 .iter()
119 .map(|&r| LogicBuffer {
120 nodes: self.nodes.clone(),
121 roots: vec![r],
122 })
123 .collect()
124 }
125}
126
127/// A single witness binding: variable name → logical term value.
128#[derive(Clone, Debug, PartialEq)]
129pub struct WitnessBinding {
130 pub variable: String,
131 pub term: LogicalTerm,
132}
133
134/// Why the engine cannot currently return a definitive `True` or `False`.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub enum UnknownReason {
137 /// Search encountered a recursive cycle and cut it rather than diverging.
138 CycleCut,
139 /// Result depends on knowledge the current KB does not have yet.
140 IncompleteKnowledge,
141 /// Result depends on negation-as-failure and is therefore not classically proved.
142 NafDependent,
143 /// An external compute predicate could not be evaluated because its backend was
144 /// unreachable or unregistered — the result is genuinely undetermined, NOT false.
145 BackendUnavailable,
146 /// A numeric operand or computed result is non-finite (±inf/NaN) — e.g. a literal
147 /// too large for an f64 (~309+ digits overflows to ±inf). The comparison/arithmetic
148 /// is genuinely undetermined, NOT a confident TRUE/FALSE.
149 NonFinite,
150}
151
152/// Which resource or search bound prevented a definitive answer.
153#[derive(Clone, Debug, PartialEq, Eq)]
154pub enum ResourceKind {
155 Depth,
156 Fuel,
157 Memory,
158}
159
160/// Top-level entailment result returned by the reasoning engine.
161#[derive(Clone, Debug, PartialEq, Eq)]
162pub enum QueryResult {
163 True,
164 False,
165 Unknown(UnknownReason),
166 ResourceExceeded(ResourceKind),
167}
168
169impl QueryResult {
170 pub fn is_true(&self) -> bool {
171 matches!(self, Self::True)
172 }
173
174 pub fn is_false(&self) -> bool {
175 matches!(self, Self::False)
176 }
177
178 pub fn is_definitive(&self) -> bool {
179 matches!(self, Self::True | Self::False)
180 }
181
182 pub fn status_label(&self) -> &'static str {
183 match self {
184 Self::True => "TRUE",
185 Self::False => "FALSE",
186 Self::Unknown(_) => "UNKNOWN",
187 Self::ResourceExceeded(_) => "RESOURCE_EXCEEDED",
188 }
189 }
190
191 pub fn detail_label(&self) -> Option<&'static str> {
192 match self {
193 Self::Unknown(UnknownReason::CycleCut) => Some("cycle-cut"),
194 Self::Unknown(UnknownReason::IncompleteKnowledge) => Some("incomplete-knowledge"),
195 Self::Unknown(UnknownReason::NafDependent) => Some("naf-dependent"),
196 Self::Unknown(UnknownReason::BackendUnavailable) => Some("backend-unavailable"),
197 Self::Unknown(UnknownReason::NonFinite) => Some("non-finite"),
198 Self::ResourceExceeded(ResourceKind::Depth) => Some("depth"),
199 Self::ResourceExceeded(ResourceKind::Fuel) => Some("fuel"),
200 Self::ResourceExceeded(ResourceKind::Memory) => Some("memory"),
201 _ => None,
202 }
203 }
204}
205
206/// Proof rule applied at a single proof step.
207///
208/// This IS the serde wire type (named fields, `#[serde(tag = "type")]`): the same
209/// type crosses every native boundary (nibli-reason → nibli-engine/nibli-wasm → JSON →
210/// nibli-ui). `nibli-protocol` re-exports it and owns only the JSON helpers; the WIT
211/// boundary (nibli-pipeline/nibli-host) keeps its generated tuple-shaped mirror by necessity.
212/// The serde attributes are the JSON contract — do not rename a field or tag.
213#[derive(Clone, Debug, PartialEq)]
214#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
215#[cfg_attr(feature = "serde", serde(tag = "type"))]
216pub enum ProofRule {
217 #[cfg_attr(feature = "serde", serde(rename = "conjunction"))]
218 Conjunction,
219 #[cfg_attr(feature = "serde", serde(rename = "disjunction_check"))]
220 DisjunctionCheck { detail: String },
221 #[cfg_attr(feature = "serde", serde(rename = "disjunction_intro"))]
222 DisjunctionIntro { side: String },
223 #[cfg_attr(feature = "serde", serde(rename = "negation"))]
224 Negation,
225 #[cfg_attr(feature = "serde", serde(rename = "modal_passthrough"))]
226 ModalPassthrough { kind: String },
227 #[cfg_attr(feature = "serde", serde(rename = "exists_witness"))]
228 ExistsWitness { var: String, term: LogicalTerm },
229 #[cfg_attr(feature = "serde", serde(rename = "exists_failed"))]
230 ExistsFailed,
231 #[cfg_attr(feature = "serde", serde(rename = "forall_vacuous"))]
232 ForallVacuous,
233 #[cfg_attr(feature = "serde", serde(rename = "forall_verified"))]
234 ForallVerified { entities: Vec<LogicalTerm> },
235 #[cfg_attr(feature = "serde", serde(rename = "forall_counterexample"))]
236 ForallCounterexample { entity: LogicalTerm },
237 #[cfg_attr(feature = "serde", serde(rename = "count_result"))]
238 CountResult { expected: u32, actual: u32 },
239 #[cfg_attr(feature = "serde", serde(rename = "predicate_check"))]
240 PredicateCheck { method: String, detail: String },
241 #[cfg_attr(feature = "serde", serde(rename = "compute_check"))]
242 ComputeCheck { method: String, detail: String },
243 #[cfg_attr(feature = "serde", serde(rename = "asserted"))]
244 Asserted { fact: String },
245 #[cfg_attr(feature = "serde", serde(rename = "derived"))]
246 Derived { label: String, fact: String },
247 #[cfg_attr(feature = "serde", serde(rename = "proof_ref"))]
248 ProofRef { fact: String },
249 /// Equality substitution: fact proved by substituting equivalent terms.
250 /// Fields: original fact, equality facts used, substituted fact that was found.
251 #[cfg_attr(feature = "serde", serde(rename = "equality_substitution"))]
252 EqualitySubstitution {
253 original: String,
254 equality_facts: String,
255 substituted: String,
256 },
257 /// Rule was tried but a condition failed.
258 #[cfg_attr(feature = "serde", serde(rename = "rule_attempt_failed"))]
259 RuleAttemptFailed {
260 rule_label: String,
261 failed_condition: String,
262 },
263 /// Predicate not found in fact store and no rule could derive it.
264 #[cfg_attr(feature = "serde", serde(rename = "predicate_not_found"))]
265 PredicateNotFound { predicate: String },
266}
267
268/// A single step in a proof trace.
269#[derive(Clone, Debug, PartialEq)]
270#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
271pub struct ProofStep {
272 pub rule: ProofRule,
273 pub holds: bool,
274 pub children: Vec<u32>,
275}
276
277/// Complete proof trace: steps array + root index.
278#[derive(Clone, Debug, PartialEq)]
279#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
280pub struct ProofTrace {
281 pub steps: Vec<ProofStep>,
282 pub root: u32,
283 /// True if any step in this trace used negation-as-failure (CWA assumption).
284 /// Under open-world semantics, NAF-dependent conclusions would be Unknown.
285 /// Populated by nibli-reason at trace construction; serialized over the wire.
286 #[cfg_attr(feature = "serde", serde(default))]
287 pub naf_dependent: bool,
288 /// True if the verdict is a CLOSED-WORLD `FALSE`: not derivable from the KB
289 /// (the closed-world assumption), as opposed to a numeric/arithmetic FALSE that
290 /// was genuinely DECIDED (e.g. `5 dunli 3`). A closed-world FALSE is the dual of
291 /// `naf_dependent` — under open-world semantics it would be Unknown, not a proof
292 /// of the negation. Computed by nibli-reason from the verdict (it needs to distinguish
293 /// FALSE from Unknown, both of which have a non-holding root), so unlike
294 /// `naf_dependent` it cannot be recomputed from the steps alone.
295 #[cfg_attr(feature = "serde", serde(default))]
296 pub cwa_false: bool,
297}
298
299impl ProofTrace {
300 /// Returns true if any step in this proof trace used negation-as-failure.
301 /// A Negation step with `holds: true` means the inner formula was unprovable
302 /// and NAF flipped it to True — this is the CWA assumption in action.
303 /// Under open-world semantics, the same conclusion would be Unknown.
304 pub fn has_naf_dependency(&self) -> bool {
305 self.steps
306 .iter()
307 .any(|s| matches!(s.rule, ProofRule::Negation) && s.holds)
308 }
309}
310
311/// Aggregation operation for numeric witness values.
312#[derive(Clone, Debug)]
313pub enum AggregateOp {
314 Sum,
315 Min,
316 Max,
317 Avg,
318}
319
320/// Unique identifier for a stored fact in the knowledge base.
321pub type FactId = u64;
322
323/// Summary of an active fact in the knowledge base.
324#[derive(Clone, Debug)]
325pub struct FactSummary {
326 pub id: FactId,
327 pub label: String,
328 pub root_count: u32,
329}
330
331/// Compile-time exhaustiveness anchor for the cross-crate conversion lattices.
332///
333/// `LogicNode`, `LogicalTerm`, and `ProofRule` are each converted by hand in
334/// several places that no single crate can see at once, so adding a variant to
335/// any of them silently leaves stale converters elsewhere — the build would
336/// break, but with scattered `E0004` errors and no roadmap. This function exists
337/// only to make that breakage land in ONE discoverable, documented location: its
338/// wildcard-free matches force `E0004` *here* the moment a variant is added, and
339/// the checklist below names every site that must then be updated.
340///
341/// Since the WIT `with`-remap (nibli-pipeline/Cargo.toml, 2026-07-18),
342/// `LogicNode`/`LogicalTerm` (and `LogicBuffer`/`QueryResult`/errors/witness/
343/// fact-summary) ARE the WIT boundary types on the guest side — no converter to
344/// touch there for a new variant of those. `ProofRule` alone keeps a hand map
345/// (the WIT `proof-rule` is a tuple/newtype-record mirror wit-bindgen can't
346/// make struct-variant, so it stays generated).
347///
348/// When you add or remove a variant of any of these three enums, update:
349/// - `nibli-pipeline/src/lib.rs` — `convert_proof_rule` (→ the WIT guest
350/// `proof-rule` record mirror); a new `LogicNode`/`LogicalTerm` variant needs
351/// NO guest converter (the type is `with`-remapped onto this crate) but DOES
352/// need the matching WIT `logic-node`/`logical-term` case in `wit/world.wit`
353/// plus a regenerate
354/// - `nibli-protocol/src/lib.rs` — **re-exports** `ProofRule`/`ProofStep`/`ProofTrace`
355/// (and `LogicalTerm`) from this crate and owns only the `proof_trace_to_json` /
356/// `proof_trace_from_json` free fns. No wire mirror or `from_canonical_*` converter
357/// remains — `ProofRule` IS the serde wire type (named fields, `serde(tag = "type")`),
358/// so it crosses every native boundary unchanged. The serde renames here are the
359/// JSON contract.
360/// - `nibli-host/src/main.rs` — `rule_to_proto` (WIT `proof-rule` → canonical `ProofRule`);
361/// for a new `LogicNode`/`LogicalTerm` variant also `wit_term_to_types` /
362/// `wit_logic_node_to_types` / `wit_logic_buffer_to_types` (WIT → `nibli_types`,
363/// the `:debug` reverse converter)
364/// - `nibli-render/src/proof.rs` — `icon` / `label` / `css_class` / `trace_display`
365/// for a new `ProofRule` variant (the readable rendering of the wire rule)
366/// - `wit/world.wit` — the `logical-term` / `proof-rule` variant lists (for
367/// `proof-rule`, add both the payload `record …-rule` and the variant case),
368/// then regenerate bindings with `cargo component build`
369/// - for a new `LogicNode`/`LogicalTerm` variant: nibli-reason lowering + evaluation,
370/// `nibli-render/src/logic.rs` (`render_logic_buffer` English + `render_logic_tree`
371/// structural tree) + `term.rs` (IR back-translation rendering), and
372/// the serde persistence round-trip test
373/// (`nibli-engine`'s `logic_buffer_serde_postcard_roundtrip_covers_all_variants`)
374///
375/// Never called at runtime; `#[doc(hidden)]` keeps it off the public API surface.
376/// (A macro-driven codegen of the conversion lattices was evaluated and declined
377/// on readability grounds — the JSON RHS field names are bespoke per variant, so a
378/// macro must spell every variant out anyway; see `todo.md`.)
379#[doc(hidden)]
380pub fn __exhaustiveness_guard(node: &LogicNode, term: &LogicalTerm, rule: &ProofRule) {
381 match node {
382 LogicNode::Predicate(_) => {}
383 LogicNode::ComputeNode(_) => {}
384 LogicNode::AndNode(_) => {}
385 LogicNode::OrNode(_) => {}
386 LogicNode::NotNode(_) => {}
387 LogicNode::ExistsNode(_) => {}
388 LogicNode::ForAllNode(_) => {}
389 LogicNode::PastNode(_) => {}
390 LogicNode::PresentNode(_) => {}
391 LogicNode::FutureNode(_) => {}
392 LogicNode::ObligatoryNode(_) => {}
393 LogicNode::PermittedNode(_) => {}
394 LogicNode::CountNode(_) => {}
395 }
396 match term {
397 LogicalTerm::Variable(_) => {}
398 LogicalTerm::Constant(_) => {}
399 LogicalTerm::Description(_) => {}
400 LogicalTerm::Unspecified => {}
401 LogicalTerm::Number(_) => {}
402 }
403 match rule {
404 ProofRule::Conjunction => {}
405 ProofRule::DisjunctionCheck { .. } => {}
406 ProofRule::DisjunctionIntro { .. } => {}
407 ProofRule::Negation => {}
408 ProofRule::ModalPassthrough { .. } => {}
409 ProofRule::ExistsWitness { .. } => {}
410 ProofRule::ExistsFailed => {}
411 ProofRule::ForallVacuous => {}
412 ProofRule::ForallVerified { .. } => {}
413 ProofRule::ForallCounterexample { .. } => {}
414 ProofRule::CountResult { .. } => {}
415 ProofRule::PredicateCheck { .. } => {}
416 ProofRule::ComputeCheck { .. } => {}
417 ProofRule::Asserted { .. } => {}
418 ProofRule::Derived { .. } => {}
419 ProofRule::ProofRef { .. } => {}
420 ProofRule::EqualitySubstitution { .. } => {}
421 ProofRule::RuleAttemptFailed { .. } => {}
422 ProofRule::PredicateNotFound { .. } => {}
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 /// Exercises the exhaustiveness anchor with one variant of each enum, so the
431 /// guard has a live call site and the three enums are confirmed constructible.
432 #[test]
433 fn exhaustiveness_guard_is_callable() {
434 __exhaustiveness_guard(
435 &LogicNode::NotNode(0),
436 &LogicalTerm::Unspecified,
437 &ProofRule::Conjunction,
438 );
439 }
440
441 fn pred(name: &str) -> LogicNode {
442 LogicNode::Predicate((name.to_string(), vec![]))
443 }
444
445 #[test]
446 fn split_roots_multi_returns_one_buffer_per_root() {
447 // Two independent roots (the bare-`.i` shape nibli-semantics emits).
448 let buf = LogicBuffer {
449 nodes: vec![pred("gerku"), pred("mlatu")],
450 roots: vec![0, 1],
451 };
452 let parts = buf.split_roots();
453 assert_eq!(parts.len(), 2);
454 assert_eq!(parts[0].roots, vec![0]);
455 assert_eq!(parts[1].roots, vec![1]);
456 // Share-nodes: each sub-buffer keeps the full arena.
457 assert_eq!(parts[0].nodes, buf.nodes);
458 assert_eq!(parts[1].nodes, buf.nodes);
459 }
460
461 #[test]
462 fn split_roots_single_is_identity() {
463 let buf = LogicBuffer {
464 nodes: vec![pred("gerku")],
465 roots: vec![0],
466 };
467 let parts = buf.split_roots();
468 assert_eq!(parts.len(), 1);
469 assert_eq!(parts[0], buf);
470 }
471
472 #[test]
473 fn split_roots_empty_returns_self() {
474 let buf = LogicBuffer {
475 nodes: vec![],
476 roots: vec![],
477 };
478 let parts = buf.split_roots();
479 assert_eq!(parts.len(), 1);
480 assert_eq!(parts[0], buf);
481 }
482
483 #[test]
484 fn split_roots_connective_root_is_not_split() {
485 // A connective (`.ije`/`ge…gi`) compiles to a SINGLE root that is an
486 // `AndNode` over its operands — one compound fact, must not split.
487 let buf = LogicBuffer {
488 nodes: vec![pred("gerku"), pred("mlatu"), LogicNode::AndNode((0, 1))],
489 roots: vec![2],
490 };
491 let parts = buf.split_roots();
492 assert_eq!(
493 parts.len(),
494 1,
495 "a connective's single And-root must stay one fact"
496 );
497 assert_eq!(parts[0], buf);
498 }
499}