Pipeline & logic IR
Normative reference:
LOGIC_IR.md at the
repo root — the public spec of the LogicBuffer IR. This page is the guided
tour; the spec wins on any disagreement. The single source of truth for the
types is nibli-types/src/logic.rs.
The pipeline
flowchart LR txt["nibli KR text"] -->|nibli-kr| ast["AstBuffer"] ast -->|nibli-semantics| lb["LogicBuffer (FOL IR)"] lb -->|nibli-reason| v["TRUE / FALSE / UNKNOWN<br/>+ ProofTrace"] lb -.-> tptp["TPTP → Vampire<br/>(nibli-verify)"] lb -.-> asp["ASP → clingo<br/>(nibli-verify)"] lb -.-> eng["English<br/>(nibli-render)"]
The three stages are plain Rust function calls — AstBuffer never crosses
a WASM boundary, and nibli-semantics is an internal stage, not a component.
The one WASM boundary is host ↔ nibli-pipeline, and only LogicBuffer
(flat, u32-indexed, no pointers) crosses it. The LogicBuffer is the
language-agnostic seam: only nibli-kr (parser) and nibli-lexicon
(dictionary) are front-end-specific; the reasoner, both differential oracles,
and the Lean proofs operate on or below the IR.
Queries and assertions use the same compiler — there is no separate query syntax at the IR level; divergence is entirely post-buffer.
CoreSession — the one compile chain
nibli_session::CoreSession packages the chain
(nibli_kr::parse_checked → nibli_semantics::compile_from_ast →
nibli_reason::transform_compute_nodes) plus the compute-predicate registry
and the assert/query verbs. Every runtime surface — nibli-engine (native),
nibli-pipeline (WASM component), nibli-wasm and nibli-ui (browser) — wraps it
with only boundary conversion, so native↔WASM agreement holds by
construction. Per-surface policy (error conversion, lint notes, env reads,
persistence, compute-dispatch wiring) deliberately stays outside the core.
AstBuffer (internal interchange)
The parser’s output and the semantic compiler’s input: parallel u32-indexed
arrays (predicates / arguments / sentences / roots). It lives in
nibli-types (not nibli-kr) because it is also nibli_kr::render’s input
(the round-trip layer) and the validated programmatic-build target — hand-built
buffers pass validate_ast_buffer (index bounds, acyclicity, the $-sigil
invariant on variables) before compilation. Arguments are typed:
Variable (sigiled $name), Marker (it / slot / ?), Pronoun (a
closed 14-variant inventory), plus names, descriptions, numbers — there is no
string-sniffed catch-all.
LogicBuffer (the FOL IR)
Two fields, no version field:
#![allow(unused)]
fn main() {
pub struct LogicBuffer {
pub nodes: Vec<LogicNode>,
pub roots: Vec<u32>, // top-level formula nodes
}
}
13 LogicNode variants: Predicate, ComputeNode (an atom dispatched to
the compute backend), AndNode, OrNode, NotNode, ExistsNode,
ForAllNode, PastNode / PresentNode / FutureNode (tense),
ObligatoryNode / PermittedNode (deontic), CountNode (“exactly N”).
5 LogicalTerm variants: Variable, Constant, Description,
Unspecified, Number(f64). Spec and code match one-for-one in name, payload,
and declaration order. Adding a variant is a breaking change across every
conversion site; an in-source guard (__exhaustiveness_guard in logic.rs)
forces that breakage to land in one documented location whose checklist names
each site to update — including the ones no compiler error reaches (the WIT
variant case + bindings regenerate, and the serde round-trip test).
Structural guarantees: post-order layout (children precede parents),
DAG-not-tree (the flattener shares subtrees; acyclicity is the producer’s
responsibility — the reasoner bounds-checks, it does not cycle-validate), and
root granularity = fact granularity (split_roots() shares the whole node
arena, exposing one root per fact). One footgun is flagged in both spec and
code: CountNode’s middle field is a count, not a node index — the only
non-index u32 payload in the IR.
There are deliberately no Biconditional/Xor nodes: the flattener
expands A <-> B and A ⊕ B into And/Or/Not shapes (sharing subtrees)
before the buffer exists.
Emitted-shape invariants (the contract)
These shapes are contract, not accident — pinned by the seam-conformance gate
(just verify-nibli-kr-seam):
- Neo-Davidsonian event decomposition.
dog(Adam).compiles to∃ev. dog(ev) ∧ dog_x1(ev, adam) ∧ dog_x2(ev, Unspecified)— a unary type predicate over a fresh event variable plus one binary role predicate per dictionary place (dogis arity-2: x2 is the breed), unfilled places padded withUnspecifiedso role predicates stay arity-consistent. - Quantifier shapes.
some dog→Exists(v, And(restrictor, body));every dog→ForAll(v, Or(Not(restrictor), body))(the implication arrow);exactly N→CountNode. Prenexall $x: …wraps the body directly — no restrictor, no arrow. - Flat-atom families. Not everything is event-decomposed:
equals(the identity) stays a flat 2-argument atom (the union-find ingests exactly that shape);viamodal tags, thethe_domain_<name>restrictors, and the abstraction type predicates are also flat. - Abstraction opacity.
event { P() }bodies compile behind a content-hashed__abs_<hash>marker: the reasoner matches the marker but skips the body, so assertingbelieve(me, fact { P })never makes barePtrue. - Compute transform. The front-end never emits
ComputeNode;nibli_reason::transform_compute_nodesrewrites markedPredicates after compilation. BYO-buffer users must run it themselves — a compute relation left as a plainPredicateis treated as an ordinary fact.
NotNode is structurally plain ¬ — the closed-world reading is a reasoner
property, carried on the verdict side by ProofTrace.naf_dependent and
ProofTrace.cwa_false.
Entry points
| You want | Use |
|---|---|
| Text → IR | NibliEngine::compile_debug (native), compile-debug (WIT), or nibli_semantics::compile_from_ast (+ transform_compute_nodes) |
| A programmatic ground fact | nibli_semantics::compile_injected_fact(relation, args) — decomposes and pads exactly like surface text |
| Reason over a buffer (BYO-IR) | nibli_reason::KnowledgeBase: assert_fact, query_entailment[_with_proof], query_find, count_witnesses, aggregate, with_assumptions, retract_fact |
| A packaged surface | nibli_engine::NibliEngine (native), the nibli-wasm Session (browser JS), or the nibli-pipeline component (WIT surface) |
Stable vs internal
Stable: the 13+5 variant inventories (names, payloads, declaration order);
the two-field buffer; post-order layout; root granularity + split_roots;
the emitted-shape invariants; the ProofRule/ProofStep/ProofTrace JSON
contract; the NibliError display prefixes; the WIT logic-types interface.
Internal: variable/Skolem naming (_v0, sk_N), __abs_ hash digits,
concrete index values, the compiler’s tree IR (IrForm), stored-fact forms,
on-disk mirrors.
The buffer has no version field — the WIT package version
(nibli:engine@0.7.0) and nibli-store’s fail-closed schema versions are the
only version markers. Treat the format as pre-1.0: pin a commit if you build
against it.
Writing a consumer or producer
The two shipped external consumers are the templates:
nibli-verify/src/tptp.rs (→ Vampire; hard-errors on out-of-fragment nodes
rather than mistranslating) and nibli-verify/src/asp.rs (→ clingo; regroups
the event decomposition back to surface atoms). A producer must emit the
invariant shapes — most importantly the event decomposition with consistent
role arities, the ∀-implication arrow, and flat 2-arg equals — and gets
soundness checking for free: the reasoner rejects non-stratifiable rule sets
at assert time.