RFD 0058 — The Tableau Model Engine (Modal Escape Hatch)
Status
Proposed. Slice 1 is the explicit-single-frame K3 modal tableau, built as a new
tier executor. Slice 2 — the fibred composition of the Kripke frame over
standpoints (this document’s §“Slice 2”) — is built. Slice 3 is scoped here as
a roadmap but is NOT built; the refusal it will eventually lift stays a loud
refusal (OE1104).
Summary
Argon’s reasoner is a tier-classified, Z-set, semi-naive Datalog engine. The
modal surface (box/diamond over a Kripke frame, §11.2) is the one place the
Datalog engine cannot reach: a box(P) is a universal over an accessibility
relation, a diamond(P) an existential, and while the standpoint frame is
translated to stratified Datalog (translate_standpoint_modals, §D3), the
explicit Kripke frame — worlds and an accessible relation the modeler
declares via std::kripke — has no evaluator. Today every such modal is
refused with OE1104 (the held escape hatch).
This RFD introduces a tableau model engine as a new TierExecutor
advertising Tier::Modal. A tableau is the standard decision procedure for
modal/description logics: it builds a candidate Kripke model and checks
satisfiability by saturating expansion rules until a clash or an open model. It
is the right machinery for the modal escape hatch precisely because it can do
what fixpoint Datalog cannot — quantify over the frame structurally.
Because a tableau is the sensitive piece of a financial-grade substrate — a
wrong answer here is silently catastrophic — the engine is gated by a
differential oracle against both the existing Datalog executor (on the
decidable overlap) and the mechanized ModalFrame semantics, before it goes
anywhere near production.
The escape hatch as a tier executor (not a second path)
The load-bearing invariant of the reasoner is that all evaluation flows
through Engine::evaluate, dispatched to a TierExecutor by tier. The
tableau is a tier executor, not a parallel evaluation path:
AtomIR-shaped rules
↓ instantiate
translate_standpoint_modals
├─ standpoint frame → stratified Datalog (recursive tier)
├─ explicit Kripke frame → AtomIR::Modal preserved ← slice 1
└─ everything else → OE1104 (narrowed)
↓ compile
CompiledAtom::Modal (new variant) ← slice 1
↓ Engine::evaluate dispatch by tier
KripkeExecutor : TierExecutor (Tier::Modal) ← slice 1
↓
Z-set RelationCatalog (BTreeMap<Tuple, Weight>)
The executor reads the RelationCatalog — the Datalog-materialized base facts
(accessible, the per-world classification of the inner predicate) seed the
tableau. This is the Konclude pay-as-you-go move: saturation first, tableau
only for the modal goals. The executor writes its derived modal facts back into
the same catalog. There is no second store, no second engine — the executor is
one more backend behind the same dispatch surface.
Slice 1 — scope (held precisely)
A box/diamond modal tableau over a single explicit Kripke frame,
K3-valued, strict consistency, one frame. Concretely:
- Frame: the modeler’s
std::kripkeWorlds andaccessiblerelation — explicit facts in the catalog. Not the standpoint reach-closure (that is the already-built §D3 Datalog translation), not the rigidity/classification frame. - Operators:
box(P)anddiamond(P)wherePis a single positive predicate atom whose evaluation is per-world. - Value algebra: K3 —
Truth4constrained to{Is, Not, Can}. - Consistency: strict. A proposition forced to both
IsandNotat one world is a clash.Can(unknown, open-world) is an open branch, not a clash.
Slice 1 does not do, and these keep refusing with a tighter OE1104:
- The fibred composition of the Kripke frame over standpoints (slice 2 — now built; see §“Slice 2”).
- Rigid roles, nominals (slice 3).
- The classification/rigidity frame (slice 3+).
Semantics (authoritative; cites the mechanization)
Over a Kripke frame with accessibility R (spec/lean/Argon/Reasoning/Modal.lean,
spec/lean/Argon/Standpoint/ModalFrame.lean):
box(w, P) = ∀ w'. R(w, w') → P(w')— necessity is the intersection over accessible worlds (Modal.leanbox).diamond(w, P) = ∃ w'. R(w, w') ∧ P(w')— possibility is the union (Modal.leandiamond).- Duality:
diamond w P ↔ ¬ box w (¬P)(diamond_iff_not_box_not, proven).
Slice 1’s R is the user’s explicit accessible facts; the abstract
KripkeFrame of Modal.lean is instantiated with those facts directly (not the
reach reflexive-transitive closure ModalFrame.lean builds for standpoints).
Dead-end worlds (the faithful K(m) semantics). A world with no accessible
successor is a dead-end. box(w, P) = ∀ w'. R(w, w') → P(w') is vacuously
true there (the universal over an empty set), and diamond(w, P) = ∃ w'. … is
vacuously false (the existential over an empty set). This is not a design
choice but the standard semantics of base multi-modal logic K(m): by the
DL↔modal correspondence (Schild, “A correspondence theory for terminological
logics,” IJCAI 1991), the description logic ALC is a notational variant of K(m),
in which ∀r.C (= box) is vacuously true at an element with no r-successors
— exactly standard DL ∀-rule behavior. It is also what Modal.lean’s
box/diamond compute. The executor therefore anchors a box rule over
every frame world — a dead-end world’s vacuously-true box derives the head
— and a diamond rule derives nothing at a dead-end. (Anchoring only over
worlds-with-successors would silently drop a dead-end’s vacuously-true box — an
under-derivation, not the faithful semantics.)
The frame split. This dead-end case is specific to slice 1’s generic
frame. Slice 1’s explicit accessible frame is base K: arbitrary, dead-ends
allowed, hence vacuous box. The standpoint frame (ModalFrame.lean’s
reflexive-transitive reach closure) is S4 — reflexive, hence serial — so
every world reaches at least itself and there are NO dead-ends; the vacuous-box
case cannot arise there. The two frames are distinct modal logics over distinct
accessibility relations, and slice 1 is the K one.
Value algebra — why K3 is correct for one frame
spec/lean/Argon/Standpoint/Consistency.lean mechanizes the strict consistency
policy and proves (append_strict_inK3) that under the default strict
policy every cell stays in the K3 fragment {Is, Not, Can}. The
information-determined-inconsistent value Both arises only at
cross-standpoint federation (append_strict_fails_iff: a strict append fails
exactly when it would create a (T, F) conflict, and that conflict is the
cross-source case). Slice 1 is a single frame under strict consistency, so by
this invariant every label is in K3. The carrier is Truth4
(oxc-protocol/src/storage.rs), with info_join the AFT bilattice operation;
slice 1 uses only the K3-closed sub-table (Both never produced).
- Clash = a proposition forced to both
IsandNotat one world — the K3 inconsistency strict rejects. Can(open-world unknown) = an open branch, not a clash. This is the open-world modal trap:box(P)holds only ifPisIsat every accessible world; aCan/unknown world does NOT satisfybox(unknown ≠ true).
DL ≅ modal correspondence (why a role tableau ports)
Schild 1991: a tableau over an accessibility role is exactly a modal tableau —
∃accessible.P = diamond P, ∀accessible.P = box P. This is why the
description-logic role-tableau machinery (studied from a separate read-only DL
reasoner as an architectural reference) ports to Argon’s modal frame, re-skinned
to Argon’s Truth4 label shape. No DL-reasoner code is copied; the pattern is
ported.
Machinery (ported architecture, re-skinned)
The portable, proven tableau machinery — adapted, never copied:
- Trail-based zero-copy undo + dependency-directed backjumping. Every mutation (a label cell set, an edge added, a node created) pushes a trail entry; undo replays the trail in reverse to a mark. Backjumping computes the earliest branch the current clash depends on and unwinds to it, skipping irrelevant choice points. This is the dominant performance win over cloning state per branch.
- The expansion loop with dirty-node tracking and pre-computed rule data.
Only nodes whose label changed are re-processed; the rule that fires on each
label cell is classified once. The
∃/∀-rules of DL become thediamond/box-rules overaccessible. - Blocking. A single explicit frame is finite (explicit
accessiblefacts), so slice 1 terminates by the finite frame. Subset/equality blocking is still implemented for the general/cyclic case so a cyclicaccessiblerelation terminates. The blocking comparison is K3-label info-order dominance, not set inclusion: a node is blocked by an ancestor whose label dominates it in the information order. - Clash detection. Adapted to the K3
Is ∧ Notclash at a single world.
The DL-specific data shapes (concept-label bitvectors, SROIQ role algebra,
absorption/NNF/EL pre-passes) are NOT ported — Argon’s label is Truth4-per-
proposition over worlds.
Routing — narrowing the refusal, not dropping it
Today two sites refuse modals:
translate_standpoint_modals(instantiate) refuses an unframed modal withOE1104(InstantiateError::ModalNotEvaluated).- The rule compiler (
compile_atom) refuses a survivingAtomIR::ModalwithOE1104(RuleCompileError::ModalNotEvaluated) as a backstop.
Slice 1 inserts the explicit-Kripke-frame recognition: when the modal’s inner
predicate is evaluated over a frame backed by std::kripke’s accessible,
AtomIR::Modal is preserved (not translated, not refused), compiled to a new
CompiledAtom::Modal { op, inner } variant, and handled by the KripkeExecutor.
Every other shape — standpoint frame (translated as before), no frame /
rigidity, rigid roles, nominals — KEEPS refusing OE1104. The refusal is
narrowed to exactly slice 1’s complement, never silently accepted.
CompiledAtom is internal to oxc-reasoning and is NOT mirrored in any
@[language_interface] Lean inductive, so adding CompiledAtom::Modal does not
touch the Lean↔Rust drift gate. (AtomIR::Modal and ModalOp are already
mirrored and unchanged.)
Soundness floor (the hard gate)
- Differential oracle. The
KripkeExecutor’s result MUST agree with (a) the semi-naive Datalog executor on any program in the decidable overlap, and (b) theModalFramesemantics —box(P)= the intersection (∀ over accessible),diamond(P)= the union (∃). A differential test runs over an explicit frame wherebox ≠ diamond(a proposition true at some accessible worlds, false or unknown at others) so a swap or collapse of the two operators fails it. - Termination. Tested on a cyclic
accessiblerelation — blocking must fire. A non-terminating tableau is a failure. - K3 clash soundness / open-world box.
box(P)holds only ifPisIsat every accessible world; aCan/unknown accessible world must NOT satisfybox. Tested explicitly (the classic open-world modal trap). - Scope refusal. Standpoint-frame, rigid-role, nominal, and classification-
frame modals still refuse
OE1104— tested that slice 1 did not silently accept them.
Slice 2 — the Kripke frame fibred over standpoints
Slice 2 lifts the slice-1 single-frame modal tableau to the fibred
composition of Argon’s two modal frames: it evaluates the explicit-Kripke
box/diamond (slice 1’s tableau) independently per standpoint, then
info-joins the per-standpoint K3 results across standpoints (the
already-shipped FDE federation, §11 / Federation.lean). The four-valued
Both arises exactly when two standpoints disagree on a modal fact —
genuine perspectival disagreement — and nowhere else.
Why fibred, not a product (decidability)
A genuine product of two S4 modalities (S4×S4) is undecidable. Slice 2
deliberately avoids that boundary. The composition is a fibration: the
Kripke accessibility relation is evaluated entirely within a single
standpoint’s catalog (slice 1, unchanged), and the standpoint dimension enters
only after the per-standpoint modal answer is fixed, as a flat info-join over
the per-standpoint results. This is the fusion (independent join) of the
two logics, not their product — decidable by the Wolter transfer theorem for
fusions (Wolter, “Fusions of modal logics revisited,” 1998) composed with
finite-frame model-checking of each per-standpoint Kripke frame (every
explicit accessible frame is finite; slice 1 already terminates on it). The
two accessibility relations never interleave: a standpoint boundary is never
crossed inside a box/diamond expansion, and a Kripke world-step is never
taken across standpoints. That isolation is the decidability argument and the
soundness argument both.
Scope (held precisely)
- In: the explicit
std::kripkeframe (slice 1’sbox/diamondoveraccessible) fibred over a query’sacross [...]standpoint set (or astandpoint {}block). Per-standpoint K3 result, info-joined across standpoints under the query’sFederationPolicy(paraconsistent:Bothsurfaces; strict: K3-projected at the boundary). - Out (still refused / not coupled): the classification / mutation-
reachability frame (slice 3); rigid roles and nominals (slice 3); a genuine
product or cross-nested
box_standpoint(box_kripke(P))(refused — see §“Cross-nesting guardrail”); a standpoint-frame modal and a Kripke modal composed in one operator (the cross-family coupling, refused).
The soundness crux — per-standpoint K3, then federate (Can vs Not)
For federation to manufacture the correct Both on genuine disagreement
and never a false Both from mere ignorance, each standpoint’s modal result
must reach federation as its faithful K3 value — Is / Not / Can —
distinguishing determined-false from open-world-undetermined:
federate(Is, Not) = Both— genuine perspectival disagreement (one standpoint says the modal necessarily holds, another says it is determined-false). Correct.federate(Is, Can) = Is— one standpoint confirms the modal; another simply does not know (its frame has an undetermined successor, no determined-false). This must not becomeBoth. (Federation.leanfederate_eq_both_iff: the result is.bothiff some source said.bothor two sources genuinely disagree —hasIs ∧ hasNot. ACancontributes to neitherhasIsnorhasNot, so it cannot induceBoth.)
The trap. Slice 1’s decide/decide_box/decide_diamond and its
denotational oracle collapse Can → Not: decide_box returns Not both
when an accessible successor is determined-false and when a successor is
merely Can (open-world unknown). This collapse is correct for the single
frame: slice 1’s derivation boundary is fail-closed (only Is derives the
head, so Not and Can are indistinguishable there — both yield “no
derivation”). But it is catastrophic at federation: a standpoint whose
box(P) is genuinely Can (an accessible world is unknown, none
determined-false), reported as Not, would federate with another standpoint’s
Is to a false Both — a silent-wrong manufactured from ignorance.
Resolution (the linchpin — flagged for review). Slice 2 introduces a
separate federation-outcome function that returns the faithful K3 value,
WITHOUT changing slice-1’s single-frame projection. For box(P) at world w
over a standpoint’s frame, scanning the accessible successors:
Isiff every accessible successor hasP = Is(necessity confirmed);Notiff some accessible successor hasP = Not(a determined-false witness defeats necessity);Canotherwise — no successor isNot, but some successor isCan(necessity is open-world undetermined: it could still go either way);- vacuously
Isat a dead-end world (∀ over no successors), per the K(m) semantics slice 1 already pins.
The ordering matters: a Not successor dominates a Can successor —
box is Not if any successor is determined-false, regardless of how many
others are Can, because determined-false already defeats necessity. For
diamond(P) at w (dual):
Isiff some accessible successor hasP = Is(a witness);Notiff every accessible successor hasP = Not(every possibility foreclosed);Canotherwise — noIswitness, but some successor isCan(a witness could still emerge);- vacuously
Notat a dead-end (∃ over no successors).
This is the standard Kleene K3 reading of box = ⋀ / diamond = ⋁ over the
three-valued successor labels (Is = ⊤, Not = ⊥, Can = the middle), with
the dead-end conventions slice 1 fixes. It is computed directly from the frame
(KripkeFrame::prop_at, which already returns the faithful Is/Not/Can),
so it needs no change to the tableau procedure or its oracle. The per-standpoint
federatable value is exactly this K3 value; slice-1’s fail-closed decide
and its denotational oracle are untouched (slice 1’s single-frame contract
is unchanged and its differential gate still holds).
A frame whose seed forces Is ∧ Not at one world is a clash (slice-1
strict-consistency rejection); that standpoint contributes nothing for the
clashing cell (it does not contribute a determined value, i.e. it is treated as
Can for that cell at federation — the strict-consistency reject is local to
the one frame and must not poison the federation with a phantom determined
value).
Routing — per-standpoint loop then federate
Two new pieces, both narrowing existing seams (no second engine, no second store):
-
Frame-aware dispatch fix (closes a latent silent-wrong).
translate_standpoint_modals(instantiate) previously checkedis_explicit_kripke_modalonly inside itsroots.is_empty()branch — so a Kripke modal appearing inside anacross [...]query or astandpoint {}block (roots non-empty) was mis-routed into the standpoint-frame Datalog translation (synth_standpoint_modal), silently producing a wrong extent instead of preservingAtomIR::Modalfor the tableau. The fix checksis_explicit_kripke_modalfirst, independent ofroots: a Kripke modal is preserved asAtomIR::Modal(→ tableau) in every standpoint context. A non-Kripke modal with no frame still refusesOE1104; a non-Kripke modal with a standpoint frame still translates as before. -
Per-standpoint query-routing loop. When a federated query carries a non-empty
acrossset and a KripkeCompiledAtom::Modal, the runtime, for each standpointsin the across set, materializess’s per-standpoint catalog (materialize_predicates_for_standpoint(s)— DEFAULT ∪ standpoint-own), runs the slice-1KripkeExecutorover that per-standpoint catalog, and reads the faithful K3 federatable value per head tuple. The contributions are then info-joined by the already-shippedquery_derive_federatedunder the query’sFederationPolicy. The federation for a modal head bypasses the plain(in_pos, in_neg)classify_source_rowspath — that path can only ever produceIs/Canfor a modal head (the executor’s fail-closed projection writesIs-only), so it cannot carry theNotfederation needs.
Cross-nesting guardrail (non-negotiable, from the decidability theory)
Cross-nesting of the two modal families — box_standpoint(box_kripke(P)) and
its converse — is the coupling the undecidability boundary lives at; only
additive (fibred / fusion) composition is sound and decidable. This is
already refused by the existing nested-modal gate
(lower_modal/atom_lower.rs): the gate refuses any modal whose inner
atom_contains_modal, uniformly, before frame recognition runs — so
box(box(…)), diamond(box(…)), and the cross-family box(box_kripke(…)) are
all rejected with the modal refusal at lowering time. The cross-family case is a
special case of that uniform refusal; no extension is required. Slice 2 pins it
with a dedicated cross-family test so a future relaxation of the gate cannot
silently open the undecidable door.
Soundness floor (slice 2)
- Differential oracle (fibred). Extend slice-1’s denotational oracle to the
fibred case: run the per-standpoint tableau, take each standpoint’s faithful
K3 federatable value, info-join them, and compare against a denotational
fibred oracle —
box/diamondevaluated per standpoint over each standpoint’s frame (the K3 reading above), info-joined. The test ranges over frames where standpoints disagree (→Both), agree (→ K3), and where one standpoint isCan/unknown while another isIs(→Is, notBoth). The Can-vs-Not distinction is the central oracle-covered property. - Mis-routing closed. A Kripke modal in an
across/standpointcontext routes to the tableau, not the standpoint-frame translation (tested). - Cross-nesting refused.
box_standpoint(box_kripke(P))(and converse) are refused (tested). - Soundness factoring. Slice-2 soundness = slice-1 tableau soundness (the
per-standpoint K3 value is the slice-1 frame semantics, distinguishing the
Canit previously projected away) ×Federation.leanfederate_eq_both_iff(federation isBothiff genuine disagreement) × a fibred-isolation argument (a standpoint boundary is never crossed inside a modal expansion, a Kripke step is never taken across standpoints — so the two frames compose additively, not as a product). Faithful toModalFrame.lean/Modal.lean/Federation.lean/Consistency.lean.
Roadmap (NOT built here)
- Slice 3 — rigid roles, nominals, the classification/rigidity frame. The rigidity frame (worlds = configurations an individual’s classification holds in), rigid roles, and nominals (singleton concepts). The full §11.2 static-discharge table.
Each slice lifts a strictly smaller piece of the OE1104 refusal; until a slice
ships, its complement keeps refusing.
Module placement
The tableau lives as a tableau module inside oxc-reasoning
(oxc-reasoning/src/tableau/), alongside the other executors. It is not a new
top-level crate: the executor is one more backend behind TierExecutor, it
shares the catalog and Truth4 carrier, and a new top-level directory is not
warranted for slice 1.