Skip to main content

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}