diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index db097a7df..a5b83b026 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,86 @@ +## 2026-07-27 — E-EVENT-IDENTITY-IS-NOT-SOURCE-IDENTITY-AND-WE-HAVE-NEITHER-1 — **`source_registry` withdrawn from PR #854 as a falsified design. The separation it revealed is the deliverable; the code was the scaffold.** + +**Status:** RULING (operator, 2026-07-27) + measurement. **Confidence:** High — every leg was verified in source or measured, not inferred. + +**Five objects, previously one carrier:** +```text +event identity ≠ evidential-base membership ≠ source dependence + ≠ object/view identity ≠ dataset version +``` + +**Leg 1 — the guard needs EVENT identity, not SOURCE identity.** `disjoint()` has exactly one consumer: NARS revision admissibility, which exists to stop *one evidence event* being counted twice through different derivation paths (Wang's evidential base = a set of input **serial numbers**). Keying it on sources means **one sensor observing twice can never raise confidence** — repetition becomes worthless, which is not a rare collision but the disabling of the most basic form of evidence accumulation. + +**Leg 2 — no canonical evidence-event identity exists here.** Verified against the types, not the names: `ClassId` is `= u16` while the GUID's `classid` is a `u32` composite (two types, one word); `ClassView` is a **late-bound projection trait**; `AppPrefix::Core` is documented as *"no render lens"*; `LanceVersion`/`DatasetVersion` is a **dataset snapshot**. So `ClassId:AppId:ClassView + version` names *a field projection of a class under a rendering interpretation, as of a commit* — no instance, no event. It fails 4 of 6 ingestion cases: two rows of one class in one commit collide; a **re-observation of an unchanged value produces no mutation at all**; one observation spans many rows; an external statement mutates nothing. Identity belongs to an **immutable receipt that refers to** an object revision — and no receipt type exists. `NodeGuid` cannot substitute: `debug_assert_identity_unique`'s own message admits *"or reused"*, so uniqueness is a **debug assertion only**. + +**Leg 3 — the digest is safe but useless, measured.** 20 000 trials/cell, genuinely disjoint bases, query `digest_a & digest_b == 0` (NOT membership FPR): **P(false overlap) ≈ n²/m** at k=1 — 6.1 % (n=2,m=64), **63.4 %** (n=8,m=64), 22.2 % even at m=256. **k>1 is catastrophic** — 98.1 % at n=8/m=64/k=2 — *even though raising k improves membership queries*. A Bloom-shaped digest never yields false disjointness, so it is safe; at realistic base sizes it reports overlap on two thirds of disjoint pairs, so it starves revision. Safety is not usefulness, and I had recommended it on the safety half alone. + +**Leg 4 — `bool` cannot carry the claim.** "Not known to overlap" ≠ "known disjoint". Both membership and dependence need tri-state (`Disjoint/Overlap/Unknown`, `Independent/Dependent/Unknown`); a Boolean silently converts ignorance into permission. + +**The reviewers found the smoke, and the prescribed fix would have cemented the fire.** Codex and CodeRabbit independently hit the 64-ceiling from four angles (examples panicking, `reach_out_integrate` swallowing `CapacityExceeded` into `DullShadow`, `asc_challenge` reporting capacity as `BlockedSelfReference`, cross-registry comparison). CodeRabbit prescribed *"reuse a bounded `SourceId`"* — **rejected**: minting one identity per distinct observation is correct behaviour, and bounding it would have made the semantic defect permanent while turning the symptoms green. **A fix that silences the smoke by making the wrong model fit is worse than the crash.** + +**Rollback is not endorsement.** The restored local `Stamp` still models source membership; it is kept as the pre-PR baseline solely because it introduces no breaking API and no global ceiling. Recorded so no future session reads the revert as a verdict that the old code was right. + +**Method note worth keeping:** the operator's questions did the work an adversarial review could not — each round I answered from a *name* (`address`, `version`, `source`) and each round the code said otherwise. Three of my own answers in this thread were wrong in the same direction: reaching for an identity already in hand instead of the act that produced the evidence. + +Refs: `E-A-LOCAL-BITSET-IS-NOT-SELF-DESCRIBING-PROVENANCE-1`, `E-THE-TWO-COPIES-HAD-ALREADY-DRIFTED-1`, `E-THE-UNCONTESTED-AXIS-IS-THE-ONE-THAT-MERGES-1`, PR #854. + +## 2026-07-27 — E-A-LOCAL-BITSET-IS-NOT-SELF-DESCRIBING-PROVENANCE-1 — **the `Stamp` folding was CONSERVATIVE, not unsound — and the real defect is one level up: a bitset does not carry the mapping that gives its bits meaning.** + +**Status:** FINDING + shipped contract (`source_registry`). **Confidence:** High. **Correction:** supersedes an audit claim, made earlier in the same arc, that `1u64 << (id % 64)` "manufactures false independence". It does not, and both crates' own doc-comments said so. + +**What folding actually does.** A collision makes two DISTINCT sources look *overlapping*. NARS revision then refuses to pool them → evidence is LOST, never double-counted. The no-double-count guarantee survives the bound; the failure is conservative in the safe direction. Getting this backwards mattered: it would have justified an urgent "correctness" fix over the real, duller problem. + +**What it genuinely destroys** — everything downstream of knowing *which bit is whom*: pooling past 64 sources, leave-one-out, withdrawal, and any interpretation of an evidence count (`count_ones()` becomes a lower bound of unknown tightness). Hence the rule: **a term id, domain id, witness id or corpus id must NEVER be silently interpreted as a bit position.** The bound is on SIMULTANEOUSLY REPRESENTED identities, not on id magnitude — `SourceId(50_000)` legitimately takes slot 0 when it registers first, and exhaustion is a reported `CapacityExceeded`, not a wrap. + +**The subtler defect the registry exposes: a stamp is meaningless without its registry.** Registry A may give source X slot 0 while registry B gives source Z slot 0 — so `Stamp(0b1)` denotes different evidence in each, and `disjoint()` will answer confidently either way. A local bitset is not self-describing provenance. Pinned as a test (`slot_zero_means_different_sources_in_different_registries`) so the hazard stays visible rather than becoming folklore. + +**Ruling: arena-local by CONTAINMENT, and registry-bearing stamps REJECTED.** The owning arena holds the registry, mints every stamp, and performs every union/disjoint; callers pass a `SourceId` and no stamp crosses an API boundary. Enforced structurally — private bits, no `Serialize`, constructible only from a registry-issued `SourceSlot`. + +The rejected alternative (`{registry_id, bits}`) exists precisely to make arena A's evidence meaningful inside arena B — i.e. a handoff between two independently-owned state containers. **That is the shape #477 deleted at the mailbox layer** (no inter-mailbox carrier, one writer per mailbox), reappearing one layer down under a new name. Containment is not merely cheaper; it is the option consistent with the ratified ownership model. Same fold, different altitude — cf. `E-AGENT-LOG-SHARED-SINK-ANTIPATTERN-1`, where the shared-mutable-sink came back one layer *up*. + +**Flip condition, named so it is falsifiable:** if replay must reconstruct evidence from PERSISTED state rather than rebuilding the arena, containment breaks. The answer even then is not a registry field on every stamp — it is a **frozen source census**: a versioned artifact from which a deterministic sorted-`SourceId` → `SourceSlot` allocation is regenerated and checked against a digest. Mapping identity stays addressable by epistemic view instead of riding in the hot carrier. + +**Sequencing discipline held.** The two `BeliefArena`s (planner + deepnsm-v2) are migrated TOGETHER. Fixing one alone leaves two incompatible independence semantics wearing matching comments — worse than fixing neither, because the asymmetry is silent. + +Refs: `contract::source_registry`, `E-THE-UNCONTESTED-AXIS-IS-THE-ONE-THAT-MERGES-1` (the arena-locality assumption was itself an uncontested axis until probed), `E-CE64-MB-4`, PR #477. + +## 2026-07-27 — E-THE-UNCONTESTED-AXIS-IS-THE-ONE-THAT-MERGES-1 — **the general form of eigenvalue blindness, demonstrated on the reviewer.** Across five adversarial rounds refining the `E-WE-HAVE-PEARL-VOCABULARY…-1` fix list, every merged carrier that got caught had been *argued about*; the one that slipped through was the axis nobody had contested yet. It was folded **one sentence after the prohibition against folding was written**, by the author of the prohibition. + +**Status:** FINDING + rule. **Confidence:** High — the instance is in this session's own transcript, not inferred. + +**The mechanism.** A contested axis accumulates names, counterexamples, independent carriers, tests, boundaries. An uncontested axis stays a background assumption, and *because nobody perturbs it, it looks naturally unified*. So the dominant interpretation is not merely the best-supported one — it is often the one whose hidden dimensions were never independently activated. The error under adversarial pressure does not die; **it relocates to wherever the pressure isn't.** + +**The migration, in order, all in one session:** missing architecture (grep used as comprehension) → confidence confused with effect → subject-matter domain confused with causal locus → single `SupportBasis` confused with provenance geometry → arena-local bit meaning assumed from privacy alone. Each round's fix was correct; each round's *next* defect sat one axis further from where anyone was looking. + +**The schema-level falsifier this yields — the orthogonality audit.** For any proposed compound carrier over dimensions A and B, require TWO witnesses: *A varies while B is fixed*, and *B varies while A is fixed*, both on non-trivial inputs. Consequences: +- Both witnesses exist ⇒ genuinely independent; never irreversibly merge them. +- **Only ONE direction has a witness ⇒ the other axis is DERIVED**, and belongs in a method, not a stored field. +- Neither ⇒ one axis, described twice. + +**It reproduced an independent decision on first use.** Run against `KanbanMove`: `(from, to)` varies while `libet_offset_us` holds (any mid-cycle arc — witness exists), but no legitimate input makes the offset vary while `(from, to)` holds. One-directional ⇒ derived ⇒ delete the field. That is exactly the A5 conclusion reached four rounds earlier by unrelated reasoning. A schema rule that re-derives a decision made on other grounds has some claim to being real rather than merely well-phrased. + +**Operating corollary:** after resolving the currently disputed dimensions, deliberately perturb the ones that survived without discussion — not because they are probably wrong, but because they received the least epistemic pressure. "We fixed the contested axes" is the moment to start looking, not to stop. + +Refs: `E-WE-HAVE-PEARL-VOCABULARY-NOT-PEARL-MECHANICS-1` (the arc this refines), `causal_audit` (the typing that resulted), `E-ZERO-DELTA-DOES-NOT-MEAN-NO-EFFECT-1`. + +## 2026-07-27 — E-ZERO-DELTA-DOES-NOT-MEAN-NO-EFFECT-1 — **a census of all 34 recipe kernels falsified an audit claim made from grep counts: 15 kernels return `delta_conf = 0.0` on every branch while mutating `ThoughtCtx`.** + +**Status:** FINDING (full-file census, then encoded as executable masks + tests). **Confidence:** High — the numbers come from reading all 34 `apply` bodies, and the masks are now enforced by a suite that fails on drift. + +**The defect in the reasoning, worth preserving because it is seductive.** `Tactic::run` calls `apply(ctx)` **first**, then adds `Outcome.delta_conf` to `ctx.confidence`. So `apply` holds `&mut ThoughtCtx` for its whole body and the returned delta says *nothing* about whether the context survived. An audit that counted `0.0` literals and concluded "evaluation-inert" was measuring one of eight possible effects and reporting it as all of them. `Htd` reorders the entire candidate vector — every downstream `max_idx`, prune and fuse reads a different array — and reports zero. + +**Census result:** 27 Operational · 6 Demonstration · 1 Stub. The 15 silent mutators write `candidates`/`beliefs`/`sd`/`rung`/`dissonance`/`temperature`. Three kernels that *look* operational land nothing: **`Etd`** sorts a CLONE of `candidates` and never writes it back (the computed decomposition is discarded); **`Cas`** computes `_level` and drops it; **`Sdd`** detects distortion, reports it in the note, and hardcodes `delta_conf = 0.0` outside the branch. Wiring any of them up is a behaviour change requiring an explicit decision — recorded as `Demonstration`, not silently fixed. + +**The fix is declaration + falsifier, not documentation.** `Tactic` gains two non-defaulted methods: `writes() -> ThoughtMask` (POSSIBLE writes, the mirror of `requires()`'s may-read) and `maturity() -> KernelMaturity{Operational,Demonstration,Stub}`. Maturity lives on the **impl**, never on the `Recipe` catalogue entry: a `Recipe` describes what the tactic *is*, maturity describes what *this code currently does* and changes the day someone finishes it — folding an implementation property into a concept record is the same merged-carrier mistake one level down. + +**Seven tests make the declarations falsifiable** (`recipe_kernels::effect_census`): no kernel writes outside its mask; every declared write is reachable on some probe; `Operational` implies a real effect; non-Operational implies none; the four context-blind kernels are input-invariant; the maturity split is non-trivial in both directions; and a regression guard pinning that zero-delta-implies-inert stays false. + +**The census also caught its own fixtures first.** `maturity_operational_implies_an_effect` failed on `Mcp` — not a kernel defect, a **probe gap**: every probe inherited `ThoughtCtx::new`'s `confidence = 0.5`, so Mcp's `confidence > 0.7 && free_energy > 0.5` branch was unreachable and Mcp looked inert. A can-fire test found the hole in the test matrix before it found one in the code, which is the argument for writing them. + +**Side finding, from the write mask existing at all:** `Lsi` declared `Sd` in `requires()` but only ever *wrote* it — an output over-declared as an input, invisible until reads and writes had separate carriers. Corrected. + +Refs: `recipe_kernels::{KernelMaturity, Tactic::writes}`, `E-THE-UNCONTESTED-AXIS-IS-THE-ONE-THAT-MERGES-1`, the falsifiability rule in CLAUDE.md. + ## 2026-07-27 — E-WE-HAVE-PEARL-VOCABULARY-NOT-PEARL-MECHANICS-1 — **operator suspicion confirmed by audit.** Asked directly ("I'm not convinced that we implemented MIT proposed causality learning properly"), the answer is: correct. We have the Pearl taxonomy comprehensively — `pearl_level()`, the SPO 2³ mask → SEE/DO/IMAGINE mapping, `InferenceOp::Counterfactual`, `RungLevel::Counterfactual`, a `pearl_junction` module — and **the Pearl operator not at all.** The one kernel carrying the counterfactual label XORs three hardcoded constants, ignores its context, and multiplies its confidence contribution by `0.0`. **Status:** FINDING (audited on the main thread, file-level). **Confidence:** High. diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 389e7354c..6a6748b43 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,35 @@ +## 2026-07-27 — branch `claude/medcare-rs-transcode-ruff-3y2olh` — **C1 WITHDRAWN (falsified design)** + D1 shipped: the four-signal settlement field + +### ⊘ `source_registry` was ATTEMPTED and WITHDRAWN — it is NOT a shipped contract +`lance_graph_contract::source_registry` and both `BeliefArena` migrations were **reverted from PR #854 before merge**. They are recorded here as a **falsified design**, not an inventory entry — nothing in the tree provides them. + +**Why it was withdrawn (operator-ruled).** An evidential base needs **evidence-EVENT identity**; `SourceId` modelled **source membership**, a different object. Four facts converged: +1. `SourceId` ≠ `EvidenceEventId`. +2. **No canonical evidence-event identity exists in this substrate** — verified, not assumed. `ClassId:AppId:ClassView + LanceVersion` cannot name one: `ClassId` is a class (`= u16`; the GUID's is a `u32` composite), `ClassView` is a late-bound projection *trait*, `AppPrefix::Core` is literally "no render lens", and `LanceVersion` is a **dataset snapshot**. Two rows of one class in one commit are indistinguishable; a re-observation of an *unchanged* value produces no mutation at all; one observation can span many rows. Identity belongs to an **immutable receipt**, which does not yet exist. +3. **A fixed-width digest is safe but useless for this query.** Measured (20 000 trials/cell, genuinely disjoint bases, `digest_a & digest_b == 0`): P(false overlap) ≈ **n²/m** at k=1 — 63.4 % at n=8/m=64, 22.2 % even at m=256; **k > 1 is catastrophic** (98 % at n=8/m=64/k=2) even though it improves membership FPR. A digest that reports overlap on two thirds of disjoint pairs disables evidence accumulation. +4. **`bool disjoint()` cannot express the needed distinction.** "Not known to overlap" ≠ "known disjoint". The API must be tri-state. + +**The operational failures both reviewers found were smoke from this, not isolated bugs:** four examples panicking past 64 sources; `reach_out_integrate` swallowing `CapacityExceeded` into `DullShadow`; `asc_challenge` reporting capacity exhaustion as `BlockedSelfReference`; cross-registry stamp comparison. **CodeRabbit's prescribed fix — "reuse a bounded `SourceId`" — was NOT taken:** minting one identity per distinct observation is correct; the 64-ceiling is the defect. + +**Rollback ≠ endorsement.** The restored local `Stamp` still models source membership, and it is still **lossy**: `Stamp::source(id) = 1 << (id % 64)` gives a bounded 64-slot horizon in which ids `0` and `64` alias. What the rollback removes is the **runtime capacity failure** (`CapacityExceeded` on the pre-cast path) — NOT the bounded membership semantics, which are unchanged and remain conservative (aliasing can only manufacture false overlap, never false disjointness, so revision under-pools rather than double-counts). It is the pre-PR baseline, kept only because it introduces no breaking API and no synchronous refusal while the correct model is designed; exact evidence-event identity remains the standing remedy for provenance collisions (codex/CodeRabbit, PR #854). + +**The architectural gain, which outlives the code:** `event identity ≠ evidential-base membership ≠ source dependence ≠ object/view identity ≠ dataset version`. The registry was the sacrificial scaffold that separated them. Next shape: `EvidenceEventId` (canonical immutable receipt) + `EvidentialBase` (exact inline, `overflow` → `Unknown`, ledger fallback — **no eviction**) + `OverlapKnowledge` / `Independence`, both tri-state. Open identity question: what guarantees two independently-minted events cannot collide — to be settled from the mailbox/ingestion/persistence ownership model, not from numeric capacity. + +### Current Contract Inventory — one new module (D1) +- `lance_graph_contract::settlement::{SettlementSignals, SettlementCell, SettlementScope}` — settlement as a FOUR-signal field. Discriminator is **closure × competence**, NOT entropy: Crystal / **Glass** (dense closure on thin evidence — the dangerous cell a scalar hides, since it looks like Crystal from one side and Fog from the other) / GroundedUnresolved / Fog. `field_entropy` + `eigenvalue_concentration` REFINE a cell and are pinned by test never to move it — the earlier "crystal = low entropy + high closure, glass = low entropy + low closure" formulation had entropy on both axes and silently deleted competence. `SettlementScope` (arena/basin/version/branch/witness-horizon) is carried WITH the signals and `comparable_to` refuses mismatched pairs — the alignment precondition that made `wisdom − competence` meaningless, made structural. **No `glass_gap()` scalar is provided**, deliberately: the subtraction is how four signals become one again, and neither axis is calibrated yet. 7 tests incl. an orthogonality receipt + threshold-inertness. + +Gate: contract 1093 green, planner 319, deepnsm-v2 96, fmt clean, no new warnings. + +## 2026-07-27 — branch `claude/medcare-rs-transcode-ruff-3y2olh` — causality-audit fixes A1/A2/A4/A5 + B1: typed causal edges, declared kernel effects, derived Libet window + +### Current Contract Inventory — new module + two trait methods + one field REMOVED +- `lance_graph_contract::causal_audit::{AuditedRelation, RelationClassification, CausalLocus, WorldDomain, CausalScope, NonCausalKind, SupportLedger, SupportReceipt, SupportProfile, SupportBasis, SourceId, RelationId}` — the typed causal edge the `E-WE-HAVE-PEARL-VOCABULARY…-1` ruling ("AUDIT BEFORE BUILD") required. **Four orthogonal axes, never merged:** kind (sum type — a non-causal relation *cannot* carry a locus; `Unclassified` is an honest resting place), **locus** (World/Interpretive/Derivational/Experiential — *where in the architecture*, NOT subject matter), domain, scope (Type/Token — NOT grammatical voice). **Support is many-of:** a receipt ledger, not a single enum, so text-attested + derivational + cross-environment coexist; `SupportProfile` is a DERIVED projection that keeps `receipt_counts` and `distinct_sources` separate (3 independent attestations ≠ 1 attestation ×3). `is_intervention_established()` requires a causal classification AND an `InterventionBacked` receipt — a corpus edge can never reach it. Classification and support are separately addressable (support accumulates while unclassified; reclassification never rewrites receipts). 8 tests incl. two orthogonality receipts. Known gap, labelled in source: `SupportReceipt::at` is a `DatasetVersion` (storage revision), NOT an epistemic view — the `QueryReference` upgrade is owed. +- `lance_graph_contract::recipe_kernels::{KernelMaturity, Tactic::writes, Tactic::maturity}` — the effect census made executable. `writes()` = POSSIBLE writes (mirror of `requires()`'s may-read); `maturity()` = Operational/Demonstration/Stub, on the **impl** not the `Recipe` catalogue entry. Census: 27/6/1. 7 falsifier tests (`effect_census`). Fixes `Lsi`, which declared `Sd` as a required *input* it only ever wrote. Detail: `EPIPHANIES` `E-ZERO-DELTA-DOES-NOT-MEAN-NO-EFFECT-1`. +- `lance_graph_contract::kanban::{LIBET_COMMIT_WINDOW_US, KanbanMove::libet_window_us}` — **REMOVES the `KanbanMove::libet_offset_us` field** (breaking for external constructors, deliberately). The window is a projection of `(from, to)`, so a stored field could only ever disagree with the transition it describes. Orthogonality audit: `(from,to)` varies while the offset holds, but nothing makes the offset vary while `(from,to)` holds ⇒ one-directional ⇒ derived. The literal `-550_000` had THREE definitions (scheduler / soa_view test double / planner constant) with TWO different stamping conditions; now one. `size_of` assert stays an upper BOUND — `KanbanMove` is a Rust-repr microcopy, not an ABI. Migrated across 9 crates. +- `lance_graph_cognitive::world::{substitute_binding, multi_substitute_binding, BindingSubstitution, SubstitutedWorld}` (was `intervene` / `Intervention` / `CounterfactualWorld`) — **renamed away from do-calculus, algebra kept.** It severs no mechanism, recomputes no descendants, holds no exogenous background fixed; it IS an exact reversible XOR substitution primitive, which is genuinely useful and now says so. Pearl citations stripped from the module docs. + +Gate: contract 1086 tests green (fmt clean, no new warnings); planner 317 + supervisor/shader-driver suites green. `lance-graph-cognitive::grammar::qualia::test_depth_detection` fails — verified PRE-EXISTING at clean HEAD via stash, untouched by this work. Board: `EPIPHANIES` `E-THE-UNCONTESTED-AXIS-IS-THE-ONE-THAT-MERGES-1` + `E-ZERO-DELTA-DOES-NOT-MEAN-NO-EFFECT-1`. + ## 2026-07-26 — branch `claude/lance-graph-last-10-pr-z30uij` — D-SCI-1 Phase 2: witness-gated construction licenses (PROIEL Greek NT) ### Current Contract Inventory — new grammar witness module + planner example diff --git a/.claude/knowledge/identity-temporal-evidence-primer.md b/.claude/knowledge/identity-temporal-evidence-primer.md new file mode 100644 index 000000000..d12f29485 --- /dev/null +++ b/.claude/knowledge/identity-temporal-evidence-primer.md @@ -0,0 +1,508 @@ +# Identity / Temporal / Evidence primer — READ BEFORE PROPOSING ANY CARRIER + +> **READ BY:** any agent touching identity, provenance, evidence, versions, +> masks, or ownership. **MANDATORY** before proposing a new struct in +> `lance-graph-contract`. +> +> Written 2026-07-27 after a session repeatedly proposed new carriers before +> establishing what the substrate already represents. Every claim below is +> `file:line`-grounded. Do not infer semantics from names. + +--- + +## 0. Two operator rulings (non-negotiable premises) + +1. **The V3 substrate is always a V3-shaped GUID using `ClassId:AppId:ClassView`.** +2. **Always SoA-owned; the SoA owns the kanban per SoA; always zero-copy, never + serialized.** Verified: `soa_envelope.rs:18-19` — *"Nothing is serialized or + transmitted; the backing bytes are resident in-place, zero-copy from creation + to Lance tombstone"*; `soa_envelope.rs:16-17` — *"a Lance version IS a + coherent LE in-place layout at cycle N"*. + +**Consequence that kills a whole class of proposals:** a "side ledger", +"append-only receipt file", or any serialized provenance store is **forbidden by +construction**. The cold/authoritative path is *Lance versions of the same +in-place LE bytes*, never a second serialized representation. + +--- + +## 1. THERE IS ONE CLASS-IDENTITY CARRIER + +`ClassId` and the GUID's `classid` are **the same carrier**, seen through +different projections. Do not model them as two systems. + +``` +NodeGuid = [u8; 16] canonical_node.rs:33-35 "16-byte canonical instance key" + ≡ FacetCascade facet.rs:94-100 byte-identical, reinterpret no-op + ├── facet_classid : u32 [0..4) ← the class address + │ ├── canon : u16 (HIGH) = ClassId — the shared CONCEPT + │ └── custom : u16 (LOW) = AppId — the render lens + └── tiers : [FacetTier; 6] [4..16) ← 6 × (8:8), coarse→fine: + HEEL · HIP · TWIG · LEAF · family · identity ← the INSTANCE lives here +``` + +- `pub type ClassId = u16` — `class_view.rs:54`. *"Per-row class discriminator — + the Cognitive-RISC `class_id`/`shape_id`… ≤65,535 shape-families; + OD-CLASSID-WIDTH ratified."* This is the **concept component**, not a rival id. +- `compose_classid(canon, custom) -> u32` / `split_classid` — `ogar_codebook.rs:337-352`. + Active order `CLASSID_ORDER = ClassidOrder::CanonHigh` (`ogar_codebook.rs:313`). +- `classid_canon` / `classid_custom` / `classid_concept` — `ogar_codebook.rs:357,364,428`. + **Accessors on one value**, not separate identities. +- `classid_canon_compat` — `ogar_codebook.rs:385-394`. Reads BOTH stored orders; + the flip is mint-forward and never reinterprets persisted ids. +- `AppPrefix::render(concept) -> u32` — `ogar_codebook.rs:241`. `Core = 0x0000` + is documented *"shared canonical core (default ClassView, no render lens)"*. +- `ClassView` is a **trait** — `class_view.rs:903`. *"the parser+schema… projects + row → typed view, late-bound"*. It is what the address **resolves to**, and it + chooses **which reading** of the 12-byte register applies (`facet.rs:95-96`: + *"which ClassView interprets the 6 tiers' 8:8"*). +- `NodeGuid::facet()` — `canonical_node.rs:441-443`, with the byte-identity test + `nodeguid_facet_bridge_is_byte_identical` (`canonical_node.rs:451`). + +### Which operations preserve identity vs collapse to concept +| Operation | Uses | Correct because | +|---|---|---| +| routing / storage / equality of an addressed form | **full `classid` u32** | the app lens is part of the addressed form | +| RBAC, ontology, cross-app concept convergence | `classid_canon`/`classid_concept` | the shared concept is the subject | +| reading corpora spanning the order flip | `classid_canon_compat` | serves both stored forms | +| rendering | `AppId` + `ClassView` | selects the skin, not the subject | + +**Comparing only the canon half is WRONG** wherever two apps' addressed forms +must stay distinct (storage, dedup of addressed rows). **Comparing the full u32 +is WRONG** wherever the shared concept is the subject (RBAC grants, concept +convergence). Neither is universally right — that is why both accessors exist. + +--- + +## 2. Instance identity + +- The instance is the GUID tail: `family` + `identity` (`GuidParts`, + `canonical_node.rs:601-615`), a.k.a. `local_key()` = trailing 6 bytes, + *"the only discriminator once the prefix is resolved"*. +- **Scope:** `local_key` is unique **only within a resolved classid prefix** — it + is not globally canonical. +- **Uniqueness is NOT enforced.** `debug_assert_identity_unique` + (`canonical_node.rs:338`) is `debug_assert!`-gated and its **only call sites are + in `mod tests`** (`canonical_node.rs:2026, 2034`). Its own doc says *"Call on + insert with whatever set/bitmap the mint path keeps"* — an obligation on the + caller, honoured by no production mint path. +- Its panic message admits reuse: *"or reused — mint a non-zero family to expand + before this fires in prod"*. +- **No tombstone, deletion, or compaction path exists** in `canonical_node.rs` + (verified ABSENT). So identity-reuse-after-delete is unspecified, not prevented. +- **No monotonic counter / serial / sequence field exists on any identity type** + (`NodeGuid`, `GuidParts`, `MailboxId`, `EdgeRef`, `EpisodicEdges64`, + `RelationId`, `WitnessEntry`). Verified ABSENT. + +--- + +## 3. Temporal model — five distinct things, three of them MISSING + +| Concept | Carrier | Status | +|---|---|---| +| dataset snapshot | `LanceVersion = u64` (`temporal.rs:47`, *"the storage frame's clock tick"*); `DatasetVersion(u64)` (`scheduler.rs:33-36`) | EXISTS | +| reader horizon | `QueryReference.ref_version` (`temporal.rs:114-116`, *"The `KnowledgeHorizon` — the Lance version the reader is pinned at"*) | EXISTS | +| epistemic policy | `EpistemicMode {Strict, Aware, Retro}` (`temporal.rs:52-61`) + `TemporalStatus {Contemporary, Anachronistic, Spoiler, Unknowable}` (`temporal.rs:91-102`) | EXISTS | +| registration horizon | `DeinterlaceRow::knowable_from()` (`temporal.rs:294-306`) — distinct from `lance_version()` | EXISTS (trait method, not a persisted field) | +| multi-writer scope | `QueryReference.server_id: u16` + `hlc_tick: Option` (`temporal.rs:110-119`); module doc calls `(server_id, lance_version, hlc_tick)` *"the deinterlace key"* | **MECHANISM IMPLEMENTED + TESTED; PRODUCTION WIRING DORMANT** — `deinterlace` sorts on `(hlc_tick ?? lance_version, lance_version)` and `deinterlace_hlc_orders_across_frames` / `deinterlace_mixed_hlc_falls_back_to_lance_version` pass; what is dormant is that no substrate call site yet sets non-zero `server_id` / `Some(hlc_tick)` (`default()`/`at()` hardcode `0`/`None`, `temporal.rs:126-151`). See §5.7. | +| **per-row last-modified version** | — | **MISSING** (`row_version` is only a *parameter name* in `classify`, never a field) | +| **transaction / commit identity** | — | **MISSING** (`transaction/mod.rs` holds execution-regime typestates, not commit ids) | +| **append-log / observation / receipt serial** | — | **MISSING** | +| **two changes to one row within one version** | — | **NOT EXPRESSIBLE** — `DeinterlaceRow` gives exactly one `lance_version()` per row | + +**Do not substitute the nearest available version field for a missing one.** +Read-at ≠ snapshot ≠ changed-at ≠ observation identity. + +--- + +## 4. Evidence carriers — what each actually answers + +| Carrier | Answers | Does NOT answer | +|---|---|---| +| `Stamp(pub u64)` (planner `belief.rs:31`; deepnsm-v2 `belief.rs:33`) | "do two bases share a *source bit*" | which event; which source (bits are folded); replay identity | +| `Belief.stamp` | evidential base **and** (via `!= default()`) "is this observation-grounded" | — **two orthogonal questions on one field** | +| `Belief.premises: Vec` | derivation inputs, *"Arena indices of premises"* (planner `belief.rs:99`) | anything replay-stable — arena indices are allocation-order | +| `SupportReceipt` / `SupportLedger` (`causal_audit.rs`) | which kind of support, from whom, when, how strong | **which event** — the receipt has no identity of its own | +| `WitnessEntry {mailbox_ref, spo_fact_ref}` (`witness_table.rs:80-95`) | which mailbox + SPO fact a W-slot resolves to | evidence membership | +| `EpisodicEdges64` (`episodic_edges.rs:103`) | up to 4 MRU episodic edges | provenance | + +**`Stamp::source(id) = 1u64 << (id % 64)`** (planner `belief.rs:36`; deepnsm-v2 +`belief.rs:38`) — 64 sources max, silent modulo aliasing beyond. Documented as +CONSERVATIVE (false overlap only, never false disjointness) — **that doc is +correct**; the defect is that source ≠ event, not that folding is unsound. + +### Verified drift between the two BeliefArenas +```rust +// planner/src/nars/belief.rs:193 — HAS the empty-stamp guard +if stamp != Stamp::default() && b.stamp.disjoint(stamp) { +// deepnsm-v2/src/belief.rs:189 — LACKS it +if b.stamp.disjoint(stamp) { +``` +`Stamp::default()` (all-zero) is `disjoint` from everything, so in deepnsm-v2 a +repeated unsourced observation **pools unboundedly**. The planner routes it to +CHOICE and has the test `empty_incoming_stamp_does_not_pool` +(planner `belief.rs:447`); deepnsm-v2 has no equivalent test. Both files carry +the *same* explanatory doc text — the prose stayed in sync while the code did not. + +--- + +## 5. Fixed-width discipline — the actual rule + +**A bitmask is justified only where each bit has a stable, predefined meaning in +a bounded vocabulary.** The repo mostly gets this right: + +| Carrier | Bit meaning | Verdict | +|---|---|---| +| `ThoughtMask(u8)` / `ThoughtField` (`recipe_kernels.rs:137,111-128`) | STATIC, *"bit positions are stable (do not reorder — append-only basis)"* | ✅ correct use | +| `FieldMask(u64)` (`class_view.rs:70`) | STATIC, *"once instances persist, a field's bit position never moves and retired bits are never reused"*; positions ≥64 **ignored, NOT folded** (`:79-83`) | ✅ correct use | +| `StepMask(u64)` (`step_mask.rs:40`) | STATIC per template version; positions ≥64 **ignored, NOT folded** (`:53-56`) | ✅ correct use | +| `WideFieldMask` (`class_view.rs:221`) | STATIC; >256 is *"a loud refusal, never a silent drop"* (`:525-529`) | ✅ correct use | +| `WitnessTable<64>` (`witness_table.rs:112`) | slot index; **N=64 is DOMAIN-derived** — *"matching the 6-bit address space of the W-slot field"* (`:100-102`); out-of-range → `Err`, no panic | ✅ correct use | +| `EpisodicEdges64` (`episodic_edges.rs:103`) | DYNAMIC 4×16-bit slots; `push` returns `None` when full; `promote` evicts slot 3 **to a `DemotionSink`** | ✅ explicit eviction, not silent | +| **`Stamp(u64)`** | **DYNAMIC — bit = a runtime-assigned source id, folded `% 64`** | ❌ **the one violation** | + +**The discriminator:** `FieldMask` and `StepMask` refuse to fold and say so in +their docs; `Stamp` folds. Dynamically-arriving identities do not belong in +globally-interpreted Boolean positions. + +**Never confuse a `u64` identity (2⁶⁴ values) with a `u64` Boolean mask (64 +positions).** `LanceVersion` is the former; `Stamp` is the latter. + +Known silent-clamp sites worth auditing before reuse: `CausalEdge64::with_w_slot` +uses a **debug-only** `debug_assert!(w <= 63)` (`causal-edge/src/edge.rs:953`); +`with_inference_mantissa` **silently wraps** out-of-range values (`:966-967`); +`set_temporal` under v2 **silently drops** the write (`:588`). + +--- + +## 5.5 SoA ownership — and the island that is NOT SoA-owned + +**The owning SoA** is `MailboxSoA` (`cognitive-shader-driver/src/mailbox_soa.rs:58`). +True struct-of-arrays — one fixed-size array per column: + +``` +mailbox_id : MailboxId ← the SoA's own identity +energy [f32; N] plasticity_counter [u8; N] +last_active_cycle [u32; N] last_write_cycle [u32; N] +edges [CausalEdge64; N] qualia [QualiaI4_16D; N] +meta [MetaWord; N] entity_type [u16; N] +temporal [u64; N] expert [u16; N] sigma [u8; N] ++ three per-row style lanes, appended AFTER Kanban +``` + +**This SoA owns its Kanban** (`mailbox_soa.rs:179,222-228`): +- `current_cycle: u32` +- `phase: KanbanColumn` — **`pub(crate)`, not `pub`** — *"Mutated only via + `MailboxSoaOwner::advance_phase` / `try_advance_phase`; starts at + `KanbanColumn::Planning`. Read it through the `MailboxSoaView::phase` getter."* + +One SoA → one Kanban → one owner (`MailboxSoaOwner`) → sole mutator. Verified. + +### ⚠ `BeliefArena` is an object-shaped island, NOT V3 SoA ownership + +```rust +// planner/src/nars/belief.rs:129-136 — and deepnsm-v2/src/belief.rs likewise +pub struct BeliefArena { + entries: Vec, // ← array-of-STRUCTS on the heap + index: HashMap, // ← a second heap map + passes: u32, reached_fixed_point: bool, +} +``` + +It has **no SoA columns, no Kanban, no `mailbox_id`, no V3 GUID addressing**. +`Belief.premises` are `Vec` *arena indices*. It is AoS + hashmap — the +opposite shape from `MailboxSoA`. + +**Consequence for any evidence work:** the question is never "how should the +arena own evidence identity". Both candidate answers — the old `Stamp` and the +withdrawn `SourceRegistry` — are heap structures inside a structure that is +already outside V3 ownership. `SourceRegistry` added a **third** heap map +(`Vec`) inside the island and called the result "containment". + +The V3-shaped question is: +> Which `MailboxSoA` column, mask, edge, or Kanban transition represents this, +> and which SoA owns the row? + +Until `BeliefArena` is either (a) backed by SoA columns or (b) explicitly +declared a non-substrate diagnostic surface, **no evidence carrier placed inside +it can be substrate-conformant**, however well-typed it is. + +--- + +## 5.7 Parallel execution + temporal deinterlacing + +Claims are labelled. **Repository absence is NOT disproof of owner-specified +architecture** — it marks where the invariant is not yet made explicit. + +``` +1. V3 GUID resolves the addressed SoA state. +2. The SoA-owned Kanban performs the SYNCHRONOUS legal transition. +3. Large populations of transitions execute IN PARALLEL. +4. Each transition casts its continuation fire-and-forget. +5. BatchWriter records intents + coalesces physical writes asynchronously. +6. Lance versioning records the resulting temporal positions. +7. temporal.rs deinterlaces the parallel standing wave. +8. QueryReference / knowledge horizons prevent hindsight leakage. +9. The cohort converges within the ~64k / 550 ms WALL-CLOCK envelope. +``` + +### ⚠ The SLA is a cohort envelope, not a per-update budget +**Do NOT compute `550 ms / 64 000`.** That division assumes serial execution. +The ~64k updates occupy **one overlapping wall-clock interval**; their compute +and write costs are not summed sequentially. The question is *"can the parallel +cohort converge, deinterlace, and become available inside 550 ms"* — never *"can +each update finish in 8.6 µs"*. + +> An earlier revision of this file contained that division. It was wrong and is +> retracted. + +### Capacity: addressing + cache envelope, NOT a concurrency ceiling + +**~64k is a preferred operating point, not a limit.** It falls out of *16-bit +addressing* + a *cache-friendly working set* — never from the SLA, never from +"how many updates may run in parallel", never from V3 semantics. + +| Scale | Addressing | Working set (× 512 B row) | Regime | +|---|---|---|---| +| ~64k (2¹⁶) | 16-bit | **32 MiB** | preferred, cache-friendly | +| ~262k (2¹⁸) | 18-bit | 128 MiB | wider addressing, more cache pressure; comfortable on newer CPUs | +| ~4M (2²²) | ~22-bit | **2 GiB** | memory-resident, outside ordinary L3 — a different **memory-latency** regime | + +**[ARITHMETIC CHECK — the only part of this the repo corroborates]** The +footprints are *exactly* derivable from a code-asserted constant: +`const _: () = assert!(core::mem::size_of::() == 512);` +(`canonical_node.rs:735`, with `NodeGuid == 16` and `EdgeBlock == 16` at +`:733-734`). `2¹⁶ × 512 B = 32 MiB` and `2²² × 512 B = 2 GiB` land on the nose. +So the capacity ladder is **internally consistent with the canonical node**, not +arbitrary. + +**The 512 B stride is UNIFORM — there is no second, smaller row shape.** +Every SoA row reserves 512 B *on paper*, always: +- `pub value: [u8; 480], // 32..512 (reserved — comes after)` (`canonical_node.rs:729`) +- *"32..512 are the class-resolved value slab. **Sum = 512 = stride.**"* (`:764`) +- The 480 B slab is where `energy` / `meta` / `qualia` / `entity_type` live + (`:708`) — i.e. the named `MailboxSoA` columns are **tenants inside the + reserved slab**, not a competing row shape. +- `EdgeBlock`: *"Canonical, not mandatory: the 16 bytes are ALWAYS reserved + (zeroed when unused)"* (`:640`); *"always reserved, never shrunk"* (`:8`). +- **RESERVE, DON'T RECLAIM** (`:16`): *"a zero tier means 'not consulted', never + 'compacted away'"*. +- The envelope **enforces** it — `verify_layout` has an error variant for *"Sum + of column byte-widths does not equal the declared row stride"* + (`soa_envelope.rs:124`). + +Only the **baked** (Lance columnar) form may omit empties — that is storage-layer +compression, and it does **not** change the logical stride. So the 32 MiB / 2 GiB +arithmetic applies uniformly to `MailboxSoA`, with no per-crate recomputation. + +> **Retraction:** an earlier revision of this file claimed `MailboxSoA`'s per-row +> footprint was "a different, materially smaller sum" than `NodeRow`'s 512 B, and +> warned against transferring the 32 MiB figure. That was a category error — it +> listed the hot columns and mistook them for the whole row, when they are +> tenants *within* the reserved 480-byte value slab. Both are the same 512-byte +> row: `NodeRow` is its AoS view, `MailboxSoA` its SoA projection. + +**⚠ One caveat that does stand:** **16-bit addressing is not expressed in the SoA +API.** `MailboxSoA` is `` and its accessors take `row: usize` +(`mailbox_soa.rs:624,630`). The `u16`s present (`entity_type`, `expert`) are +**values, not addresses**. The address widths are [OWNER-SPECIFIED]; the row +footprints are arithmetically checked. + +### Four dimensions that must never substitute for each other +| Dimension | Meaning | Do NOT infer | +|---|---|---| +| **Address width** | how many SoA positions can be named | 16-bit ⇏ 64k *sequential* operations | +| **Active population** | how many rows/updates participate concurrently | 64k parallel ⇏ 550 ms / 64k per-update latency | +| **Cache envelope** | does the working set stay near the CPU | 32 MiB-friendly ⇏ larger populations unaddressable | +| **Wall-clock SLA** | how fast the parallel cohort must converge | 4M addressable ⇏ same cache behaviour as 64k | + +**The ownership model does not change across these scales.** What changes is +physical locality and memory latency. V3 GUID resolution, `ClassView` dispatch, +SoA ownership and one-Kanban-per-SoA are scale-invariant. Above the cache +envelope the expected structural response is **partitioning into several SoAs — +each retaining its own Kanban** — not a different algorithm. *(Partitioning +behaviour at scale is [CODE-AUDIT REQUIRED]: no multi-SoA partitioning policy is +stated in the substrate crates.)* + +### Certainty labels +- **[OWNER-SPECIFIED]** ~64k updates execute in parallel within a 550 ms + wall-clock SLA. *Not located in code as an SLA* — `550_000` appears only as + `LIBET_COMMIT_WINDOW_US` (a Libet readiness anchor on the + `Planning → CognitiveWork` crossing, `kanban.rs:146-154`), and `64k` only in + `onebrc-probe` preset names. **A target the code does not yet state.** The + *capacity* half is partly corroborated — see the ARITHMETIC CHECK above. +- **[OWNER-SPECIFIED]** The 16-bit / 18-bit / 22-bit addressing ladder and the + 32 MiB / 2 GiB envelopes. Row footprints check out exactly against + `size_of::() == 512`, which is the UNIFORM stride (reserve-don't- + reclaim); the *address widths* themselves are not encoded in any row-index + type (`row: usize` throughout). +- **[OWNER-SPECIFIED]** Compute + write cost are masked by the parallel pipeline. +- **[CODE-PROVEN]** `BatchWriter` casts are ahead-firing and physical writes are + coalesced — *"records intent moves AHEAD of any storage write completing"*; + *"one physical flush coalesces all earlier intents for a row + (last-state-wins)"*; `cast() -> CastId`, *"NEVER refused"*. +- **[CODE-PROVEN, NARROW]** `BatchWriter` does **not** execute payload compute — + *"intent recording, nothing else"*; *"the writer never inspects `P`"*. Do not + attribute the whole masking property to it. +- **[CODE-PROVEN]** The Kanban step itself is **synchronous inline** — *"the + in-stream synchronous kanbanstep (`VersionScheduler::on_version → + try_advance_phase(&mut)`), fired inline"*. Fire-and-forget describes the + **cast**, not the transition. + +### Where compute parallelism actually lives [CODE-AUDIT] +- **Actor-per-mailbox** — `lance-graph-supervisor/src/kanban_actor.rs` uses + `ractor` (`impl Actor`, `ractor::registry::where_is`, `ractor::call!`). One SoA + = one mailbox = one Kanban = one actor. This is the parallelism unit. +- **Row sweeps inside one SoA** — `MailboxSoA` batch delivery + (`mailbox_soa.rs:337`, *"Accept a batch of `(target_row, CausalEdge64)` + deliveries"*) + cycle-guarded late-batch rejection (`:182,:245`). +- **`MailboxSoA::cast_to`** (`mailbox_soa.rs:748-762`) — the W4a ahead-firing + cast pairing into `BatchWriter`. +- **NOT rayon/`par_iter`** in the substrate: those appear only in the + `onebrc-probe` benchmark crate. **Gap: no data-parallel executor over the + cohort is stated in the substrate crates.** + +### Temporal deinterlacing — [CODE-PROVEN, with tests] +`temporal.rs` is the standing-wave resolver, not a snapshot wrapper. + +- **Sort key** (`temporal.rs:345-351`): + `(hlc_tick.unwrap_or(lance_version), lance_version)`. The fallback is + deliberate — falling back to `0` *"would force every missing-HLC row ahead of + all HLC rows regardless of its version (Codex P2 on #468)"*. +- **Horizon gate**: each row passes `.dispatchable(v_ref.mode)` before ordering. +- **Tests that exist:** `deinterlace_filters_and_orders_single_server`, + `deinterlace_hlc_orders_across_frames`, + `deinterlace_mixed_hlc_falls_back_to_lance_version`, and + **`no_hindsight_streamed_known_game`** — hindsight gating is named and tested. + +**Correction to an earlier reading in this file:** HLC is *implemented and +tested* in `deinterlace`; what is dormant is **production wiring** — no +substrate call site sets a non-zero `server_id` or `Some(hlc_tick)` +(`QueryReference::default`/`at` hardcode `0`/`None`). Mechanism present, callers +not yet multi-writer. + +**Physical completion order ≠ epistemic order.** Ordering is by the temporal key +above, never by when a cast landed. + +### Performance audit — the right questions +| Concern | Question | +|---|---| +| Parallel width | Can ~64k updates remain concurrently active? | +| Wall-clock convergence | Does the **cohort** converge in 550 ms? | +| Synchronous critical path | What must an update finish **before it may cast**? | +| Compute overlap | Which stages run concurrently across rows/mailboxes? | +| Batch coalescing | How many logical updates become one physical write? | +| Deinterlacing cost | What does temporal ordering cost for the whole cohort? | +| Knowledge gating | Can any update observe a **later** Lance version? | +| **Contention** | Does any shared registry/allocator **serialize** the cohort? | +| Failure behaviour | Can one local capacity condition **refuse or stall** the wave? | + +### The real PR #854 danger, correctly framed +Not the cost of an O(≤64) scan. A per-arena **mutable** `SourceRegistry` on the +pre-cast path introduces: shared mutable allocation · **insertion-order-dependent +meaning** (so slot assignment becomes nondeterministic under concurrency, hence +**replay-unstable**) · a 64-entry ceiling · **synchronous refusal** +(`CapacityExceeded`) · and a serialization point that can collapse part of an +otherwise parallel cohort around one allocator. + +> **Retraction:** an earlier revision called `SourceRegistry` "the forbidden +> confirmation-ledger shape under another name". The `E-ACK-ELIMINATED-1` ruling +> forbids *"a confirmation ledger (a persisted id→version map)"* — write-durability +> bookkeeping. `SourceRegistry` is an id→**slot** map for source interning: +> structurally similar, **not covered by that ruling**. The contention and +> refusal objections above stand on their own. + +--- + +## 6. Ownership / persistence / replay + +- `SoaEnvelope` (`soa_envelope.rs:170`) — the owner of the in-place backing store. + `ENVELOPE_LAYOUT_VERSION = 2` (`:54`). +- **Write-on-behalf iron rule** (`soa_envelope.rs:165-169`): every consuming crate + writes ON BEHALF OF the envelope's mailbox id, never directly. +- Mutation lives on the **owner** type, never on the read trait + (`soa_envelope.rs:148-149`; mirrors `MailboxSoaView` vs `MailboxSoaOwner`). +- `MailboxId = u32` (`collapse_gate.rs:121`) — *"unique u32 identity of one + spatial-temporal meaning accumulator"*. +- **Replay stability:** anything whose meaning depends on allocation order + (arena indices in `premises`, `Stamp` bit assignment) is NOT replay-stable. + Anything derived from the canonical GUID + Lance version IS. + +--- + +## 7. VERIFIED GAPS (say MISSING; do not substitute) + +1. **Evidence-event identity** — no receipt/observation/serial identity exists. +2. **Per-row mutation version** — no persisted last-changed field. +3. **Transaction/commit identity** — none. +4. **Intra-version ordering** — one row = one point on the version axis. +5. **Replay-stable derivation provenance** — `premises` are arena indices. +6. **Dependence model** — nothing represents whether two sources share a common + cause, though the workspace *measured* non-independence (cloned-lane probe: + +94 % naive agreement, similarity 1.000000). +7. **Enforced instance uniqueness** — debug-only, test-only call sites. + +--- + +## 8. DO NOT INVENT + +1. **Do not create a second classid carrier** because layers expose different + projections. `ClassId`, `classid_canon`, `classid_concept`, `GuidParts`, + `facet_classid` are **accessors on one value**. +2. **Do not mistake a component accessor for a separate identity system.** +3. **Do not create an arena-local replacement for canonical persistent identity** + (this is what `SourceRegistry` did; withdrawn in PR #854). +4. **Do not use source identity as evidence-event identity.** +5. **Do not use a dataset snapshot version as observation-event identity.** +6. **Do not assign dynamically-arriving updates to fixed, globally-interpreted + Boolean positions.** Follow `FieldMask`/`StepMask`: refuse, never fold. +7. **Do not treat projection/rendering differences as independent evidence** + without first proving the semantic distinction. +8. **Do not use a Boolean API where the architecture distinguishes true, false, + and unknown.** "Not known to overlap" ≠ "known disjoint". +9. **Do not introduce a serialized side-store for provenance.** Zero-copy, + never serialized; the cold path is Lance versions of the same LE bytes. +10. **Do not widen a mask to fit more members** — `WideFieldMask`'s doc calls + exceeding the cap *"a split signal, not a case to widen the mask type"*. +11. **Do not reuse `Stamp`'s shape as precedent.** It is the one carrier in this + inventory that violates the mask discipline its siblings document. +12. **Do not evaluate a design by whether it implements `Serialize`, can be + frozen into a census, or can be rebuilt from serialized records.** V3 is + never serialized, so "not serializable" is not a safety property and + "reconstructible from a serialized mapping" is not a solution. Judge by + *which SoA owns it* and *which Kanban governs its transitions*. +13. **Do not let a Rust struct's shape override the substrate's architecture.** + That `BeliefArena` is a struct with a `Vec` does not make an arena-owned + registry legitimate — it makes the arena the thing to question. +14. **Do not treat "arena-local" as a containment guarantee.** In V3, containment + is SoA ownership + write-on-behalf, not privacy of a heap field. +15. **Do not put fallible, allocating, or shared-mutable work before a cast.** + `cast()` is *"NEVER refused"*. The danger is not per-update cost — it is + **serializing a parallel cohort around one allocator** and making slot + meaning depend on insertion order. Never derive a per-update budget by + dividing the cohort SLA by the update count. +16. **Do not add a confirmation ledger under any name** — the BatchWriter doc + forbids it explicitly. Durability evidence is the row's own `LanceVersion`. +17. **Do not ride owned bytes on a cast payload.** `P` is a DESCRIPTOR + (mailbox, dirty row-range, cycle); deltas stay in the SoA backing store. +18. **Do not use physical completion order as epistemic order.** Ordering is + `(hlc_tick ?? lance_version, lance_version)` through `deinterlace`, gated by + `QueryReference.mode`. +19. **Do not describe Lance versions as durability acknowledgements only.** They + are the temporally sorted standing wave — the substrate replay traverses. +20. **Do not treat repository absence as disproof of owner-specified + architecture.** Report where an invariant is not yet explicit in code, tests, + or docs; do not conclude it is false. + +--- + +## 9. Reading order for the next coding session + +1. This file. +2. `soa_envelope.rs` module docs (ownership + zero-copy contract). +3. `canonical_node.rs` §CANON block in `CLAUDE.md`, then `facet.rs:88-110`. +4. `ogar_codebook.rs:285-400` (the one flippable classid composition). +5. `temporal.rs` **whole file** (the epistemic model is the spec). +6. `planner/src/nars/belief.rs` **and** `deepnsm-v2/src/belief.rs` side by side — + they differ at one line and it matters. +7. `.claude/board/EPIPHANIES.md` top 5 entries. diff --git a/crates/cognitive-shader-driver/src/mailbox_soa.rs b/crates/cognitive-shader-driver/src/mailbox_soa.rs index b31761f37..39154d322 100644 --- a/crates/cognitive-shader-driver/src/mailbox_soa.rs +++ b/crates/cognitive-shader-driver/src/mailbox_soa.rs @@ -965,12 +965,9 @@ impl MailboxSoaOwner for MailboxSoA { // the mailbox writes to itself in place; this is its own lifecycle // step recorded at its own current_cycle, per the #477 three-tier model.) witness_chain_position: self.current_cycle, - libet_offset_us: if from == KanbanColumn::Planning && to == KanbanColumn::CognitiveWork - { - -550_000 - } else { - 0 - }, + // No Libet stamp: the window is DERIVED from `(from, to)` via + // `KanbanMove::libet_window_us()`. An owner cannot mint a move that + // disagrees with itself about having crossed the Rubicon. exec: ExecTarget::Native, } } @@ -1270,7 +1267,7 @@ mod tests { let sched = NextPhaseScheduler; let mut steps = 0u32; - let mut first_libet = 0i32; + let mut first_libet: Option = None; for v in 1..=10u64 { // IN-direction: the scheduler lowers a version tick to the next move… let Some(mv) = sched.on_version(&mb, DatasetVersion(v), ExecTarget::Native) else { @@ -1284,7 +1281,7 @@ mod tests { .expect("the scheduler proposes only legal Rubicon edges"); assert_eq!(applied.to, mv.to); if steps == 0 { - first_libet = applied.libet_offset_us; + first_libet = applied.libet_window_us(); } steps += 1; } @@ -1300,8 +1297,9 @@ mod tests { "Planning→CognitiveWork→Evaluation→Commit = 3 advances" ); assert_eq!( - first_libet, -550_000, - "the Planning→CognitiveWork crossing carries the Libet −550 ms anchor" + first_libet, + Some(lance_graph_contract::kanban::LIBET_COMMIT_WINDOW_US), + "the Planning→CognitiveWork crossing opens the Libet 550 ms window" ); } diff --git a/crates/lance-graph-cognitive/Cargo.lock b/crates/lance-graph-cognitive/Cargo.lock index 7ea7e49e0..c3e8d22a5 100644 --- a/crates/lance-graph-cognitive/Cargo.lock +++ b/crates/lance-graph-cognitive/Cargo.lock @@ -39,9 +39,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow-array" -version = "57.3.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8955af33b25f3b175ee10af580577280b4bd01f7e823d94c7cdef7cf8c9aef" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" dependencies = [ "ahash", "arrow-buffer", @@ -57,9 +57,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "57.3.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c697ddca96183182f35b3a18e50b9110b11e916d7b7799cbfd4d34662f2c56c2" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" dependencies = [ "bytes", "half", @@ -69,9 +69,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "57.3.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fdd994a9d28e6365aa78e15da3f3950c0fdcea6b963a12fa1c391afb637b304" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" dependencies = [ "arrow-buffer", "arrow-schema", @@ -82,9 +82,9 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "57.3.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c872d36b7bf2a6a6a2b40de9156265f0242910791db366a2c17476ba8330d68" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" [[package]] name = "autocfg" @@ -201,6 +201,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -318,6 +324,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "half" version = "2.7.1" @@ -332,9 +344,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "holograph" @@ -374,6 +386,22 @@ dependencies = [ "cc", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "js-sys" version = "0.3.95" @@ -397,6 +425,11 @@ dependencies = [ [[package]] name = "lance-graph-contract" version = "0.1.0" +dependencies = [ + "glob", + "serde", + "serde_yaml", +] [[package]] name = "libc" @@ -441,8 +474,7 @@ dependencies = [ "num-complex", "num-integer", "num-traits", - "p64", - "phyllotactic-manifold", + "paste", "portable-atomic", "portable-atomic-util", "rawpointer", @@ -493,15 +525,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "p64" -version = "0.1.0" -dependencies = [ - "phyllotactic-manifold", -] - -[[package]] -name = "phyllotactic-manifold" -version = "0.1.0" +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pin-project-lite" @@ -560,6 +587,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.228" @@ -590,6 +623,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "shlex" version = "1.3.0" @@ -648,6 +694,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "version_check" version = "0.9.5" diff --git a/crates/lance-graph-cognitive/src/world/counterfactual.rs b/crates/lance-graph-cognitive/src/world/counterfactual.rs index 6ad1baa7f..3829e4b50 100644 --- a/crates/lance-graph-cognitive/src/world/counterfactual.rs +++ b/crates/lance-graph-cognitive/src/world/counterfactual.rs @@ -1,101 +1,136 @@ -//! Counterfactual reasoning — Pearl's Rung 3: "What if I had...?" +//! Reversible **binding substitution** on fingerprint world states: swap one +//! bound component for another and measure how far the composite moved. //! -//! Implements do-calculus interventions on fingerprint world states. -//! An intervention replaces one causal variable with a counterfactual value, -//! then measures how the world state diverges from the baseline. +//! ## This is NOT do-calculus, and used to say it was //! -//! # Science -//! - Pearl (2009): "Causality" ch.7 — structural counterfactuals -//! - Halpern & Pearl (2005): Actual causality definition -//! - Lewis (1973): Counterfactual conditionals +//! An earlier version of this module was titled "Pearl's Rung 3" and described +//! itself as implementing interventions. It does not, and the gap is not a +//! detail of degree: +//! +//! - **No mechanism is severed.** `do(X = x)` requires cutting X's incoming +//! edges so its parents stop determining it. There are no edges here — the +//! world is one fingerprint, not a structural causal model. +//! - **No descendants are recomputed.** After a real intervention, everything +//! downstream of X is re-derived under the mutilated model. Here the composite +//! is XOR-rewritten in place and nothing propagates. +//! - **No exogenous background is held fixed**, so the counterfactual +//! "same world, one variable changed" semantics has nothing to anchor to. +//! +//! What the algebra genuinely provides is worth keeping on its own merits: +//! XOR-binding is self-inverse, so `world ⊗ old ⊗ new` exactly replaces a bound +//! component and is exactly reversible. That is a **substitution primitive** — +//! useful for reversible binding, fingerprint edits, synthetic mutation, and for +//! *encoding* a hypothetical once some other layer has decided what the +//! hypothetical is. It is not the layer that decides. +//! +//! Naming it after Pearl made the substrate look like it had an operator it +//! never had, in a workspace where `RungLevel::Counterfactual` is a real +//! address. Renamed rather than deleted: the algebra is sound, the claim was not. +//! +//! # Algebra +//! XOR self-inverse binding: `(a ⊗ b) ⊗ b = a`. use crate::FINGERPRINT_BITS as TOTAL_BITS; use crate::Fingerprint; -/// A counterfactual world is a BindSpace state where one or more -/// fingerprints have been replaced with intervened values. +/// A world state in which one or more bound components have been substituted. #[derive(Debug, Clone)] -pub struct CounterfactualWorld { - /// The intervention applied - pub intervention: Intervention, - /// Fingerprint of the world state AFTER intervention +pub struct SubstitutedWorld { + /// The substitution applied. + /// The COMPLETE applied sequence, in order — empty when none was applied. + /// + /// Not `the last one`: `state` reflects every substitution, so recording + /// only the final entry misreports what produced it, and fabricating an + /// all-zero entry for the empty case makes "nothing was substituted" + /// indistinguishable from "substituted with zeros". A module whose argument + /// is that the algebra should say honestly what it did cannot have a field + /// that does otherwise (codex/CodeRabbit, PR #854). + pub substitutions: Vec, + /// Fingerprint of the world state AFTER substitution. pub state: Fingerprint, - /// Divergence from baseline (Hamming distance / total bits) + /// Divergence from baseline (Hamming distance / total bits). pub divergence: f32, } -/// An intervention replaces one causal node with a counterfactual value. +/// Replace one bound component of a composite fingerprint with another. +/// +/// "Component", not "causal variable": nothing here knows what causes what. #[derive(Debug, Clone)] -pub struct Intervention { - /// What was changed (identity of the variable) +pub struct BindingSubstitution { + /// Identity of the component being substituted. pub target: Fingerprint, - /// What it was (original binding) + /// What it was (original binding). pub original: Fingerprint, - /// What it became (counterfactual binding) - pub counterfactual: Fingerprint, + /// What it becomes (replacement binding). + pub replacement: Fingerprint, } -/// Create a counterfactual world by intervening on a variable. -/// -/// Pearl Rung 3: "What would have happened if X were x'?" +/// Substitute one bound component for another, and report how far the +/// composite moved. /// -/// Method: unbind the original variable from the world state, -/// bind the counterfactual value in its place. +/// Exact and reversible — XOR binding is self-inverse, so unbinding the +/// original and binding the replacement leaves every other component +/// untouched, and applying the inverse substitution restores the input +/// bit-for-bit. /// /// ```text -/// world' = world ⊗ original ⊗ counterfactual -/// = (base ⊗ original) ⊗ original ⊗ counterfactual -/// = base ⊗ counterfactual +/// world' = world ⊗ original ⊗ replacement +/// = (base ⊗ original) ⊗ original ⊗ replacement +/// = base ⊗ replacement /// ``` -pub fn intervene(world: &Fingerprint, intervention: &Intervention) -> CounterfactualWorld { - // Unbind original, bind counterfactual +/// +/// This is a substitution, NOT `do(X = x)` — see the module docs. +pub fn substitute_binding( + world: &Fingerprint, + substitution: &BindingSubstitution, +) -> SubstitutedWorld { + // Unbind original, bind replacement let new_state = world - .bind(&intervention.original) // Unbind: cancels original via XOR - .bind(&intervention.counterfactual); // Bind: installs replacement + .bind(&substitution.original) // Unbind: cancels original via XOR + .bind(&substitution.replacement); // Bind: installs replacement let divergence = world.hamming(&new_state) as f32 / TOTAL_BITS as f32; - CounterfactualWorld { - intervention: intervention.clone(), + SubstitutedWorld { + substitutions: vec![substitution.clone()], state: new_state, divergence, } } -/// Compare two counterfactual worlds. +/// Compare two substituted worlds. /// /// Returns normalized Hamming distance between the two world states. -pub fn worlds_differ(w1: &CounterfactualWorld, w2: &CounterfactualWorld) -> f32 { +pub fn worlds_differ(w1: &SubstitutedWorld, w2: &SubstitutedWorld) -> f32 { w1.state.hamming(&w2.state) as f32 / TOTAL_BITS as f32 } -/// Apply multiple interventions to a world state. +/// Apply multiple substitutions to a world state. /// -/// Each intervention is applied sequentially, so later interventions +/// Each substitution is applied sequentially, so later substitutions /// operate on the already-modified world. -pub fn multi_intervene(world: &Fingerprint, interventions: &[Intervention]) -> CounterfactualWorld { +pub fn multi_substitute_binding( + world: &Fingerprint, + substitutions: &[BindingSubstitution], +) -> SubstitutedWorld { let mut current = world.clone(); - for intervention in interventions { - let cf = intervene(¤t, intervention); + for substitution in substitutions { + let cf = substitute_binding(¤t, substitution); current = cf.state; } let divergence = world.hamming(¤t) as f32 / TOTAL_BITS as f32; - CounterfactualWorld { - intervention: if let Some(last) = interventions.last() { - last.clone() - } else { - Intervention { - target: Fingerprint::zero(), - original: Fingerprint::zero(), - counterfactual: Fingerprint::zero(), - } - }, + SubstitutedWorld { + substitutions: substitutions.to_vec(), state: current, divergence, } } -// Keep the original structs for backward compatibility +// Keep the original structs for backward compatibility. +// +// These two DO legitimately concern hypothesis-vs-baseline comparison at the +// world-versioning level, so they keep their names — unlike the XOR primitive +// above, they make no claim to be an intervention operator. /// High-level counterfactual metadata (for world versioning). pub struct Counterfactual { pub baseline_version: u64, @@ -103,7 +138,7 @@ pub struct Counterfactual { pub affected_nodes: Vec, } -/// A change applied to create a counterfactual world. +/// A change applied to create a hypothesis world. #[derive(Clone, Debug)] pub enum Change { Remove(String), @@ -124,69 +159,71 @@ mod tests { use super::*; #[test] - fn test_intervene_diverges() { + fn test_substitute_binding_diverges() { let base = Fingerprint::from_content("base_world_state"); let variable = Fingerprint::from_content("the_variable"); let world = base.bind(&variable); - let intervention = Intervention { + let substitution = BindingSubstitution { target: variable.clone(), original: variable.clone(), - counterfactual: Fingerprint::from_content("counterfactual_variable"), + replacement: Fingerprint::from_content("replacement_variable"), }; - let cf_world = intervene(&world, &intervention); + let cf_world = substitute_binding(&world, &substitution); - // Counterfactual world should differ from original + // Substituting a component moves the composite substantially. assert!( cf_world.divergence > 0.3, - "Counterfactual should diverge >30% from baseline: {:.3}", + "substitution should diverge >30% from baseline: {:.3}", cf_world.divergence ); } #[test] - fn test_intervene_recovers_base() { + fn test_substitute_binding_recovers_base() { let base = Fingerprint::from_content("base_world_state"); let variable = Fingerprint::from_content("the_variable"); let world = base.bind(&variable); - let cf_var = Fingerprint::from_content("counterfactual_variable"); - let intervention = Intervention { + let cf_var = Fingerprint::from_content("replacement_variable"); + let substitution = BindingSubstitution { target: variable.clone(), original: variable.clone(), - counterfactual: cf_var.clone(), + replacement: cf_var.clone(), }; - let cf_world = intervene(&world, &intervention); + let cf_world = substitute_binding(&world, &substitution); - // The intervened variable should be recoverable from new world + // The substituted component is recoverable from the new world — this is + // the primitive's actual contract (exact reversibility), and the reason + // the algebra survives the rename. // world' = base ⊗ cf_var, so world' ⊗ cf_var = base let recovered = cf_world.state.bind(&cf_var); assert_eq!( recovered.as_raw(), base.as_raw(), - "Should recover base world after unbinding counterfactual" + "Should recover base world after unbinding replacement" ); } #[test] - fn test_identity_intervention() { + fn test_identity_substitution() { let base = Fingerprint::from_content("base_state"); let variable = Fingerprint::from_content("unchanged"); let world = base.bind(&variable); - // Intervening with same value should produce identical world - let identity = Intervention { + // Substituting with the same value should produce an identical world + let identity = BindingSubstitution { target: variable.clone(), original: variable.clone(), - counterfactual: variable.clone(), + replacement: variable.clone(), }; - let cf = intervene(&world, &identity); + let cf = substitute_binding(&world, &identity); assert_eq!( cf.divergence, 0.0, - "Identity intervention should produce zero divergence" + "Identity substitution should produce zero divergence" ); assert_eq!(cf.state.as_raw(), world.as_raw()); } @@ -197,52 +234,52 @@ mod tests { let var = Fingerprint::from_content("variable"); let world = base.bind(&var); - let i1 = Intervention { + let i1 = BindingSubstitution { target: var.clone(), original: var.clone(), - counterfactual: Fingerprint::from_content("counterfactual_A"), + replacement: Fingerprint::from_content("replacement_A"), }; - let i2 = Intervention { + let i2 = BindingSubstitution { target: var.clone(), original: var.clone(), - counterfactual: Fingerprint::from_content("counterfactual_B"), + replacement: Fingerprint::from_content("replacement_B"), }; - let w1 = intervene(&world, &i1); - let w2 = intervene(&world, &i2); + let w1 = substitute_binding(&world, &i1); + let w2 = substitute_binding(&world, &i2); let diff = worlds_differ(&w1, &w2); assert!( diff > 0.3, - "Different interventions should produce different worlds: {:.3}", + "Different substitutions should produce different worlds: {:.3}", diff ); } #[test] - fn test_multi_intervene() { + fn test_multi_substitute_binding() { let world = Fingerprint::from_content("complex_world"); let var_a = Fingerprint::from_content("var_a"); let var_b = Fingerprint::from_content("var_b"); let world = world.bind(&var_a).bind(&var_b); - let interventions = vec![ - Intervention { + let substitutions = vec![ + BindingSubstitution { target: var_a.clone(), original: var_a, - counterfactual: Fingerprint::from_content("cf_a"), + replacement: Fingerprint::from_content("cf_a"), }, - Intervention { + BindingSubstitution { target: var_b.clone(), original: var_b, - counterfactual: Fingerprint::from_content("cf_b"), + replacement: Fingerprint::from_content("cf_b"), }, ]; - let cf = multi_intervene(&world, &interventions); + let cf = multi_substitute_binding(&world, &substitutions); assert!( cf.divergence > 0.3, - "Multi-intervention should diverge: {:.3}", + "Multi-substitution should diverge: {:.3}", cf.divergence ); } diff --git a/crates/lance-graph-cognitive/src/world/mod.rs b/crates/lance-graph-cognitive/src/world/mod.rs index 94ea84656..f77a5f5fd 100644 --- a/crates/lance-graph-cognitive/src/world/mod.rs +++ b/crates/lance-graph-cognitive/src/world/mod.rs @@ -1,10 +1,14 @@ -//! World state and counterfactual reasoning +//! World state and reversible binding substitution. +//! +//! NOTE: `counterfactual` is a historical module name. The substitution +//! primitives it exports are NOT do-calculus interventions — see the module +//! docs for what they are and what they are not. pub mod counterfactual; mod state; pub use counterfactual::{ - Change, Counterfactual, CounterfactualWorld, Intervention, intervene, multi_intervene, - worlds_differ, + multi_substitute_binding, substitute_binding, worlds_differ, BindingSubstitution, Change, + Counterfactual, SubstitutedWorld, }; pub use state::World; diff --git a/crates/lance-graph-contract/examples/foveated_awareness.rs b/crates/lance-graph-contract/examples/foveated_awareness.rs index cbfbda3a1..2ff3098c8 100644 --- a/crates/lance-graph-contract/examples/foveated_awareness.rs +++ b/crates/lance-graph-contract/examples/foveated_awareness.rs @@ -418,13 +418,6 @@ fn advance( from: card.col, to, witness_chain_position: cycle, - libet_offset_us: if card.col == KanbanColumn::Planning - && to == KanbanColumn::CognitiveWork - { - -550_000 - } else { - 0 - }, exec: lance_graph_contract::kanban::ExecTarget::Native, }); to diff --git a/crates/lance-graph-contract/src/causal_audit.rs b/crates/lance-graph-contract/src/causal_audit.rs new file mode 100644 index 000000000..80f21ad13 --- /dev/null +++ b/crates/lance-graph-contract/src/causal_audit.rs @@ -0,0 +1,676 @@ +//! Typed causal-relation audit — four orthogonal axes, never one merged carrier. +//! +//! ## Why this module exists +//! +//! The substrate carries Pearl's *vocabulary* (`RungLevel::Counterfactual`, +//! `InferenceType::Intervention`) at several layers, but an edge asserting +//! "X causes Y" has, until now, been **untyped**: nothing distinguishes a +//! causal claim a corpus merely *reports* from one the system *observed*, or a +//! claim about the world from one about its own derivations. An audit that +//! cannot make those distinctions will happily promote a sentence into an +//! interventional fact. +//! +//! ## The four axes, and why they must stay four +//! +//! Causality has several independent geometries. Merging any two of them +//! produces a carrier that looks tidy and silently discards a dimension: +//! +//! | Axis | Question | Type | +//! |---|---|---| +//! | **Kind** | causal at all, or correlational / definitional / temporal? | [`RelationClassification`] | +//! | **Locus** | *where in the architecture* does the relation operate? | [`CausalLocus`] | +//! | **Domain** | *what subject matter* does it concern? | [`WorldDomain`] | +//! | **Scope** | a general regularity, or this particular episode? | [`CausalScope`] | +//! | **Support** | what evidence backs it, and of which kinds? | [`SupportLedger`] | +//! +//! **Locus is not domain.** `accusative marker → parser selects object role` +//! concerns physical text, a social language convention, and a formal grammar +//! all at once — yet its causal locus is unambiguously +//! [`Interpretive`](CausalLocus::Interpretive). `recipe 17 + rail → belief P +//! admitted` is [`Derivational`](CausalLocus::Derivational) whether P is about +//! physics or politics. Classifying only by domain reproduces the original +//! category error under prettier names. +//! +//! **Scope is not grammatical voice.** "The outage was caused by cable damage" +//! is passive and [`Token`](CausalScope::Token); "Smoking causes cancer" is +//! active and [`Type`](CausalScope::Type). Voice does not predict scope, so the +//! axis is named for what it measures. +//! +//! **Support is many-of, not one-of.** A single edge can be simultaneously +//! text-attested, linguistically asserted, derivationally traced, and +//! cross-environment invariant — those are not competing alternatives. A +//! single-valued `support_basis` field would force a later process to elect one +//! and silently discard the rest, which is exactly the fold this module is +//! built to prevent. Hence [`SupportLedger`], a receipt list. +//! +//! ## Illegal states are unrepresentable +//! +//! [`RelationClassification`] is a sum type, not a bag of `Option`s: a +//! non-causal relation cannot carry a `CausalLocus`, and an unclassified edge +//! has [`Unclassified`](RelationClassification::Unclassified) to sit in rather +//! than being coerced into a half-typed causal claim. An audit that cannot say +//! "not yet classified" will invent classifications. +//! +//! ## Classification and support are separable, and stay separable +//! +//! [`AuditedRelation`] holds the two side by side rather than copying support +//! into every classification branch, because their lifecycles differ: support +//! **accumulates** while classification is still `Unclassified`, and +//! classification can be **revised** without rewriting historical receipts. + +use crate::scheduler::DatasetVersion; + +/// Where in the architecture a causal relation operates. +/// +/// Orthogonal to [`WorldDomain`] (what it is *about*) — see the module docs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CausalLocus { + /// Out in the modelled world: physical, social, or institutional + /// mechanisms the system did not produce. + World, + /// In the act of interpretation: a parse decision, a role assignment, a + /// disambiguation. The cause operates on *reading*, not on the world. + Interpretive, + /// In the system's own inference: a recipe, a rail, a rule admitted this + /// belief from those premises. The provenance of a conclusion. + Derivational, + /// In the system's own processing history: what it saw, in what order, + /// under what load — causes that operate on the experiencing substrate. + Experiential, + /// Not yet determined. Distinct from "no locus": this says *unknown*, and + /// must never be defaulted into `World`. + Unknown, +} + +/// What subject matter a causal relation concerns. +/// +/// Primarily meaningful under [`CausalLocus::World`], though it may annotate +/// the content processed by any locus. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WorldDomain { + /// Physical mechanism. + Physical, + /// Social dynamics between agents. + Social, + /// Deliberate action by an agent with intent. + Intentional, + /// Rules, policies, organisations — causes that hold because a body says so. + Institutional, + /// Within a formal model or calculus, where "cause" means derivation under + /// the model's own rules. + FormalModel, + /// Not yet determined. + Unknown, +} + +/// Whether a causal claim is general or particular. +/// +/// NOT grammatical voice — see the module docs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CausalScope { + /// A general regularity: "smoking causes cancer". + Type, + /// This particular episode: "the cable damage caused Tuesday's outage". + /// Halpern-Pearl *actual* causality. + Token, +} + +/// A relation that is not a causal claim. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NonCausalKind { + /// Co-varies, with no direction asserted. + Correlational, + /// True by definition or stipulation — "a bachelor is unmarried". + Definitional, + /// Ordered in time, with no mechanism claimed. The most common thing + /// mistaken for causal. + Temporal, + /// Part-of / member-of structure. + Mereological, + /// A relation the classifier can name but that fits no bucket above. + Other, +} + +/// An opaque handle for a relation the audit has not classified yet. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] +pub struct RelationId(pub u64); + +/// What kind of relation this is — a sum type, so a non-causal relation +/// *cannot* carry a causal locus and an unclassified one need not pretend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RelationClassification { + /// Classified, and not a causal claim. + NonCausal { kind: NonCausalKind }, + /// Classified as causal. `locus` and `scope` are REQUIRED — a causal claim + /// that cannot say where it operates or whether it is general is not + /// classified, it is [`Unclassified`](RelationClassification::Unclassified). + /// `world_domain` is optional because it is only fully meaningful under + /// [`CausalLocus::World`]. + Causal { + /// Where in the architecture the relation operates. + locus: CausalLocus, + /// What subject matter it concerns, when that is known. + world_domain: Option, + /// General regularity or particular episode. + scope: CausalScope, + }, + /// Not yet classified — an honest resting place. Support may accumulate + /// against this edge for as long as it sits here. + Unclassified { raw_relation: RelationId }, +} + +impl RelationClassification { + /// Is this a causal claim? `false` for both `NonCausal` and `Unclassified` + /// — an unclassified edge is NOT provisionally causal. + #[inline] + #[must_use] + pub const fn is_causal(&self) -> bool { + matches!(self, Self::Causal { .. }) + } + + /// The locus, when this is a classified causal relation. + #[inline] + #[must_use] + pub const fn locus(&self) -> Option { + match self { + Self::Causal { locus, .. } => Some(*locus), + _ => None, + } + } +} + +/// One *kind* of evidential support. An edge normally has several at once — +/// this is a receipt category, never a whole verdict. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum SupportBasis { + /// A source *states* the causal claim. The corpus attests that someone + /// wrote it; it does not witness the mechanism. + TextAttested = 0, + /// The system observed both relata occurring. Observation of occurrence, + /// still not of mechanism. + DirectlyObserved = 1, + /// Only the ordering is known. The weakest basis that still looks causal. + TemporalOrderOnly = 2, + /// Carried by causative wording ("because", "led to", a causative verb) — + /// a grammatical signal, not evidence about the world. + LinguisticallyAsserted = 3, + /// Holds across environments/contexts that vary other factors. + CrossEnvironmentInvariant = 4, + /// Changing the antecedent under controlled mechanism conditions changed + /// the consequent. The only basis that earns interventional standing — + /// and the one a text corpus can never produce. + InterventionBacked = 5, + /// Reproduced in simulation, under the simulation's own assumptions. + SimulationOnly = 6, + /// A derivation admitted it: recipe, rule, or rail, with a traceable path. + DerivationalTrace = 7, + /// Provenance not recorded. + Unknown = 8, +} + +impl SupportBasis { + /// Bit position in a [`SupportProfile`] mask. + #[inline] + #[must_use] + pub const fn bit(self) -> u16 { + 1u16 << (self as u8) + } + + /// Does this basis, on its own, license treating the relation as + /// interventionally established? Only + /// [`InterventionBacked`](SupportBasis::InterventionBacked). + /// + /// Deliberately narrow: `CrossEnvironmentInvariant` is strong evidence and + /// still not an intervention, and the gap between them is the whole reason + /// this enum has nine variants instead of four. + #[inline] + #[must_use] + pub const fn is_intervention_grade(self) -> bool { + matches!(self, Self::InterventionBacked) + } +} + +/// An opaque, stable identity for an evidence source. +/// +/// NOT a bit position. Arbitrary and sparse — a term id, corpus id, witness +/// id, or hash. Mapping it to a dense local slot is a registry's job, never an +/// arithmetic accident. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] +pub struct EvidenceSourceId(pub u64); + +/// One piece of evidence for a relation: which kind, from whom, when, how +/// strong. +/// +/// Receipts are the **source of truth** for provenance; [`SupportProfile`] is a +/// derived projection of them. That direction matters — three independent +/// text attestations and one attestation counted three times produce identical +/// masks but must never produce identical strength. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SupportReceipt { + /// Which kind of support this is. + pub basis: SupportBasis, + /// Who supplied it — a stable external identity. + pub source: EvidenceSourceId, + /// When it was recorded. + /// + /// **Known gap:** this is a storage revision, NOT an epistemic view. It + /// answers "which dataset version" but not "which version was this observer + /// permitted to see, under which read mode". The planner-side ledger should + /// carry the richer `QueryReference` once that type is reachable from the + /// contract; until then this field is deliberately the weaker identity and + /// is labelled as such rather than silently standing in for the stronger + /// one. + pub at: DatasetVersion, + /// Weight of this individual receipt, `0..=255`. Per-receipt, never a + /// pre-aggregated score. + pub strength: u8, +} + +/// The receipt ledger for one relation — the canonical provenance record. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SupportLedger { + receipts: Vec, +} + +impl SupportLedger { + /// An empty ledger — the correct starting state for a relation nobody has + /// evidenced yet. + #[must_use] + pub const fn new() -> Self { + Self { + receipts: Vec::new(), + } + } + + /// Record a receipt. Append-only: evidence accumulates, and a later + /// classification never rewrites it. + pub fn record(&mut self, receipt: SupportReceipt) { + self.receipts.push(receipt); + } + + /// Every receipt, in the order recorded. + #[inline] + #[must_use] + pub fn receipts(&self) -> &[SupportReceipt] { + &self.receipts + } + + /// Is there no evidence at all? + #[inline] + #[must_use] + pub fn is_empty(&self) -> bool { + self.receipts.is_empty() + } + + /// Withdraw every receipt from `source`, returning how many were removed. + /// + /// This is why receipts are canonical and a mask is not: withdrawal + /// requires knowing *which* evidence came from whom, and a bitmask cannot + /// answer that. + pub fn withdraw_source(&mut self, source: EvidenceSourceId) -> usize { + let before = self.receipts.len(); + self.receipts.retain(|r| r.source != source); + before - self.receipts.len() + } + + /// How many DISTINCT sources back this relation with `basis`. + /// + /// Distinct-source counting, not receipt counting: one source repeating + /// itself is not corroboration. Linear scan — ledgers are small, and the + /// hot path reads [`SupportProfile`], not this. + #[must_use] + pub fn distinct_sources_for(&self, basis: SupportBasis) -> usize { + let mut seen: Vec = Vec::new(); + for r in self.receipts.iter().filter(|r| r.basis == basis) { + if !seen.contains(&r.source) { + seen.push(r.source); + } + } + seen.len() + } + + /// Does any receipt license interventional standing? + #[must_use] + pub fn has_intervention_grade(&self) -> bool { + self.receipts + .iter() + .any(|r| r.basis.is_intervention_grade()) + } + + /// Project to the compact [`SupportProfile`] for the SIMD / fixed-width path. + /// + /// ONE pass — the earlier form rescanned every receipt nine times through + /// `distinct_sources_for` (codex/CodeRabbit, PR #854). + /// + /// `independent_strength` is left `None` throughout: no dependence model + /// exists, so no strength here has been shown to be independent corroboration. + #[must_use] + pub fn profile(&self) -> SupportProfile { + let mut p = SupportProfile::default(); + let mut seen: [Vec; 9] = Default::default(); + for r in &self.receipts { + p.basis_mask |= r.basis.bit(); + let slot = r.basis as usize; + let cell = &mut p.per_basis[slot]; + cell.receipt_count = cell.receipt_count.saturating_add(1); + cell.total_strength = cell.total_strength.saturating_add(u32::from(r.strength)); + if !seen[slot].contains(&r.source) { + seen[slot].push(r.source); + cell.distinct_source_count = cell.distinct_source_count.saturating_add(1); + } + } + p + } +} + +impl SupportBasis { + /// Every variant, for exhaustive projection. + pub const ALL: [SupportBasis; 9] = [ + Self::TextAttested, + Self::DirectlyObserved, + Self::TemporalOrderOnly, + Self::LinguisticallyAsserted, + Self::CrossEnvironmentInvariant, + Self::InterventionBacked, + Self::SimulationOnly, + Self::DerivationalTrace, + Self::Unknown, + ]; +} + +/// What one [`SupportBasis`] has behind it — four readings kept apart. +/// +/// **`total_strength` is NOT corroboration.** It sums every receipt, so one +/// source repeating itself three times reaches the same total as three +/// independent sources. That is the defect this struct exists to make +/// unreachable by accident: the repetition-proof reading is +/// [`distinct_source_count`](Self::distinct_source_count), and any claim of +/// *corroborated* strength must come from +/// [`independent_strength`](Self::independent_strength). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct BasisProfile { + /// Receipts recorded — repetition-SENSITIVE by design. + pub receipt_count: u32, + /// Distinct sources — repetition-PROOF; the honest corroboration count. + pub distinct_source_count: u32, + /// Summed receipt strength (saturating). See the type docs: this is a + /// volume reading, never an independence reading. + pub total_strength: u32, + /// Strength that may be treated as independent corroboration. + /// + /// **Always `None` today, and deliberately so.** Establishing it requires a + /// dependence model — whether two distinct sources share a common cause — + /// which this substrate does not yet have. (The workspace has *measured* + /// that its witnesses are not automatically independent: the cloned-lane + /// probe, +94 % naive agreement, similarity 1.000000.) `None` means "not + /// established", never "zero", and a caller must not substitute + /// `total_strength` for it. + pub independent_strength: Option, +} + +/// Fixed-width projection of a [`SupportLedger`] — derived, never authoritative. +/// +/// One [`BasisProfile`] per [`SupportBasis`], indexed by `basis as usize`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SupportProfile { + /// One bit per [`SupportBasis`] present. + pub basis_mask: u16, + /// Per-basis readings, indexed by `basis as usize`. + pub per_basis: [BasisProfile; 9], +} + +impl SupportProfile { + /// The readings for one basis. + #[inline] + #[must_use] + pub const fn basis(&self, basis: SupportBasis) -> &BasisProfile { + &self.per_basis[basis as usize] + } +} + +impl SupportProfile { + /// Is `basis` present at all? + #[inline] + #[must_use] + pub const fn has(&self, basis: SupportBasis) -> bool { + self.basis_mask & basis.bit() != 0 + } + + /// How many distinct bases back this relation. + #[inline] + #[must_use] + pub const fn basis_diversity(&self) -> u32 { + self.basis_mask.count_ones() + } +} + +/// A relation with its classification and its evidence, held separately. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditedRelation { + /// What kind of relation this is. + pub classification: RelationClassification, + /// What backs it. + pub support: SupportLedger, +} + +impl AuditedRelation { + /// A relation entering the audit with no classification and no evidence. + #[must_use] + pub fn unclassified(raw: RelationId) -> Self { + Self { + classification: RelationClassification::Unclassified { raw_relation: raw }, + support: SupportLedger::new(), + } + } + + /// Revise the classification, leaving the receipt ledger untouched. + /// + /// The whole point of keeping the two apart: re-reading an edge as + /// `Derivational` rather than `World` must not disturb the record of who + /// attested it. + pub fn reclassify(&mut self, classification: RelationClassification) { + self.classification = classification; + } + + /// May this relation be treated as interventionally established? + /// + /// Requires BOTH a causal classification AND an intervention-grade receipt. + /// A corpus-derived edge fails this no matter how many text attestations it + /// accumulates — which is the guarantee this module exists to provide. + #[must_use] + pub fn is_intervention_established(&self) -> bool { + self.classification.is_causal() && self.support.has_intervention_grade() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn receipt(basis: SupportBasis, source: u64, strength: u8) -> SupportReceipt { + SupportReceipt { + basis, + source: EvidenceSourceId(source), + at: DatasetVersion(1), + strength, + } + } + + /// The load-bearing invariant: support is MANY-of. An edge carrying four + /// distinct bases must keep all four — no election, no discard. + #[test] + fn support_is_many_of_not_one_of() { + let mut led = SupportLedger::new(); + for b in [ + SupportBasis::TextAttested, + SupportBasis::LinguisticallyAsserted, + SupportBasis::DerivationalTrace, + SupportBasis::CrossEnvironmentInvariant, + ] { + led.record(receipt(b, 7, 10)); + } + let p = led.profile(); + assert_eq!(p.basis_diversity(), 4, "all four bases survive projection"); + assert!(p.has(SupportBasis::TextAttested)); + assert!(p.has(SupportBasis::DerivationalTrace)); + } + + /// Three independent attestations and one attestation repeated three times + /// share a `basis_mask` — and MUST NOT read as equally corroborated. + /// This is the compression the receipt ledger exists to refuse. + #[test] + fn repeated_source_is_not_corroboration() { + let mut independent = SupportLedger::new(); + for src in [1, 2, 3] { + independent.record(receipt(SupportBasis::TextAttested, src, 10)); + } + + let mut repeated = SupportLedger::new(); + for _ in 0..3 { + repeated.record(receipt(SupportBasis::TextAttested, 1, 10)); + } + + let (a, b) = (independent.profile(), repeated.profile()); + let (ta, tb) = ( + a.basis(SupportBasis::TextAttested), + b.basis(SupportBasis::TextAttested), + ); + + // Three readings CANNOT tell them apart — stated as assertions so the + // limitation is documented behaviour rather than a latent surprise. + assert_eq!(a.basis_mask, b.basis_mask, "masks are identical…"); + assert_eq!(ta.receipt_count, tb.receipt_count, "…so are raw counts…"); + assert_eq!( + ta.total_strength, tb.total_strength, + "…and so is total_strength — it is a VOLUME reading, never corroboration. \ + Summing it was the shipped bug (codex, PR #854)." + ); + + // Exactly one reading distinguishes them, and it is the repetition-proof one. + assert_eq!(ta.distinct_source_count, 3); + assert_eq!(tb.distinct_source_count, 1); + + // And nothing here claims independence, because nothing established it. + assert_eq!(ta.independent_strength, None); + assert_eq!(tb.independent_strength, None); + } + + /// A corpus edge cannot reach interventional standing by piling on text. + #[test] + fn text_attestation_never_becomes_intervention() { + let mut r = AuditedRelation::unclassified(RelationId(9)); + r.reclassify(RelationClassification::Causal { + locus: CausalLocus::World, + world_domain: Some(WorldDomain::Physical), + scope: CausalScope::Type, + }); + for src in 0..50 { + r.support + .record(receipt(SupportBasis::TextAttested, src, 255)); + r.support + .record(receipt(SupportBasis::LinguisticallyAsserted, src, 255)); + } + assert!( + !r.is_intervention_established(), + "100 receipts, zero interventions" + ); + + // …and ONE genuine intervention flips it. + r.support + .record(receipt(SupportBasis::InterventionBacked, 999, 1)); + assert!(r.is_intervention_established()); + } + + /// Classification is revisable; receipts are not disturbed by revision. + #[test] + fn support_survives_reclassification() { + let mut r = AuditedRelation::unclassified(RelationId(1)); + r.support + .record(receipt(SupportBasis::DerivationalTrace, 4, 30)); + r.support.record(receipt(SupportBasis::TextAttested, 5, 20)); + let before = r.support.clone(); + + r.reclassify(RelationClassification::Causal { + locus: CausalLocus::Derivational, + world_domain: None, + scope: CausalScope::Token, + }); + assert_eq!(r.support, before, "revision must not rewrite history"); + + r.reclassify(RelationClassification::NonCausal { + kind: NonCausalKind::Temporal, + }); + assert_eq!(r.support, before); + } + + /// An unclassified edge is NOT provisionally causal, and support may + /// accumulate against it while it waits. + #[test] + fn unclassified_is_not_causal_but_still_collects_evidence() { + let mut r = AuditedRelation::unclassified(RelationId(3)); + assert!(!r.classification.is_causal()); + assert_eq!(r.classification.locus(), None); + + r.support.record(receipt(SupportBasis::TextAttested, 1, 10)); + assert!(!r.support.is_empty()); + assert!( + !r.is_intervention_established(), + "no classification, no standing" + ); + } + + /// Withdrawal is per-source and exact — the operation a bitmask cannot do. + #[test] + fn withdrawal_removes_exactly_one_sources_receipts() { + let mut led = SupportLedger::new(); + led.record(receipt(SupportBasis::TextAttested, 1, 10)); + led.record(receipt(SupportBasis::DerivationalTrace, 1, 10)); + led.record(receipt(SupportBasis::TextAttested, 2, 10)); + + assert_eq!(led.withdraw_source(EvidenceSourceId(1)), 2); + assert_eq!(led.receipts().len(), 1); + assert_eq!(led.receipts()[0].source, EvidenceSourceId(2)); + assert_eq!( + led.withdraw_source(EvidenceSourceId(42)), + 0, + "absent source is a no-op" + ); + } + + /// Locus and domain vary INDEPENDENTLY — the orthogonality receipt. + /// + /// Two witnesses, both non-trivial: locus changes while domain holds, and + /// domain changes while locus holds. One-directional variation would mean + /// one axis is derived from the other and should not be a stored field. + #[test] + fn locus_and_domain_are_independently_variable() { + let causal = |locus, domain| RelationClassification::Causal { + locus, + world_domain: Some(domain), + scope: CausalScope::Type, + }; + + // Witness 1: locus varies, domain fixed (Social). + assert_ne!( + causal(CausalLocus::World, WorldDomain::Social), + causal(CausalLocus::Interpretive, WorldDomain::Social) + ); + // Witness 2: domain varies, locus fixed (Interpretive). + assert_ne!( + causal(CausalLocus::Interpretive, WorldDomain::Social), + causal(CausalLocus::Interpretive, WorldDomain::FormalModel) + ); + } + + /// Scope varies independently of BOTH locus and domain. + #[test] + fn scope_is_independent_of_locus_and_domain() { + let at = |scope| RelationClassification::Causal { + locus: CausalLocus::World, + world_domain: Some(WorldDomain::Physical), + scope, + }; + assert_ne!(at(CausalScope::Type), at(CausalScope::Token)); + } +} diff --git a/crates/lance-graph-contract/src/kanban.rs b/crates/lance-graph-contract/src/kanban.rs index 445ce9863..9dfa5529c 100644 --- a/crates/lance-graph-contract/src/kanban.rs +++ b/crates/lance-graph-contract/src/kanban.rs @@ -143,6 +143,16 @@ impl KanbanColumn { } } +/// The Libet readiness window, in µs — the `-550 ms` anchor a thinking cycle +/// has between the Σ-commit crossing and the act landing. +/// +/// Magnitude only: the sign lived in the retired `KanbanMove::libet_offset_us` +/// field, and direction is now carried by the transition itself (see +/// [`KanbanMove::libet_window_us`]). ONE definition — the planner's +/// `elevation::cycle::LIBET_CYCLE_BUDGET_US` re-exports this rather than +/// restating the literal. +pub const LIBET_COMMIT_WINDOW_US: u32 = 550_000; + /// One kanban transition: the planner's output unit and the ractor's lifecycle step. /// /// `Copy` and small (≤ 16 B) so it rides the airgap as owned microcopy, never a @@ -159,15 +169,36 @@ pub struct KanbanMove { /// structural time, not a wall-clock stamp (R4). (Same convention the /// retired `CollapseGateEmission` carrier used, kept after its removal.) pub witness_chain_position: u32, - /// Libet commit anchor: signed micros relative to the act. `-550_000` on the - /// `Planning → CognitiveWork` Σ-commit; `0` otherwise. Structural offset only. - pub libet_offset_us: i32, /// Which execution backend the planner selected for this move's work — the /// JIT-adjacent strategy target (native planner / JIT / SurrealQL / Elixir). pub exec: ExecTarget, } impl KanbanMove { + /// The Libet commit window this move opens, in µs — `Some(550_000)` exactly + /// on the `Planning → CognitiveWork` Σ-commit crossing, `None` on every + /// other arc. + /// + /// **Derived, never stored.** The window is a pure projection of + /// `(from, to)`: the Rubicon crossing IS the anchor, so a separately + /// writable `libet_offset_us` field could only ever disagree with the + /// transition it describes (a `Planning → CognitiveWork` move stamped `0`, + /// or a mid-cycle move stamped `-550_000`). Removing the field removes the + /// invalid state rather than testing for it. The one legal destination-arc + /// into `CognitiveWork` is from `Planning` + /// ([`next_phases`](KanbanColumn::next_phases)), so matching on the pair is + /// exactly as precise as matching the destination alone — and says why. + /// + /// Read side: `lance-graph-planner` `elevation::cycle::CycleBudget::from_move`. + #[inline] + #[must_use] + pub const fn libet_window_us(&self) -> Option { + match (self.from, self.to) { + (KanbanColumn::Planning, KanbanColumn::CognitiveWork) => Some(LIBET_COMMIT_WINDOW_US), + _ => None, + } + } + /// The SoA cycle-ownership stamp (S2.5) — the mailbox `current_cycle` at /// which this lifecycle step was emitted. /// @@ -248,7 +279,13 @@ impl core::fmt::Display for RubiconTransitionError { impl core::error::Error for RubiconTransitionError {} // `KanbanMove` must stay a small owned microcopy (airgap discipline, I1): -// MailboxId(4) + u32(4) + i32(4) + 2×KanbanColumn(1) + ExecTarget(1) packs within 16 B. +// MailboxId(4) + u32(4) + 2×KanbanColumn(1) + ExecTarget(1) packs within 16 B. +// +// An UPPER BOUND, deliberately — not an exact-size pin. `KanbanMove` is a +// Rust-representation microcopy, not an ABI or a persisted byte layout, so the +// exact `size_of` is the compiler's business (default `repr` gives no layout +// guarantee). Pin an exact size only if the layout ever becomes contractual, +// and then state `repr` + supported targets alongside it. const _: () = assert!(core::mem::size_of::() <= 16); #[cfg(test)] @@ -288,7 +325,6 @@ mod tests { from: KanbanColumn::Planning, to: KanbanColumn::CognitiveWork, witness_chain_position: 7, - libet_offset_us: -550_000, exec: ExecTarget::Native, }; let n = m; // Copy, not move @@ -296,6 +332,69 @@ mod tests { assert!(core::mem::size_of::() <= 16); } + /// The Libet window is DERIVED from the transition — can-fire and + /// can-stay-silent, both on non-trivial moves. + /// + /// The pair is not decoration: an orthogonality audit on the retired + /// `libet_offset_us` field found variation in one direction only — + /// `(from, to)` can vary while the offset holds (any mid-cycle arc), but no + /// legitimate input makes the offset vary while `(from, to)` holds. + /// One-directional variation means *derived*, not *independent*; hence the + /// projection below and no field. + #[test] + fn libet_window_fires_only_on_the_rubicon_crossing() { + let mv = |from, to| KanbanMove { + mailbox: 42, + from, + to, + witness_chain_position: 3, + exec: ExecTarget::Native, + }; + + // FIRES: the Σ-commit crossing, and only with the window's magnitude. + assert_eq!( + mv(KanbanColumn::Planning, KanbanColumn::CognitiveWork).libet_window_us(), + Some(LIBET_COMMIT_WINDOW_US) + ); + + // STAYS SILENT: every other LEGAL arc in the lifecycle DAG — non-trivial + // inputs, not an empty/default move. + for (from, to) in [ + (KanbanColumn::CognitiveWork, KanbanColumn::Evaluation), + (KanbanColumn::Evaluation, KanbanColumn::Commit), + (KanbanColumn::Evaluation, KanbanColumn::Plan), + (KanbanColumn::Evaluation, KanbanColumn::Prune), + (KanbanColumn::Planning, KanbanColumn::Prune), // pre-Rubicon veto + (KanbanColumn::Plan, KanbanColumn::Planning), // re-deliberate + ] { + assert_eq!( + mv(from, to).libet_window_us(), + None, + "{from:?} -> {to:?} must open no window" + ); + } + } + + /// `Planning` is the ONLY legal predecessor of `CognitiveWork`, which is why + /// matching the pair and matching the destination alone agree over the DAG. + /// Pinned so a future arc into `CognitiveWork` breaks this test loudly + /// instead of silently widening the Rubicon crossing. + #[test] + fn cognitive_work_has_exactly_one_legal_predecessor() { + let predecessors: Vec = [ + KanbanColumn::Planning, + KanbanColumn::CognitiveWork, + KanbanColumn::Evaluation, + KanbanColumn::Commit, + KanbanColumn::Plan, + KanbanColumn::Prune, + ] + .into_iter() + .filter(|c| c.can_transition_to(KanbanColumn::CognitiveWork)) + .collect(); + assert_eq!(predecessors, vec![KanbanColumn::Planning]); + } + #[test] fn rubicon_lifecycle_transitions() { // Forward arc. diff --git a/crates/lance-graph-contract/src/lib.rs b/crates/lance-graph-contract/src/lib.rs index a9f9c3b34..5f569d721 100644 --- a/crates/lance-graph-contract/src/lib.rs +++ b/crates/lance-graph-contract/src/lib.rs @@ -54,6 +54,7 @@ pub mod awareness_facet; pub mod callcenter; pub mod cam; pub mod canonical_node; +pub mod causal_audit; pub mod causal_witness; pub mod class_view; /// D-V3-W6a — classid adoption-scan counting logic (`ClassidForm`, @@ -144,6 +145,7 @@ pub mod scenario; pub mod scheduler; pub mod selection; pub mod sensorium; +pub mod settlement; pub mod sigma_propagation; pub mod sla; pub mod soa_envelope; diff --git a/crates/lance-graph-contract/src/recipe_kernels.rs b/crates/lance-graph-contract/src/recipe_kernels.rs index 05a6441f1..352f459c3 100644 --- a/crates/lance-graph-contract/src/recipe_kernels.rs +++ b/crates/lance-graph-contract/src/recipe_kernels.rs @@ -177,11 +177,73 @@ impl ThoughtMask { } } +/// The epistemic status of a tactic's **implementation** — machine-readable, so +/// "this one is a placeholder" is a value the registry can filter on rather than +/// a sentence in a doc-comment nobody parses. +/// +/// Lives on the [`Tactic`] impl, NOT on the [`Recipe`] catalogue entry. A +/// `Recipe` describes what the tactic *is* (Tier / Mechanism / Bucket / 2³) — +/// stable properties of the concept. Maturity describes what *this code* +/// currently does, and changes the day someone finishes the implementation. The +/// catalogue entry must not have to change when that happens; folding an +/// implementation property into a concept record is the same +/// merged-carrier mistake the effect census exists to catch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KernelMaturity { + /// Has a real effect on [`ThoughtCtx`]: mutates at least one field, or + /// returns a non-zero `delta_conf` on at least one branch. Enforced by + /// `maturity_operational_implies_an_effect` — a tactic that can do neither + /// CANNOT declare itself `Operational`. + Operational, + /// Runs a real, deterministic computation but lands no effect — either it + /// ignores `ctx` entirely (algebra demonstrations), or it computes a result + /// and discards it. Honest scaffolding, not production behaviour. Whether a + /// given `Demonstration` should be wired up or deleted is an open decision, + /// recorded per-impl; it is NOT resolved by silently giving it an effect. + Demonstration, + /// Hardcoded constants standing in for an unimplemented mechanism. + Stub, +} + +impl KernelMaturity { + /// May this tactic's effects be relied on in a production dispatch? + /// Only [`Operational`](KernelMaturity::Operational). + #[inline] + #[must_use] + pub const fn is_production(self) -> bool { + matches!(self, Self::Operational) + } +} + /// The uniform behaviour every tactic implements (the Elixir-style contract). pub trait Tactic: Sync { /// The catalogue metadata for this tactic. fn meta(&self) -> &'static Recipe; + /// The epistemic status of this implementation — see [`KernelMaturity`]. + /// + /// NON-defaulted on purpose, exactly like [`requires`](Tactic::requires): a + /// default of `Operational` would let an unimplemented tactic inherit a + /// production claim by saying nothing, which is the failure mode this + /// method exists to close. + fn maturity(&self) -> KernelMaturity; + + /// The tactic's **output checklist**: which [`ThoughtField`]s its + /// [`apply`](Tactic::apply) can mutate, on ANY branch. + /// + /// **POSSIBLE writes, not guaranteed writes** — the mirror of `requires()`'s + /// *may-read*. A tactic that writes `Temperature` only under + /// `GateState::Block` still declares `Temperature`. + /// + /// This exists because `delta_conf` is ONE of eight possible effects, and + /// reading it as the whole effect is wrong: an effect census over the 34 + /// found **15 tactics returning `delta_conf = 0.0` on every branch while + /// mutating `ThoughtCtx`** — [`Tactic::run`] calls `apply(ctx)` first and + /// only then adds the delta, so a zero delta says nothing about whether the + /// context survived unchanged. `Htd` reorders the entire candidate vector + /// and reports zero. + fn writes(&self) -> ThoughtMask; + /// The tactic's **input checklist**: which [`ThoughtField`]s its [`apply`] reads. /// /// NON-defaulted on purpose — every tactic MUST declare what it consumes, so the @@ -249,6 +311,12 @@ impl Tactic for Rte { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::FreeEnergy, ThoughtField::Rung]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Rung, ThoughtField::FreeEnergy]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Recursive expansion: deepen the rung while there's surprise; Berry-Esseen-style stop. let mut depth = 0; @@ -271,6 +339,12 @@ impl Tactic for Htd { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Hierarchical decompose: bipolar split around the mean (CLAM-style). let m = mean(&ctx.candidates); @@ -288,6 +362,12 @@ impl Tactic for Smad { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // 3-agent vote: agreement (low spread) revises confidence up. let spread = ctx.candidates.iter().cloned().fold(0.0f32, f32::max) @@ -312,6 +392,12 @@ impl Tactic for Rcr { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Reverse-causality: walk backward (effect→cause) = reverse the chain. ctx.candidates.reverse(); @@ -327,6 +413,12 @@ impl Tactic for Tcp { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates, ThoughtField::Sd]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Prune low-confidence branches: keep candidates above an SD-derived floor. let floor = mean(&ctx.candidates) * (1.0 - ctx.sd); @@ -345,6 +437,12 @@ impl Tactic for Tr { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates, ThoughtField::Temperature]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Thought randomization: deterministic temperature-scaled perturbation above noise floor. let amp = (ctx.temperature * 0.1).max(NOISE_FLOOR); @@ -364,6 +462,12 @@ impl Tactic for Asc { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Confidence]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Adversarial self-critique: negate the top belief; survival = strength, else weaken. let survives = ctx.confidence > 0.6; @@ -386,6 +490,13 @@ impl Tactic for Cas { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Rung]) } + /// Demonstration: computes an abstraction level and discards it — no effect on ctx, no confidence delta. + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Demonstration + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Conditional abstraction scaling: pick HDR resolution from rung (coarse→fine). let _level = match ctx.rung { @@ -406,6 +517,12 @@ impl Tactic for Irs { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates, ThoughtField::Temperature]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Iterative roleplay: a persona modulation (structurally distinct search kernel). for c in ctx.candidates.iter_mut() { @@ -423,6 +540,12 @@ impl Tactic for Mcp { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Confidence, ThoughtField::FreeEnergy]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Meta-cognition: if confident but high free-energy (poorly calibrated), pull confidence down. let miscalibrated = ctx.confidence > 0.7 && ctx.free_energy > 0.5; @@ -445,6 +568,12 @@ impl Tactic for Cr { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Beliefs]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Contradiction: same topic, opposing frequency (one true, one false). let mut found = false; @@ -476,6 +605,13 @@ impl Tactic for Tca { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + /// only when `candidates` is non-empty + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Temporal augmentation: lag-shift the series (Granger-style precedence). if !ctx.candidates.is_empty() { @@ -493,6 +629,13 @@ impl Tactic for Cdt { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates, ThoughtField::Temperature]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + /// both branches write; the convergent branch only when a max exists + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Convergent↔divergent by temperature: hot spreads, cold collapses to the best. if ctx.temperature > 0.5 { @@ -517,6 +660,12 @@ impl Tactic for Mct { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Multimodal: unify modalities into one fingerprint (mean as the unified score). let unified = mean(&ctx.candidates); @@ -533,8 +682,16 @@ impl Tactic for Lsi { fn meta(&self) -> &'static Recipe { Self::rec() } + /// Reads `candidates` only — `Sd` is an OUTPUT (see `writes`), not an input; it + /// was over-declared before the write-mask existed. fn requires(&self) -> ThoughtMask { - ThoughtMask::of(&[ThoughtField::Candidates, ThoughtField::Sd]) + ThoughtMask::of(&[ThoughtField::Candidates]) + } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Sd]) } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Latent introspection: read the distribution (mean/sd) and write sd back. @@ -554,6 +711,12 @@ impl Tactic for Pso { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Scaffold: pre-organize (sort) the reasoning candidates descending. ctx.candidates @@ -570,6 +733,12 @@ impl Tactic for Cdi { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Beliefs, ThoughtField::Dissonance]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Beliefs, ThoughtField::Dissonance]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Induce dissonance: inject a conflicting belief to force deeper investigation. let topic = ctx.beliefs.first().map(|b| b.0).unwrap_or(0); @@ -591,6 +760,13 @@ impl Tactic for Cws { ThoughtField::Beliefs, ]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + /// only when a max-scoring candidate exists + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Beliefs]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Context persistence: checkpoint the current best into the (persistent) belief set. if let Some(&best) = ctx.candidates.get(max_idx(&ctx.candidates)) { @@ -608,6 +784,13 @@ impl Tactic for Are { fn requires(&self) -> ThoughtMask { ThoughtMask::EMPTY } + /// Demonstration: context-blind ABBA unbind identity; ignores ctx entirely. + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Demonstration + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, _ctx: &mut ThoughtCtx) -> Outcome { // Reverse-engineer via exact algebraic inverse: A⊗B⊗B = A (XOR self-inverse). let (a, b) = (0xDEADBEEFu32, 0xCAFEBABEu32); @@ -625,6 +808,12 @@ impl Tactic for Tcf { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Cascade filter: N strategies = N perturbed views; keep the agreement (median). let mut v = ctx.candidates.clone(); @@ -644,6 +833,12 @@ impl Tactic for Ssr { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Confidence, ThoughtField::FreeEnergy]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Self-skepticism: challenge intensity scales with (confidence − evidence). let intensity = (ctx.confidence - ctx.free_energy.min(1.0)).max(0.0); @@ -659,6 +854,15 @@ impl Tactic for Etd { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + /// Demonstration: sorts a CLONE of `candidates` and never writes it back — the + /// computed decomposition is discarded. Wiring it up is a behaviour change and + /// needs an explicit decision, not a silent fix. + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Demonstration + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Emergent decomposition: split at the largest gap (natural cluster boundary). let mut v = ctx.candidates.clone(); @@ -675,6 +879,13 @@ impl Tactic for Amp { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::FreeEnergy, ThoughtField::Rung]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + /// only when `free_energy > 0.5` + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Rung]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Adaptive meta: TD-style — raise the rung when free-energy stays high. if ctx.free_energy > 0.5 { @@ -692,6 +903,13 @@ impl Tactic for Zcf { fn requires(&self) -> ThoughtMask { ThoughtMask::EMPTY } + /// Demonstration: context-blind VSA bind identity; ignores ctx entirely. + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Demonstration + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, _ctx: &mut ThoughtCtx) -> Outcome { // Zero-shot fusion: bind(A,B) — valid in both, recoverable. let (a, b) = (0x0Au32, 0xB0u32); @@ -709,6 +927,13 @@ impl Tactic for Hpm { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + /// only when `candidates` is non-empty + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Pattern match: nearest candidate to a query target (the substrate sweep). let target = 0.5f32; @@ -732,6 +957,13 @@ impl Tactic for Cur { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + /// only while more than one candidate remains (the loop may not run) + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Cascading uncertainty reduction: coarse→fine prune ~half per pass; raise confidence. while ctx.candidates.len() > 1 { @@ -756,6 +988,12 @@ impl Tactic for Mpc { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Multi-perspective compression: bundle = consensus (mean per the bundle op). let consensus = mean(&ctx.candidates); @@ -772,6 +1010,12 @@ impl Tactic for Ssam { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Sd]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Analogy A→B, C≈A ⊢ C→B: confidence ∝ source similarity. let sim = 1.0 - ctx.sd; // closer cluster ⇒ stronger analogy @@ -787,6 +1031,13 @@ impl Tactic for Idr { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + /// only when `candidates` is non-empty + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Candidates]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Intent reframe: pick the dominant interpretation (max candidate). let i = max_idx(&ctx.candidates); @@ -805,6 +1056,12 @@ impl Tactic for Spp { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Shadow-parallel: two independent paths; agreement = structural verification. let path_a = mean(&ctx.candidates); @@ -830,6 +1087,13 @@ impl Tactic for Icr { fn requires(&self) -> ThoughtMask { ThoughtMask::EMPTY } + /// Stub: hardcoded constants; `delta_conf` is literally `x * 0.0`. + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Stub + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } /// **⚠ STUB — this is NOT a Pearl counterfactual.** Labelled honestly per the /// falsifiability rule ("a doc-comment claim is not a behaviour"). /// @@ -871,6 +1135,14 @@ impl Tactic for Sdd { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Candidates]) } + /// Demonstration: detects distortion and reports it in the note, but `delta_conf` + /// is hardcoded 0.0 outside the branch — the detection lands nowhere. + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Demonstration + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Semantic distortion: deviation above the Berry-Esseen noise floor = real distortion. let dev = (mean(&ctx.candidates) - 0.5).abs(); @@ -894,6 +1166,13 @@ impl Tactic for Dtmf { fn requires(&self) -> ThoughtMask { ThoughtMask::of(&[ThoughtField::Sd, ThoughtField::Temperature]) } + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Operational + } + /// only when the gate reads BLOCK + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[ThoughtField::Temperature]) + } fn apply(&self, ctx: &mut ThoughtCtx) -> Outcome { // Meta-frame switch when the current frame is BLOCKed. let switched = ctx.gate_state() == GateState::Block; @@ -919,6 +1198,13 @@ impl Tactic for Hkf { fn requires(&self) -> ThoughtMask { ThoughtMask::EMPTY } + /// Demonstration: context-blind cross-domain bind identity; ignores ctx entirely. + fn maturity(&self) -> KernelMaturity { + KernelMaturity::Demonstration + } + fn writes(&self) -> ThoughtMask { + ThoughtMask::of(&[]) + } fn apply(&self, _ctx: &mut ThoughtCtx) -> Outcome { // Cross-domain fusion: bind(domain_A, relation, domain_B); reversible/auditable. let (da, rel, db) = (0x11u32, 0x22u32, 0x44u32); @@ -954,6 +1240,309 @@ kernels! { 33 => Dtmf, 34 => Hkf, } +/// Effect-census tests: do the declared masks tell the truth? +/// +/// A declaration drifts from its implementation exactly like a doc-comment +/// does — that is the failure this whole module's `writes()` mask exists to +/// answer, so the mask itself needs a falsifier. Every test here asks +/// *what input would make this fail?* +#[cfg(test)] +mod effect_census { + use super::*; + + /// Probe contexts chosen to exercise the CONDITIONAL write branches the + /// census identified — empty vs populated `candidates`, both sides of the + /// `temperature > 0.5` split, `free_energy > 0.5`, and all three + /// `GateState`s. A single default ctx would leave conditional writers + /// looking inert and quietly pass every test below. + fn probes() -> Vec { + let mut hot = ThoughtCtx::new(vec![0.9, 0.6, 0.3, 0.1]); + hot.sd = 0.5; // BLOCK — the only gate state Dtmf switches under + hot.temperature = 0.9; // > 0.5 — Cdt divergent branch + hot.free_energy = 0.9; // > 0.5 — Amp raises the rung + hot.rung = 3; + hot.beliefs = vec![(7, 0.9, 0.8), (7, 0.1, 0.7)]; // same-topic contradiction + + let mut cold = ThoughtCtx::new(vec![0.4, 0.45, 0.5, 0.55]); + cold.sd = 0.05; // FLOW + cold.temperature = 0.1; // <= 0.5 — Cdt convergent branch + cold.free_energy = 0.05; // <= 0.5 — Amp holds + cold.rung = 8; + cold.beliefs = vec![(3, 0.6, 0.5)]; + + let mut empty = ThoughtCtx::new(vec![]); // every `is_empty` guard bites + empty.sd = 0.25; // HOLD + empty.beliefs = vec![]; + + let mut single = ThoughtCtx::new(vec![0.5]); // len == 1: Cur's loop never runs + single.sd = 0.25; + single.beliefs = vec![(1, 0.5, 0.5)]; + + // Overconfident-and-surprised. Added because + // `maturity_operational_implies_an_effect` FAILED on `Mcp` without it: + // every probe above inherits `ThoughtCtx::new`'s `confidence = 0.5`, so + // Mcp's `confidence > 0.7 && free_energy > 0.5` branch was unreachable + // and Mcp looked inert. The gap was in the fixtures, not the kernel — + // which is precisely what a can-fire test is for, and it found the hole + // in the probe matrix before it found one in a kernel. + let mut overconfident = ThoughtCtx::new(vec![0.8, 0.2]); + overconfident.confidence = 0.95; + overconfident.free_energy = 0.9; + overconfident.sd = 0.4; // BLOCK + overconfident.temperature = 0.6; + overconfident.rung = 5; + overconfident.beliefs = vec![(9, 0.9, 0.9), (9, 0.05, 0.6)]; + + vec![hot, cold, empty, single, overconfident] + } + + /// Which [`ThoughtField`]s differ between two contexts. + /// + /// Bit-equality on the floats: "unchanged" means the kernel did not touch + /// it, so an exact comparison is the right one (no epsilon — an epsilon + /// here would hide small real writes). + fn changed_fields(before: &ThoughtCtx, after: &ThoughtCtx) -> ThoughtMask { + // Build through the canonical constructor, not a hand-rolled + // `1 << (f as u8)`: the census must be tied to `ThoughtMask`'s own bit + // encoding, or it silently diverges if `of` ever changes (CodeRabbit, + // PR #854). Ironic on arrival — this helper bypassed the constructor + // added in the same commit. + let mut fields: Vec = Vec::new(); + let mut set = |f: ThoughtField| fields.push(f); + if before.sd != after.sd { + set(ThoughtField::Sd); + } + if before.free_energy != after.free_energy { + set(ThoughtField::FreeEnergy); + } + if before.dissonance != after.dissonance { + set(ThoughtField::Dissonance); + } + if before.temperature != after.temperature { + set(ThoughtField::Temperature); + } + if before.confidence != after.confidence { + set(ThoughtField::Confidence); + } + if before.rung != after.rung { + set(ThoughtField::Rung); + } + if before.candidates != after.candidates { + set(ThoughtField::Candidates); + } + if before.beliefs != after.beliefs { + set(ThoughtField::Beliefs); + } + ThoughtMask::of(&fields) + } + + /// **No kernel may mutate a field it did not declare.** + /// + /// Uses `apply` directly, NOT `run`: `run` adds `delta_conf` to + /// `ctx.confidence` afterwards, which is a separate declared effect and + /// would otherwise show up here as an undeclared `Confidence` write. + #[test] + fn no_kernel_writes_outside_its_declared_mask() { + for k in all_kernels() { + let declared = k.writes(); + for probe in probes() { + let before = probe.clone(); + let mut after = probe; + let _ = k.apply(&mut after); + let actual = changed_fields(&before, &after); + assert!( + actual.covered_by(declared), + "{} ({}) mutated fields outside writes(): actual={:08b} declared={:08b}", + k.meta().code, + k.meta().id, + actual.0, + declared.0 + ); + } + } + } + + /// **A declared write must be REACHABLE** — the can-fire half. + /// + /// A mask that over-declares is as dishonest as one that under-declares: + /// it makes a kernel look more effectful than it is, and it is exactly + /// what `Lsi` was doing on the read side (declaring `Sd` as an input it + /// never read) before the census. + #[test] + fn every_declared_write_actually_happens_on_some_probe() { + for k in all_kernels() { + let declared = k.writes(); + if declared.is_empty() { + continue; + } + let mut observed = 0u8; + for probe in probes() { + let before = probe.clone(); + let mut after = probe; + let _ = k.apply(&mut after); + observed |= changed_fields(&before, &after).0; + } + assert_eq!( + observed & declared.0, + declared.0, + "{} ({}) declares writes it never performs: declared={:08b} observed={:08b}", + k.meta().code, + k.meta().id, + declared.0, + observed + ); + } + } + + /// **`Operational` requires an effect.** A kernel that can neither mutate + /// `ThoughtCtx` nor move confidence is not production behaviour, whatever + /// its note string claims. + /// + /// This is the invariant [`KernelMaturity::Operational`] documents, made + /// executable — without it, maturity is another unenforced doc-comment. + #[test] + fn maturity_operational_implies_an_effect() { + for k in all_kernels() { + if k.maturity() != KernelMaturity::Operational { + continue; + } + let has_write = !k.writes().is_empty(); + let moves_confidence = probes().into_iter().any(|mut c| { + let out = k.apply(&mut c); + out.delta_conf != 0.0 + }); + assert!( + has_write || moves_confidence, + "{} ({}) claims Operational but writes nothing and never moves confidence", + k.meta().code, + k.meta().id + ); + } + } + + /// The converse: a `Demonstration` or `Stub` must NOT be quietly + /// effectful. If one starts doing real work, its maturity is stale and + /// this fails rather than letting an unreviewed effect ride in under a + /// "not production" label. + #[test] + fn non_operational_kernels_land_no_effect() { + for k in all_kernels() { + if k.maturity() == KernelMaturity::Operational { + continue; + } + assert!( + k.writes().is_empty(), + "{} is {:?} but declares writes", + k.meta().code, + k.maturity() + ); + for probe in probes() { + let before = probe.clone(); + let mut after = probe; + let out = k.apply(&mut after); + assert_eq!( + changed_fields(&before, &after), + ThoughtMask::EMPTY, + "{} is {:?} but mutated ctx", + k.meta().code, + k.maturity() + ); + assert_eq!( + out.delta_conf, + 0.0, + "{} is {:?} but moved confidence", + k.meta().code, + k.maturity() + ); + } + } + } + + /// The four context-blind kernels return the SAME outcome for radically + /// different inputs — the honest test for an algebra demonstration. + /// + /// Note this is deliberately NOT a can-fire / can-stay-silent pair: those + /// belong to detectors and thresholded gates. A kernel that ignores its + /// argument has no input condition to fire on, so the meaningful property + /// is *invariance*, and asserting anything else would be theatre. + #[test] + fn context_blind_kernels_are_input_invariant() { + const BLIND: [u8; 4] = [19, 24, 31, 34]; // Are, Zcf, Icr, Hkf + for id in BLIND { + let k = kernel(id).expect("id in range"); + let outs: Vec = probes().into_iter().map(|mut c| k.apply(&mut c)).collect(); + for o in &outs { + assert_eq!( + o, + &outs[0], + "{} is context-blind and must not vary with ctx", + k.meta().code + ); + } + assert_ne!( + k.maturity(), + KernelMaturity::Operational, + "{} ignores ctx entirely and cannot be Operational", + k.meta().code + ); + } + } + + /// The maturity split is non-trivial in BOTH directions — neither label is + /// vacuous. A classification that applied to everything (or nothing) would + /// carry exactly as much information as no classification at all. + #[test] + fn maturity_discriminates_and_is_not_all_one_label() { + let ks = all_kernels(); + let operational = ks + .iter() + .filter(|k| k.maturity() == KernelMaturity::Operational) + .count(); + let demonstration = ks + .iter() + .filter(|k| k.maturity() == KernelMaturity::Demonstration) + .count(); + let stub = ks + .iter() + .filter(|k| k.maturity() == KernelMaturity::Stub) + .count(); + + assert_eq!(operational + demonstration + stub, 34); + assert!(operational > 0 && operational < 34, "not all one label"); + assert!(demonstration > 0, "the demonstrations must stay visible"); + assert_eq!(stub, 1, "Icr is the one self-declared stub"); + } + + /// **The finding this census exists for.** `delta_conf == 0.0` does NOT + /// mean "no effect": `run` applies the delta only AFTER `apply` has had + /// full `&mut` access. A substantial set of kernels return zero while + /// reordering candidates, rewriting beliefs, or raising the rung. + /// + /// Pinned as a REGRESSION GUARD, not as a target: if a future refactor + /// makes zero-delta imply inert, this fails and the reasoning gets + /// re-examined rather than silently inverted. + #[test] + fn zero_delta_does_not_imply_inert() { + let silent_mutators: Vec<&'static str> = all_kernels() + .iter() + .filter(|k| !k.writes().is_empty()) + .filter(|k| { + probes() + .into_iter() + .all(|mut c| k.apply(&mut c).delta_conf == 0.0) + }) + .map(|k| k.meta().code) + .collect(); + + assert!( + silent_mutators.len() >= 10, + "expected a substantial silent-mutator set, found {}: {:?}", + silent_mutators.len(), + silent_mutators + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/lance-graph-contract/src/scheduler.rs b/crates/lance-graph-contract/src/scheduler.rs index 4e9e84840..c17eed44a 100644 --- a/crates/lance-graph-contract/src/scheduler.rs +++ b/crates/lance-graph-contract/src/scheduler.rs @@ -27,7 +27,7 @@ //! is the sole mutator (R1 "one SoA never transformed"; mirrors the //! `MailboxSoaView` / `MailboxSoaOwner` read/write split). -use crate::kanban::{ExecTarget, KanbanColumn, KanbanMove}; +use crate::kanban::{ExecTarget, KanbanMove}; use crate::soa_view::MailboxSoaView; /// A monotonic Lance dataset version — the surreal Timeline tick, i.e. one entry @@ -89,12 +89,6 @@ impl VersionScheduler for NextPhaseScheduler { // `next_phases()` is empty exactly for the absorbing columns (Commit/Prune): // `?` short-circuits to `None`, i.e. "the cycle ended — schedule nothing". let to = *from.next_phases().first()?; - let libet_offset_us = if from == KanbanColumn::Planning && to == KanbanColumn::CognitiveWork - { - -550_000 - } else { - 0 - }; Some(KanbanMove { mailbox: view.mailbox_id(), from, @@ -103,7 +97,6 @@ impl VersionScheduler for NextPhaseScheduler { // for the chain index until the A3 `witness_arc` column lands. Read it as // the SoA cycle-ownership stamp via `KanbanMove::cycle()` (S2.5). witness_chain_position: view.current_cycle(), - libet_offset_us, exec, }) } @@ -113,6 +106,7 @@ impl VersionScheduler for NextPhaseScheduler { mod tests { use super::*; use crate::collapse_gate::MailboxId; + use crate::kanban::{KanbanColumn, LIBET_COMMIT_WINDOW_US}; /// Minimal `MailboxSoaView` with a settable phase — proves the scheduler /// lowers a version event to the right move without any consumer crate @@ -171,7 +165,7 @@ mod tests { .expect("Planning is not absorbing"); assert_eq!(m.from, KanbanColumn::Planning); assert_eq!(m.to, KanbanColumn::CognitiveWork); // forward arc, not the Prune veto - assert_eq!(m.libet_offset_us, -550_000); // the Σ-commit Rubicon crossing + assert_eq!(m.libet_window_us(), Some(LIBET_COMMIT_WINDOW_US)); // Σ-commit Rubicon crossing assert_eq!(m.mailbox, 42); assert_eq!(m.witness_chain_position, 9); // current_cycle stamp } @@ -186,7 +180,7 @@ mod tests { ) .unwrap(); assert_eq!(cw.to, KanbanColumn::Evaluation); - assert_eq!(cw.libet_offset_us, 0); + assert_eq!(cw.libet_window_us(), None); let ev = NextPhaseScheduler .on_version( @@ -196,7 +190,7 @@ mod tests { ) .unwrap(); assert_eq!(ev.to, KanbanColumn::Commit); // forward arc = calcify - assert_eq!(ev.libet_offset_us, 0); + assert_eq!(ev.libet_window_us(), None); } #[test] diff --git a/crates/lance-graph-contract/src/settlement.rs b/crates/lance-graph-contract/src/settlement.rs new file mode 100644 index 000000000..de8fa7fe8 --- /dev/null +++ b/crates/lance-graph-contract/src/settlement.rs @@ -0,0 +1,302 @@ +//! Settlement as a FOUR-dimensional field, never a score. +//! +//! ## The discriminator is closure × competence +//! +//! Two independent questions, and collapsing them is the whole failure mode: +//! +//! - **Closure density** — how structurally complete the belief field is: how +//! much of what could be derived has been. +//! - **Evidence competence** — how well-grounded that structure is, per the +//! `deepnsm-v2` `1 - U` reading (confidence · contradiction · derived-share). +//! +//! | Closure | Competence | Cell | +//! |---|---|---| +//! | high | high | [`Crystal`](SettlementCell::Crystal) — settled and deserved | +//! | high | low | [`Glass`](SettlementCell::Glass) — **dense closure on thin evidence** | +//! | low | high | [`GroundedUnresolved`](SettlementCell::GroundedUnresolved) | +//! | low | low | [`Fog`](SettlementCell::Fog) | +//! +//! **Glass is the dangerous cell**, and it is exactly what a scalar hides: it +//! looks like Crystal from the closure side and like Fog from the evidence +//! side, so any single number averages it into something unremarkable. +//! +//! ## Entropy is a THIRD signal, not one of the two axes +//! +//! An earlier formulation put entropy on both axes — "crystal = low entropy +//! high closure, glass = low entropy low closure" — which silently deleted +//! competence and made the matrix a restatement of one variable. Entropy +//! describes **field concentration**: how narrow the surviving hypothesis +//! space is. Concentration says nothing about whether the concentration is +//! structurally closed or evidentially earned. Same for eigenvalue +//! concentration, which measures how much of the field is dominated by one +//! lineage. Both refine the cell; neither defines it: +//! +//! - Glass + low entropy + high eigenvalue → confidently calcified monoculture +//! - Glass + high entropy → many thinly-supported derived structures +//! - Crystal + low entropy + low concentration → legitimate settlement +//! - Crystal + high eigenvalue → perhaps right, but dominated by one lineage +//! +//! ## Scope alignment is a precondition, so it is a field +//! +//! Closure is a whole-arena property; competence is per-basin (often a single +//! subject). Subtracting one from the other across mismatched scopes produces +//! a confident number about nothing. [`SettlementScope`] is carried WITH the +//! signals and [`SettlementSignals::comparable_to`] refuses mismatched pairs — +//! the alignment requirement made structural rather than remembered. +//! +//! ## No derived scalar is provided, on purpose +//! +//! There is deliberately no `glass_gap()` here. A difference between closure +//! and competence is only meaningful once both are calibrated at the same +//! scope, and that calibration has not been done. Shipping the subtraction +//! first is how the four signals become one again. + +/// What a [`SettlementSignals`] measurement covers. +/// +/// Two readings are comparable only when every component matches. Version and +/// branch are included because a settlement reading is an epistemic +/// observation: "how settled, as of when, on which line of development". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SettlementScope { + /// The arena / field the reading covers. + pub arena_id: u32, + /// The basin within it, or `None` for a whole-arena reading. + /// + /// A whole-arena closure and a single-basin competence are NOT comparable; + /// this is the field that makes that checkable. + pub basin_id: Option, + /// The dataset version read as-of. + pub version: u64, + /// The line of development. + pub branch_id: u32, + /// How far back the evidence horizon extends, in versions. Two readings + /// over different horizons see different evidence and are not comparable. + pub witness_horizon: u32, +} + +/// Which settlement cell a reading falls in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SettlementCell { + /// High closure, high competence — settled, and the settlement is earned. + Crystal, + /// High closure, LOW competence — dense structure on thin evidence. Reads + /// as settled from the outside and is the cell most worth interrupting. + Glass, + /// Low closure, high competence — well-grounded and honestly unfinished. + GroundedUnresolved, + /// Low closure, low competence — neither structured nor grounded. + Fog, +} + +impl SettlementCell { + /// Does this cell present as settled, whether or not it deserves to? + /// True for [`Crystal`](Self::Crystal) AND [`Glass`](Self::Glass) — that + /// shared appearance is precisely why the second axis is needed. + #[inline] + #[must_use] + pub const fn appears_settled(self) -> bool { + matches!(self, Self::Crystal | Self::Glass) + } + + /// Is the settlement evidentially earned? + #[inline] + #[must_use] + pub const fn is_earned(self) -> bool { + matches!(self, Self::Crystal | Self::GroundedUnresolved) + } +} + +/// The four preserved settlement signals for one scope. +/// +/// All four are kept. There is no constructor that reduces them to a score. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SettlementSignals { + /// What this reading covers — carried so comparisons can be refused. + pub scope: SettlementScope, + /// Structural completeness, `0.0..=1.0`. + pub closure_density: f32, + /// Evidential grounding (`1 - U`), `0.0..=1.0`. + pub evidence_competence: f32, + /// Field concentration — how narrow the surviving space is. A refining + /// signal, NOT one of the two classifying axes. + pub field_entropy: f32, + /// How much of the field one lineage dominates. Also refining. + pub eigenvalue_concentration: f32, +} + +/// Midpoint split for both classifying axes. +/// +/// Hand-chosen, and said out loud per `I-NOISE-FLOOR-JIRAK`: this is NOT a +/// bound-derived threshold. It is the neutral split for an uncalibrated +/// `0..1` reading, and the falsification matrix below is what would expose it +/// if real data clusters away from the midpoint. +pub const SETTLEMENT_MIDPOINT: f32 = 0.5; + +impl SettlementSignals { + /// Which cell this reading falls in — closure × competence ONLY. + /// + /// Entropy and eigenvalue concentration are deliberately not consulted: + /// they refine a cell, they do not choose it. + #[must_use] + pub fn cell(&self) -> SettlementCell { + let closed = self.closure_density >= SETTLEMENT_MIDPOINT; + let grounded = self.evidence_competence >= SETTLEMENT_MIDPOINT; + match (closed, grounded) { + (true, true) => SettlementCell::Crystal, + (true, false) => SettlementCell::Glass, + (false, true) => SettlementCell::GroundedUnresolved, + (false, false) => SettlementCell::Fog, + } + } + + /// May these two readings be compared at all? + /// + /// Every scope component must match. This is the precondition that made + /// `wisdom - competence` meaningless: whole-arena closure against + /// per-basin competence is a confident number about nothing. + #[inline] + #[must_use] + pub fn comparable_to(&self, other: &Self) -> bool { + self.scope == other.scope + } + + /// Is this a confidently-calcified monoculture — glass, narrow, and + /// dominated by one lineage? + /// + /// The composite worth naming, because all three signals must agree before + /// it means anything, and it is still a PREDICATE over preserved fields, + /// never a score that replaces them. + #[must_use] + pub fn is_calcified_monoculture(&self, entropy_ceiling: f32, dominance_floor: f32) -> bool { + self.cell() == SettlementCell::Glass + && self.field_entropy <= entropy_ceiling + && self.eigenvalue_concentration >= dominance_floor + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scope() -> SettlementScope { + SettlementScope { + arena_id: 1, + basin_id: None, + version: 7, + branch_id: 0, + witness_horizon: 32, + } + } + + fn signals(closure: f32, competence: f32, entropy: f32, eigen: f32) -> SettlementSignals { + SettlementSignals { + scope: scope(), + closure_density: closure, + evidence_competence: competence, + field_entropy: entropy, + eigenvalue_concentration: eigen, + } + } + + /// The falsification matrix: all four cells are reachable, and each is + /// reached by varying ONLY the two classifying axes. + #[test] + fn all_four_cells_are_reachable() { + assert_eq!(signals(0.9, 0.9, 0.1, 0.1).cell(), SettlementCell::Crystal); + assert_eq!(signals(0.9, 0.1, 0.1, 0.1).cell(), SettlementCell::Glass); + assert_eq!( + signals(0.1, 0.9, 0.1, 0.1).cell(), + SettlementCell::GroundedUnresolved + ); + assert_eq!(signals(0.1, 0.1, 0.1, 0.1).cell(), SettlementCell::Fog); + } + + /// **Entropy must NOT move the cell.** This is the regression guard against + /// the earlier formulation that put entropy on both axes and thereby + /// deleted competence from the matrix. + #[test] + fn entropy_and_eigenvalue_never_change_the_cell() { + for entropy in [0.0, 0.25, 0.5, 0.75, 1.0] { + for eigen in [0.0, 0.5, 1.0] { + assert_eq!( + signals(0.9, 0.2, entropy, eigen).cell(), + SettlementCell::Glass, + "closure/competence decide; entropy={entropy} eigen={eigen} must not" + ); + } + } + } + + /// Closure and competence are INDEPENDENTLY variable — the orthogonality + /// receipt, two non-trivial witnesses. + #[test] + fn closure_and_competence_are_independently_variable() { + // Witness 1: closure varies, competence fixed low → Fog ⇄ Glass. + assert_ne!( + signals(0.1, 0.2, 0.5, 0.5).cell(), + signals(0.9, 0.2, 0.5, 0.5).cell() + ); + // Witness 2: competence varies, closure fixed high → Glass ⇄ Crystal. + assert_ne!( + signals(0.9, 0.2, 0.5, 0.5).cell(), + signals(0.9, 0.8, 0.5, 0.5).cell() + ); + } + + /// Glass and Crystal are indistinguishable on appearance and separated only + /// by competence — the reason a single settlement score cannot work. + #[test] + fn glass_and_crystal_both_appear_settled() { + let glass = signals(0.9, 0.2, 0.2, 0.9); + let crystal = signals(0.9, 0.9, 0.2, 0.2); + assert!(glass.cell().appears_settled()); + assert!(crystal.cell().appears_settled()); + assert!(!glass.cell().is_earned()); + assert!(crystal.cell().is_earned()); + } + + /// Mismatched scope refuses comparison — whole-arena vs per-basin is the + /// exact mix that made the earlier subtraction meaningless. + #[test] + fn mismatched_scope_is_not_comparable() { + let whole = signals(0.9, 0.9, 0.2, 0.2); + let mut per_basin = whole; + per_basin.scope.basin_id = Some(4); + assert!(!whole.comparable_to(&per_basin)); + assert!(whole.comparable_to(&whole.clone())); + + // Version and horizon are equally disqualifying. + let mut later = whole; + later.scope.version = 8; + assert!(!whole.comparable_to(&later)); + let mut wider = whole; + wider.scope.witness_horizon = 64; + assert!(!whole.comparable_to(&wider)); + } + + /// The composite predicate discriminates in BOTH directions — it fires on a + /// calcified monoculture and stays silent on non-trivial near-misses, one + /// per conjunct. + #[test] + fn calcified_monoculture_fires_and_stays_silent() { + let (ceil, floor) = (0.3, 0.7); + + assert!(signals(0.9, 0.2, 0.1, 0.9).is_calcified_monoculture(ceil, floor)); + + // Earned settlement — Crystal, not Glass. + assert!(!signals(0.9, 0.9, 0.1, 0.9).is_calcified_monoculture(ceil, floor)); + // Glass, but the field is still wide. + assert!(!signals(0.9, 0.2, 0.8, 0.9).is_calcified_monoculture(ceil, floor)); + // Glass and narrow, but no single lineage dominates. + assert!(!signals(0.9, 0.2, 0.1, 0.2).is_calcified_monoculture(ceil, floor)); + } + + /// The thresholds are live knobs, not decoration: tightening must silence, + /// loosening must admit. + #[test] + fn monoculture_thresholds_are_not_inert() { + let s = signals(0.9, 0.2, 0.4, 0.6); + assert!(!s.is_calcified_monoculture(0.3, 0.7), "outside both bounds"); + assert!(s.is_calcified_monoculture(0.5, 0.5), "loosening admits it"); + } +} diff --git a/crates/lance-graph-contract/src/soa_view.rs b/crates/lance-graph-contract/src/soa_view.rs index 05f6adea4..5d301ea7b 100644 --- a/crates/lance-graph-contract/src/soa_view.rs +++ b/crates/lance-graph-contract/src/soa_view.rs @@ -374,11 +374,6 @@ mod tests { from, to, witness_chain_position: self.cycle, - libet_offset_us: if to == KanbanColumn::CognitiveWork { - -550_000 - } else { - 0 - }, exec: crate::kanban::ExecTarget::Native, } } @@ -425,12 +420,17 @@ mod tests { } #[test] - fn owner_advances_phase_and_sets_libet_anchor() { + fn owner_advances_phase_and_opens_the_libet_window() { let mut soa = sample(); let m = soa.advance_phase(KanbanColumn::CognitiveWork); assert_eq!(m.from, KanbanColumn::Planning); assert_eq!(m.to, KanbanColumn::CognitiveWork); - assert_eq!(m.libet_offset_us, -550_000); + // The window is DERIVED from the crossing — the owner cannot stamp a + // Σ-commit move that disagrees with itself about having crossed. + assert_eq!( + m.libet_window_us(), + Some(crate::kanban::LIBET_COMMIT_WINDOW_US) + ); assert_eq!(soa.phase(), KanbanColumn::CognitiveWork); } diff --git a/crates/lance-graph-planner/src/elevation/cycle.rs b/crates/lance-graph-planner/src/elevation/cycle.rs index 9acc5a2ae..223280fde 100644 --- a/crates/lance-graph-planner/src/elevation/cycle.rs +++ b/crates/lance-graph-planner/src/elevation/cycle.rs @@ -30,14 +30,18 @@ use super::budget::{budget_for_cluster, PatienceBudget}; use crate::thinking::style::ThinkingCluster; -use lance_graph_contract::kanban::KanbanMove; +use lance_graph_contract::kanban::{KanbanMove, LIBET_COMMIT_WINDOW_US}; use std::time::Duration; -/// The per-cycle net thinking budget, in µs — the magnitude of the Libet -/// anchor (`-550_000 µs`) the contract scheduler stamps on the Σ-commit -/// crossing. A parity test below pins this against the REAL stamped move so -/// the two constants cannot drift apart silently. -pub const LIBET_CYCLE_BUDGET_US: u32 = 550_000; +/// The per-cycle net thinking budget, in µs — the Libet window a cycle has +/// between the Σ-commit crossing and the act landing. +/// +/// **Re-exported, not restated.** This IS +/// [`lance_graph_contract::kanban::LIBET_COMMIT_WINDOW_US`]; the literal has +/// exactly one definition, so there is nothing left for a parity test to keep +/// in sync (the window is now derived from the transition — see +/// `KanbanMove::libet_window_us`). +pub const LIBET_CYCLE_BUDGET_US: u32 = LIBET_COMMIT_WINDOW_US; /// Measured per-card kanban overhead (spawn + 3 Rubicon ticks + join): /// **~66 µs** — onebrc-probe lane E (t2, 2026-07-02), fine granularity. @@ -75,18 +79,16 @@ impl CycleBudget { Self::new(LIBET_CYCLE_BUDGET_US) } - /// **The M12 read side:** derive the cycle budget from a stamped - /// [`KanbanMove`]. `Some` exactly when the move carries a Libet anchor - /// (a negative `libet_offset_us` — the Σ-commit `Planning → - /// CognitiveWork` crossing); the budget is the anchor's magnitude. - /// Mid-cycle moves (offset `0`) carry no window → `None` (keep the - /// current budget; a move never *shrinks* the cycle). + /// **The M12 read side:** derive the cycle budget from a [`KanbanMove`]. + /// `Some` exactly when the move IS the Σ-commit `Planning → CognitiveWork` + /// crossing; mid-cycle moves open no window → `None` (keep the current + /// budget; a move never *shrinks* the cycle). + /// + /// The window is now a projection of the transition rather than a stored + /// field, so there is no sign test and no `unsigned_abs()` here: a move + /// cannot disagree with itself about whether it crossed the Rubicon. pub fn from_move(mv: &KanbanMove) -> Option { - if mv.libet_offset_us < 0 { - Some(Self::new(mv.libet_offset_us.unsigned_abs())) - } else { - None - } + mv.libet_window_us().map(Self::new) } /// Charge `us` microseconds of completed work (saturating — spending @@ -189,11 +191,12 @@ mod tests { } #[test] - fn libet_constant_pins_the_real_scheduler_stamp_no_silent_drift() { - // The M12 parity gate: our budget constant equals the magnitude the - // REAL contract scheduler stamps on the Σ-commit crossing. If the - // contract anchor ever changes, this test fails loudly instead of - // the two constants drifting apart. + fn budget_derives_from_the_real_scheduler_crossing() { + // The M12 read gate against the REAL contract scheduler (not a + // hand-built move): the Σ-commit crossing opens a window of exactly + // the canonical width. There is no longer a second constant to drift — + // LIBET_CYCLE_BUDGET_US IS the contract's LIBET_COMMIT_WINDOW_US — so + // what this pins is the SEAM: scheduler → move → budget. let mv = NextPhaseScheduler .on_version( &PhaseView(KanbanColumn::Planning), @@ -202,19 +205,16 @@ mod tests { ) .expect("Planning proposes the forward arc"); assert_eq!(mv.to, KanbanColumn::CognitiveWork); - assert!(mv.libet_offset_us < 0, "Σ-commit carries the anchor"); - assert_eq!(mv.libet_offset_us.unsigned_abs(), LIBET_CYCLE_BUDGET_US); - // The read side: the budget derives FROM the stamped move. - let budget = CycleBudget::from_move(&mv).expect("anchored move opens a window"); + let budget = CycleBudget::from_move(&mv).expect("the crossing opens a window"); assert_eq!(budget.remaining_us(), LIBET_CYCLE_BUDGET_US); assert_eq!(budget, CycleBudget::libet()); } #[test] fn mid_cycle_moves_open_no_window() { - // A mid-cycle advance (CognitiveWork → Evaluation) carries offset 0: - // no new window — the current budget keeps running. + // A mid-cycle advance (CognitiveWork → Evaluation) is not a Rubicon + // crossing: no new window — the current budget keeps running. let mv = NextPhaseScheduler .on_version( &PhaseView(KanbanColumn::CognitiveWork), @@ -222,7 +222,7 @@ mod tests { ExecTarget::Native, ) .expect("forward arc"); - assert_eq!(mv.libet_offset_us, 0); + assert_eq!(mv.libet_window_us(), None); assert!(CycleBudget::from_move(&mv).is_none()); } diff --git a/crates/lance-graph-planner/src/nars/insight.rs b/crates/lance-graph-planner/src/nars/insight.rs index d70663272..cd24950fc 100644 --- a/crates/lance-graph-planner/src/nars/insight.rs +++ b/crates/lance-graph-planner/src/nars/insight.rs @@ -17,6 +17,7 @@ //! measures SIZE, not insight (the D-SRS-3b "composite = size" collapse shape). use super::belief::BeliefArena; +use crate::temporal::QueryReference; use lance_graph_contract::mul::FlowState; use lance_graph_contract::sensorium::GraphSignals; @@ -47,6 +48,71 @@ pub struct Snapshot { pub wonder: f32, } +/// A [`Snapshot`] stamped with the epistemic view it was read under — an owned, +/// movable record of "these signals, as of this reader's reference". +/// +/// **Owned identity, never a borrow.** An earlier draft held `arena: &BeliefArena` +/// alongside the snapshot; that couples a historical reading to a live mutable +/// arena (so the two can silently disagree), makes the record unmovable and +/// unpersistable, and leaves it ambiguous whether the field means *identity* or +/// *current content*. The arena is re-read separately through `arena_id` + the +/// reference when needed. +/// +/// **`at` is a [`QueryReference`], not a bare version.** A version answers +/// "which dataset revision"; a `QueryReference` answers "which revision was this +/// observer permitted to see, under which epistemic mode, at which rung" — the +/// `Strict`/`Retro` distinction is the whole point of the temporal layer, and a +/// settlement or insight reading taken under `Retro` is not the same +/// observation as one taken under `Strict`. +/// +/// No `branch_id` field: `QueryReference::server_id` already names the version +/// line, and duplicating it is how two carriers drift apart. +#[derive(Debug, Clone, Copy)] +pub struct VersionedSnapshot { + /// The epistemic view this reading was taken under. + pub at: QueryReference, + /// Which arena was read. + pub arena_id: u32, + /// The signals themselves. + pub snapshot: Snapshot, +} + +impl VersionedSnapshot { + /// Stamp a snapshot with the view it was read under. + #[must_use] + pub fn new(at: QueryReference, arena_id: u32, snapshot: Snapshot) -> Self { + Self { + at, + arena_id, + snapshot, + } + } + + /// Read `arena` and stamp the result in one step. + #[must_use] + pub fn of( + arena: &BeliefArena, + revision_velocity: f32, + at: QueryReference, + arena_id: u32, + ) -> Self { + Self::new(at, arena_id, Snapshot::of(arena, revision_velocity)) + } + + /// May these two readings be compared as a before→after step? + /// + /// Same arena, same epistemic MODE, same rung — differing only in version, + /// which is what a step IS. Comparing a `Strict` reading against a `Retro` + /// one measures the mode change, not the reasoning. + #[must_use] + pub fn steppable_to(&self, later: &Self) -> bool { + self.arena_id == later.arena_id + && self.at.mode == later.at.mode + && self.at.rung == later.at.rung + && self.at.server_id == later.at.server_id + } +} + impl Snapshot { /// Read the arena into a snapshot. `revision_velocity` (revisions this step ÷ /// steps) is supplied by the caller — it is a rate over the step, not a @@ -210,6 +276,39 @@ mod tests { use super::*; use crate::nars::{CStmt, Copula, Stamp, TruthValue}; + /// A before→after step is two readings of the SAME arena under the SAME + /// lens at different versions. Can-fire. + #[test] + fn same_lens_different_version_is_a_step() { + let snap = Snapshot::of(&BeliefArena::new(), 0.0); + let before = VersionedSnapshot::new(QueryReference::at(10, 2), 7, snap); + let after = VersionedSnapshot::new(QueryReference::at(11, 2), 7, snap); + assert!(before.steppable_to(&after)); + } + + /// Can-stay-silent, on NON-TRIVIAL differences — one per scope component. + /// A reading taken under a different epistemic mode, rung, or arena is a + /// different observation, and differencing it measures the lens change + /// rather than the reasoning. + #[test] + fn a_changed_lens_is_not_a_step() { + let snap = Snapshot::of(&BeliefArena::new(), 0.0); + let base = VersionedSnapshot::new(QueryReference::at(10, 2), 7, snap); + + // Different rung — and, via `at`, a different derived mode. + let other_rung = VersionedSnapshot::new(QueryReference::at(11, 5), 7, snap); + assert!(!base.steppable_to(&other_rung)); + + // Different arena entirely. + let other_arena = VersionedSnapshot::new(QueryReference::at(11, 2), 9, snap); + assert!(!base.steppable_to(&other_arena)); + + // Different version line (server). + let mut other_line = QueryReference::at(11, 2); + other_line.server_id = 3; + assert!(!base.steppable_to(&VersionedSnapshot::new(other_line, 7, snap))); + } + fn inh(s: u16, p: u16) -> CStmt { CStmt { s, diff --git a/crates/lance-graph-planner/src/strategy/style_strategy.rs b/crates/lance-graph-planner/src/strategy/style_strategy.rs index ba3256f71..e80cba355 100644 --- a/crates/lance-graph-planner/src/strategy/style_strategy.rs +++ b/crates/lance-graph-planner/src/strategy/style_strategy.rs @@ -379,20 +379,21 @@ impl StyleStrategy { /// `KanbanColumn::Planning.can_transition_to(CognitiveWork)`), carrying the −550 ms /// Σ-commit anchor (matches `soa_view` `advance_phase`, contract). /// - /// Honestly-fillable fields: `from`/`to`/`libet_offset_us` (structural constants of - /// the crossing) and `exec` (the backend `reliability_of` actually ran = the - /// interpreted `recipe_kernels` layer = [`ExecTarget::Elixir`], per this module's - /// doc header). Bootstrap-sentinel fields: `mailbox = 0` (write-on-behalf of the - /// documented bootstrap owner, NOT as ourselves — the live owner rebinds it) and - /// `witness_chain_position = 0` (no live `current_cycle` exists at plan time; 0 is - /// the zero-fallback pre-cycle stamp the owner overwrites on adoption). + /// Honestly-fillable fields: `from`/`to` (structural constants of the crossing, + /// whose `libet_window_us()` derives `Some(LIBET_COMMIT_WINDOW_US)` for exactly + /// this Planning→CognitiveWork pair) and `exec` (the backend `reliability_of` + /// actually ran = the interpreted `recipe_kernels` layer = [`ExecTarget::Elixir`], + /// per this module's doc header). Bootstrap-sentinel fields: `mailbox = 0` + /// (write-on-behalf of the documented bootstrap owner, NOT as ourselves — the + /// live owner rebinds it) and `witness_chain_position = 0` (no live + /// `current_cycle` exists at plan time; 0 is the zero-fallback pre-cycle stamp + /// the owner overwrites on adoption). fn intended_move(_style: ThinkingStyle) -> KanbanMove { KanbanMove { mailbox: 0, from: KanbanColumn::Planning, to: KanbanColumn::CognitiveWork, witness_chain_position: 0, - libet_offset_us: -550_000, exec: ExecTarget::Elixir, } } @@ -401,6 +402,7 @@ impl StyleStrategy { #[cfg(test)] mod tests { use super::*; + use lance_graph_contract::kanban::LIBET_COMMIT_WINDOW_US; #[test] fn analytical_default_selects_truth_aware_recipes() { @@ -864,7 +866,8 @@ mod tests { "intended edge must be a legal Rubicon transition" ); assert_eq!( - mv.libet_offset_us, -550_000, + mv.libet_window_us(), + Some(LIBET_COMMIT_WINDOW_US), "Σ-commit anchor on the crossing" ); assert_eq!( diff --git a/crates/lance-graph-planner/tests/w1_probes.rs b/crates/lance-graph-planner/tests/w1_probes.rs index 9f8923738..e61c8796e 100644 --- a/crates/lance-graph-planner/tests/w1_probes.rs +++ b/crates/lance-graph-planner/tests/w1_probes.rs @@ -25,7 +25,6 @@ fn make_move(mailbox: u32, from: KanbanColumn, to: KanbanColumn, witness: u32) - from, to, witness_chain_position: witness, - libet_offset_us: 0, exec: ExecTarget::Native, } } diff --git a/crates/lance-graph-supervisor/src/kanban_actor.rs b/crates/lance-graph-supervisor/src/kanban_actor.rs index 2757cd985..2bd0d37d2 100644 --- a/crates/lance-graph-supervisor/src/kanban_actor.rs +++ b/crates/lance-graph-supervisor/src/kanban_actor.rs @@ -436,7 +436,6 @@ mod tests { from, to, witness_chain_position: self.cycle, - libet_offset_us: 0, exec: ExecTarget::Native, } } diff --git a/crates/lance-graph-supervisor/tests/w2b_real_owner_probe.rs b/crates/lance-graph-supervisor/tests/w2b_real_owner_probe.rs index ade43c6b4..656ecd6bb 100644 --- a/crates/lance-graph-supervisor/tests/w2b_real_owner_probe.rs +++ b/crates/lance-graph-supervisor/tests/w2b_real_owner_probe.rs @@ -33,7 +33,9 @@ mod w2b_real_owner_probe { /// followed by `set_populated` (W1c discipline). This mirrors the /// crate's own construction idiom, not an invented shape. fn real_mailbox() -> ProbeMailbox { - let mut mb = MailboxSoA::new(/* mailbox_id */ 77, /* w_slot */ 3, /* threshold */ 1.0); + let mut mb = MailboxSoA::new( + /* mailbox_id */ 77, /* w_slot */ 3, /* threshold */ 1.0, + ); // Declare 1 populated row so MailboxSoaView::n_rows() is non-zero, // matching how a real spawn would declare its logical size // (`MailboxSoA::set_populated` docs: "mirrors fixing BindSpace::len diff --git a/crates/lance-graph/examples/graph_self_reasoning.rs b/crates/lance-graph/examples/graph_self_reasoning.rs index 0ee7ebb0e..e2c8a301f 100644 --- a/crates/lance-graph/examples/graph_self_reasoning.rs +++ b/crates/lance-graph/examples/graph_self_reasoning.rs @@ -64,14 +64,8 @@ fn advance( from: at, to, witness_chain_position: step, - // Libet anchor: −550 ms exactly on the Planning→CognitiveWork Σ-commit. - libet_offset_us: if at == KanbanColumn::Planning - && to == KanbanColumn::CognitiveWork - { - -550_000 - } else { - 0 - }, + // Libet anchor: −550 ms exactly on the Planning→CognitiveWork Σ-commit, + // now derived from the transition itself via `libet_window_us()`. exec: ExecTarget::Native, }); to @@ -367,7 +361,7 @@ fn main() { m.cycle(), m.from, m.to, - m.libet_offset_us, + m.libet_window_us().map(|w| -(w as i64)).unwrap_or(0), m.exec ); } diff --git a/crates/lance-graph/src/graph/scheduler.rs b/crates/lance-graph/src/graph/scheduler.rs index d92e9f5c1..52f5a2b9f 100644 --- a/crates/lance-graph/src/graph/scheduler.rs +++ b/crates/lance-graph/src/graph/scheduler.rs @@ -184,7 +184,7 @@ mod tests { use arrow_array::builder::FixedSizeBinaryBuilder; use arrow_array::{FixedSizeBinaryArray, RecordBatch, UInt32Array}; use lance_graph_contract::collapse_gate::MailboxId; - use lance_graph_contract::kanban::{ExecTarget, KanbanColumn}; + use lance_graph_contract::kanban::{ExecTarget, KanbanColumn, LIBET_COMMIT_WINDOW_US}; use std::sync::Arc; use tempfile::TempDir; @@ -331,7 +331,7 @@ mod tests { // Forward arc: Planning -> CognitiveWork carries the Libet anchor. assert_eq!(mv.from, KanbanColumn::Planning); assert_eq!(mv.to, KanbanColumn::CognitiveWork); - assert_eq!(mv.libet_offset_us, -550_000); + assert_eq!(mv.libet_window_us(), Some(LIBET_COMMIT_WINDOW_US)); assert_eq!(mv.mailbox, 7); assert_eq!(mv.witness_chain_position, 11); assert_eq!(mv.exec, ExecTarget::Native); @@ -366,7 +366,7 @@ mod tests { let mv = a.unwrap(); assert_eq!(mv.from, KanbanColumn::CognitiveWork); assert_eq!(mv.to, KanbanColumn::Evaluation); - assert_eq!(mv.libet_offset_us, 0); + assert_eq!(mv.libet_window_us(), None); } #[tokio::test(flavor = "current_thread")] diff --git a/crates/onebrc-probe/Cargo.lock b/crates/onebrc-probe/Cargo.lock index 2911c1ca6..4c0234ec7 100644 --- a/crates/onebrc-probe/Cargo.lock +++ b/crates/onebrc-probe/Cargo.lock @@ -1149,6 +1149,7 @@ dependencies = [ "bgz-tensor", "causal-edge", "highheelbgz", + "lance-graph-contract", "ndarray", "serde", "serde_json", diff --git a/crates/onebrc-probe/src/lane_e.rs b/crates/onebrc-probe/src/lane_e.rs index ffde5ee69..afdf3e5e1 100644 --- a/crates/onebrc-probe/src/lane_e.rs +++ b/crates/onebrc-probe/src/lane_e.rs @@ -120,7 +120,6 @@ impl MailboxSoaOwner for ProbeBoard { from, to, witness_chain_position: self.cycle, - libet_offset_us: 0, exec: ExecTarget::Native, } } diff --git a/crates/onebrc-probe/src/lane_g.rs b/crates/onebrc-probe/src/lane_g.rs index f55b5289f..07df6cd0f 100644 --- a/crates/onebrc-probe/src/lane_g.rs +++ b/crates/onebrc-probe/src/lane_g.rs @@ -238,7 +238,6 @@ impl Actor for ShardOwner { from: KanbanColumn::CognitiveWork, to: KanbanColumn::Evaluation, witness_chain_position: pos, - libet_offset_us: 0, exec: ExecTarget::Native, }); } diff --git a/crates/onebrc-probe/src/lane_i.rs b/crates/onebrc-probe/src/lane_i.rs index 84f252832..84b382922 100644 --- a/crates/onebrc-probe/src/lane_i.rs +++ b/crates/onebrc-probe/src/lane_i.rs @@ -322,7 +322,6 @@ impl Actor for OwnershipSink { from: KanbanColumn::CognitiveWork, to: KanbanColumn::Evaluation, witness_chain_position: pos, - libet_offset_us: 0, exec: ExecTarget::Native, }); } @@ -416,7 +415,6 @@ impl Actor for LanceSink { from: KanbanColumn::CognitiveWork, to: KanbanColumn::Evaluation, witness_chain_position: pos, - libet_offset_us: 0, exec: ExecTarget::Native, }); } diff --git a/crates/onebrc-probe/src/lane_j.rs b/crates/onebrc-probe/src/lane_j.rs index 1e213d424..8db0670fe 100644 --- a/crates/onebrc-probe/src/lane_j.rs +++ b/crates/onebrc-probe/src/lane_j.rs @@ -358,7 +358,6 @@ impl Actor for OwnershipLane { from: KanbanColumn::CognitiveWork, to: KanbanColumn::Evaluation, witness_chain_position: pos, - libet_offset_us: 0, exec: ExecTarget::Native, }); } @@ -440,7 +439,6 @@ impl Actor for LanceLane { from: KanbanColumn::CognitiveWork, to: KanbanColumn::Evaluation, witness_chain_position: pos, - libet_offset_us: 0, exec: ExecTarget::Native, }); } diff --git a/crates/surreal_container/src/view.rs b/crates/surreal_container/src/view.rs index 33634c0d1..869a13891 100644 --- a/crates/surreal_container/src/view.rs +++ b/crates/surreal_container/src/view.rs @@ -319,7 +319,10 @@ mod tests { .expect("Planning is not absorbing"); assert_eq!(mv.from, KanbanColumn::Planning); assert_eq!(mv.to, KanbanColumn::CognitiveWork); - assert_eq!(mv.libet_offset_us, -550_000); // Libet anchor + assert_eq!( + mv.libet_window_us(), + Some(lance_graph_contract::kanban::LIBET_COMMIT_WINDOW_US) + ); // Libet anchor assert_eq!( mv.exec, lance_graph_contract::kanban::ExecTarget::SurrealQl, diff --git a/crates/surreal_container/tests/scheduler_seam.rs b/crates/surreal_container/tests/scheduler_seam.rs index b1b2c208b..bb482aee3 100644 --- a/crates/surreal_container/tests/scheduler_seam.rs +++ b/crates/surreal_container/tests/scheduler_seam.rs @@ -10,7 +10,7 @@ //! seam WRONG. None of these pin "current behaviour" — they pin the contract //! the doc-comments assert. -use lance_graph_contract::kanban::{ExecTarget, KanbanColumn}; +use lance_graph_contract::kanban::{ExecTarget, KanbanColumn, LIBET_COMMIT_WINDOW_US}; use lance_graph_contract::scheduler::{DatasetVersion, NextPhaseScheduler, VersionScheduler}; use lance_graph_contract::soa_view::MailboxSoaView; use surreal_container::view::SurrealMailboxView; @@ -79,8 +79,9 @@ fn libet_anchor_only_on_sigma_commit_crossing() { .expect("Planning advances"); assert_eq!(crossing.to, KanbanColumn::CognitiveWork); assert_eq!( - crossing.libet_offset_us, -550_000, - "the Σ-commit crossing must carry the -550ms Libet anchor" + crossing.libet_window_us(), + Some(LIBET_COMMIT_WINDOW_US), + "the Σ-commit crossing must open the canonical Libet window" ); for from in [KanbanColumn::CognitiveWork, KanbanColumn::Evaluation, KanbanColumn::Plan] { @@ -88,8 +89,9 @@ fn libet_anchor_only_on_sigma_commit_crossing() { .on_version(&view_at(from), DatasetVersion(2), ExecTarget::Native) .expect("non-absorbing column advances"); assert_eq!( - mv.libet_offset_us, 0, - "{from:?} is not the Σ-commit crossing — Libet offset must be 0" + mv.libet_window_us(), + None, + "{from:?} is not the Σ-commit crossing — no Libet window opens" ); } } diff --git a/crates/symbiont/src/kanban_loop.rs b/crates/symbiont/src/kanban_loop.rs index f92d1ce3c..de6c579f0 100644 --- a/crates/symbiont/src/kanban_loop.rs +++ b/crates/symbiont/src/kanban_loop.rs @@ -180,18 +180,11 @@ impl MailboxSoaOwner for SymbiontBoard { fn advance_phase(&mut self, to: KanbanColumn) -> KanbanMove { let from = self.phase; self.phase = to; - let libet_offset_us = - if from == KanbanColumn::Planning && to == KanbanColumn::CognitiveWork { - -550_000 - } else { - 0 - }; KanbanMove { mailbox: self.mailbox, from, to, witness_chain_position: self.cycle, - libet_offset_us, exec: ExecTarget::Native, } } @@ -219,6 +212,7 @@ pub fn run_demo() { #[cfg(test)] mod tests { use super::*; + use lance_graph_contract::kanban::LIBET_COMMIT_WINDOW_US; #[test] fn loop_drives_forward_arc_to_commit() { @@ -235,9 +229,10 @@ mod tests { ] ); assert!(board.phase().is_absorbing()); - // the Planning→CognitiveWork crossing carries the Libet anchor; others 0. - assert_eq!(trail[0].libet_offset_us, -550_000); - assert_eq!(trail[1].libet_offset_us, 0); + // the Planning→CognitiveWork crossing carries the Libet anchor; others None + // (the window is now derived from the transition, not stamped). + assert_eq!(trail[0].libet_window_us(), Some(LIBET_COMMIT_WINDOW_US)); + assert_eq!(trail[1].libet_window_us(), None); // monotonic cycle stamps (the SoA cycle-ownership stamp, R4). assert_eq!( trail.iter().map(|m| m.cycle()).collect::>(), diff --git a/docs/architecture/soa-three-tier-model.md b/docs/architecture/soa-three-tier-model.md index 875c3ca96..83eb5270b 100644 --- a/docs/architecture/soa-three-tier-model.md +++ b/docs/architecture/soa-three-tier-model.md @@ -67,7 +67,9 @@ phase. This is triggered by the Lance writer, not by the SoA itself. Lance writer → VersionScheduler::on_version(&view, at, exec) │ read-only &V: never mutates ▼ - Option { mailbox, from→to, libet_offset_us } + Option { mailbox, from→to, witness_chain_position, exec } + │ the Libet window is DERIVED: libet_window_us() + │ = Some(550_000) iff from→to is Planning→CognitiveWork │ caller applies ▼ MailboxSoaOwner::advance_phase(to) ← SOLE mutator diff --git a/docs/probes/particle-soa-envelope-audit.md b/docs/probes/particle-soa-envelope-audit.md index 3379af1e3..541eb6a87 100644 --- a/docs/probes/particle-soa-envelope-audit.md +++ b/docs/probes/particle-soa-envelope-audit.md @@ -236,7 +236,10 @@ in the Rust sense.** read-only) — **"propose, don't dispose": the scheduler never mutates; only `MailboxSoaOwner::advance_phase` mutates.** (Confirmed — clean ownership split.) - `NextPhaseScheduler` advances the 6-phase Rubicon Kanban lifecycle on each - Lance version tick. Planning→CognitiveWork stamps `libet_offset_us = -550_000`. + Lance version tick. The Planning→CognitiveWork crossing IS the Libet anchor: + `KanbanMove::libet_window_us()` derives `Some(LIBET_COMMIT_WINDOW_US)` from + `(from, to)`. (The stored `libet_offset_us` field was removed — a separately + writable projection of the transition could only ever disagree with it.) - Per-row time stamps in the envelope are `current_cycle: u32` and `last_emission_cycle [u32;N]` — these are **same-cycle idempotency guards**, not history. No previous-self snapshot is copied into rows. @@ -248,7 +251,7 @@ in the Rust sense.** │ on_version(&view, at, exec) ▼ VersionScheduler (READ-ONLY &V) ──proposes──► KanbanMove { mailbox, from→to phase, - │ witness_chain_position, libet_offset_us } + │ witness_chain_position, exec } │ (caller applies) ▼ MailboxSoaOwner::advance_phase(to) ← SOLE mutator