From 1064d96ac524f19e4f7c547ef1de3ade3ad165e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 07:55:53 +0000 Subject: [PATCH 01/16] causality audit: type the causal edge, declare kernel effects, derive the Libet window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ratified fix list from the E-WE-HAVE-PEARL-VOCABULARY-NOT-PEARL- MECHANICS-1 arc, after five adversarial review rounds. Each item replaced a merged carrier with orthogonal ones. B1 — contract::causal_audit (new). The ruling was AUDIT BEFORE BUILD: classify every edge labelled causal before adding causal machinery. Four axes that must stay four: * kind — a sum type, so a non-causal relation cannot carry a locus and an unclassified edge has somewhere honest to sit * locus — World/Interpretive/Derivational/Experiential: WHERE in the architecture the relation operates. Not subject matter. An accusative marker causing a parser to pick a role is Interpretive whether its content is physical, social or formal. * domain — subject matter, meaningful mainly under World * scope — Type/Token. Not grammatical voice: "the outage was caused by cable damage" is passive and Token. Support is MANY-of, not one-of: a receipt ledger, because one edge can be text-attested and derivationally traced and cross-environment invariant at once. SupportProfile is a derived projection that keeps receipt counts and distinct-source counts separate, so three independent attestations never read the same as one attestation repeated three times. is_intervention_established() needs both a causal classification and an InterventionBacked receipt, which a text corpus cannot produce. A1+A2 — Tactic::{maturity, writes}. An effect census over all 34 kernels falsified an earlier claim made from grep counts: 15 return delta_conf = 0.0 on every branch while mutating ThoughtCtx. Tactic::run calls apply(ctx) first and adds the delta after, so a zero delta says nothing about whether the context survived — Htd reorders the whole candidate vector and reports zero. writes() declares possible writes (mirror of requires()'s may-read); maturity() is Operational/Demonstration/Stub and lives on the impl, not the Recipe catalogue entry, because it changes when the code changes and the concept does not. Census: 27/6/1. Three kernels that look operational land nothing — Etd sorts a clone and discards it, Cas drops its computed level, Sdd hardcodes zero outside its branch; recorded as Demonstration rather than silently wired up, since that would be an unreviewed behaviour change. Seven tests make the declarations falsifiable. One of them found a hole in its own probe matrix before it found one in a kernel. Also fixes Lsi, which declared Sd as a required input it only ever wrote — invisible until reads and writes had separate carriers. A4 — the XOR world primitive renamed off do-calculus. It severs no mechanism, recomputes no descendants, holds no exogenous background fixed. It is an exact reversible substitution, which is worth keeping and now says so. Renamed rather than deleted: the algebra was sound, the claim was not. A5 — KanbanMove::libet_offset_us REMOVED, window derived from (from, to). An orthogonality audit found variation in one direction only: the transition can vary while the offset holds, but nothing legitimately makes the offset vary while the transition holds. One-directional means derived, so it becomes a method. The literal -550_000 had three definitions under two different stamping conditions; now one. Breaking for external constructors, deliberately: it removes an invalid state rather than testing for it. Gate: contract 1086 green, planner 317 green, supervisor + shader-driver green, fmt clean, no new warnings. lance-graph-cognitive grammar::qualia:: test_depth_detection fails — verified pre-existing at clean HEAD via stash. Co-Authored-By: Claude --- .claude/board/EPIPHANIES.md | 37 ++ .claude/board/LATEST_STATE.md | 10 + .../src/mailbox_soa.rs | 18 +- crates/lance-graph-cognitive/Cargo.lock | 94 ++- .../src/world/counterfactual.rs | 196 +++--- crates/lance-graph-cognitive/src/world/mod.rs | 10 +- .../examples/foveated_awareness.rs | 7 - .../lance-graph-contract/src/causal_audit.rs | 621 ++++++++++++++++++ crates/lance-graph-contract/src/kanban.rs | 109 ++- crates/lance-graph-contract/src/lib.rs | 1 + .../src/recipe_kernels.rs | 586 ++++++++++++++++- crates/lance-graph-contract/src/scheduler.rs | 16 +- crates/lance-graph-contract/src/soa_view.rs | 14 +- .../src/elevation/cycle.rs | 58 +- .../src/strategy/style_strategy.rs | 21 +- crates/lance-graph-planner/tests/w1_probes.rs | 1 - .../src/kanban_actor.rs | 1 - .../tests/w2b_real_owner_probe.rs | 4 +- .../examples/graph_self_reasoning.rs | 12 +- crates/lance-graph/src/graph/scheduler.rs | 6 +- crates/surreal_container/src/view.rs | 5 +- .../surreal_container/tests/scheduler_seam.rs | 8 +- 22 files changed, 1632 insertions(+), 203 deletions(-) create mode 100644 crates/lance-graph-contract/src/causal_audit.rs diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index db097a7df..5c37e9ef4 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,40 @@ +## 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..7e60d4fc0 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,13 @@ +## 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/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..0774a1b4b 100644 --- a/crates/lance-graph-cognitive/src/world/counterfactual.rs +++ b/crates/lance-graph-cognitive/src/world/counterfactual.rs @@ -1,93 +1,121 @@ -//! 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. + pub substitution: BindingSubstitution, + /// 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 { + substitution: 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() { + SubstitutedWorld { + substitution: if let Some(last) = substitutions.last() { last.clone() } else { - Intervention { + BindingSubstitution { target: Fingerprint::zero(), original: Fingerprint::zero(), - counterfactual: Fingerprint::zero(), + replacement: Fingerprint::zero(), } }, state: current, @@ -95,7 +123,11 @@ pub fn multi_intervene(world: &Fingerprint, interventions: &[Intervention]) -> C } } -// 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 +135,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 +156,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 { + 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 +231,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..7c0399094 --- /dev/null +++ b/crates/lance-graph-contract/src/causal_audit.rs @@ -0,0 +1,621 @@ +//! 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 SourceId(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: SourceId, + /// 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: SourceId) -> 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. + #[must_use] + pub fn profile(&self) -> SupportProfile { + let mut p = SupportProfile::default(); + for r in &self.receipts { + p.basis_mask |= r.basis.bit(); + let slot = r.basis as usize; + p.receipt_counts[slot] = p.receipt_counts[slot].saturating_add(1); + p.strength[slot] = p.strength[slot].saturating_add(r.strength); + } + for basis in SupportBasis::ALL { + p.distinct_sources[basis as usize] = + u8::try_from(self.distinct_sources_for(basis)).unwrap_or(u8::MAX); + } + 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, + ]; +} + +/// Fixed-width projection of a [`SupportLedger`] — derived, never authoritative. +/// +/// Keeps `receipt_counts` and `distinct_sources` SEPARATE on purpose: one +/// source attesting three times and three sources attesting once each share a +/// `basis_mask` and must not share a corroboration reading. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SupportProfile { + /// One bit per [`SupportBasis`] present. + pub basis_mask: u16, + /// Receipts recorded per basis. + pub receipt_counts: [u8; 9], + /// DISTINCT sources per basis — the corroboration reading. + pub distinct_sources: [u8; 9], + /// Summed receipt strength per basis (saturating). + pub strength: [u8; 9], +} + +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: SourceId(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()); + assert_eq!(a.basis_mask, b.basis_mask, "masks are identical…"); + assert_eq!(a.receipt_counts, b.receipt_counts, "…and so are raw counts"); + assert_eq!(a.distinct_sources[SupportBasis::TextAttested as usize], 3); + assert_eq!(b.distinct_sources[SupportBasis::TextAttested as usize], 1); + assert_ne!( + a.distinct_sources, b.distinct_sources, + "corroboration must distinguish them" + ); + } + + /// 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(SourceId(1)), 2); + assert_eq!(led.receipts().len(), 1); + assert_eq!(led.receipts()[0].source, SourceId(2)); + assert_eq!( + led.withdraw_source(SourceId(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..a5fc19e6f 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`, diff --git a/crates/lance-graph-contract/src/recipe_kernels.rs b/crates/lance-graph-contract/src/recipe_kernels.rs index 05a6441f1..a6f2d1447 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,304 @@ 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 { + let mut bits = 0u8; + let mut set = |f: ThoughtField| bits |= 1 << (f as u8); + 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(bits) + } + + /// **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/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/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/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..759e06deb 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,7 +79,8 @@ 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, + crossing.libet_window_us(), + Some(LIBET_COMMIT_WINDOW_US), "the Σ-commit crossing must carry the -550ms Libet anchor" ); @@ -88,7 +89,8 @@ 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, + mv.libet_window_us(), + None, "{from:?} is not the Σ-commit crossing — Libet offset must be 0" ); } From 0d1e5669966e6b0b854901e501a0b9803e9e86b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 07:58:41 +0000 Subject: [PATCH 02/16] =?UTF-8?q?contract:=20canonical=20source=20registry?= =?UTF-8?q?=20=E2=80=94=20stable=20ids=20to=20dense=20slots,=20arena-local?= =?UTF-8?q?=20stamps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 of the causality-audit fix list. Two crates independently shipped `pub struct Stamp(pub u64)` with `source(id) = 1 << (id % 64)`. Correcting the record first: that folding is CONSERVATIVE, not unsound. A collision makes two distinct sources look overlapping, so revision refuses to pool them — it loses evidence rather than fabricating independence. Both copies documented this correctly and an earlier audit claim to the contrary was wrong. What folding does destroy is everything downstream of knowing which bit is whom: pooling past 64 sources, leave-one-out, withdrawal, and any reading of an evidence count. The rule now enforced: 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 — a source numbered 50_000 legitimately takes slot 0 if it registers first, and exhaustion is a reported error instead of a wrap. Stamps are arena-local by CONTAINMENT, not convention: the bits are private, there is no Serialize, and a Stamp can only come from a SourceSlot its registry issued. The alternative — registry-bearing stamps — exists to make evidence from one arena meaningful inside another, which is the inter-container handoff the substrate already removed at the mailbox layer (#477), reintroduced one layer down. Containment is the option consistent with that ownership model. The flip condition is named in the module docs: if replay ever needs evidence from persisted state, the answer is a frozen source census keyed by version, still not a registry field on every stamp. causal_audit re-exports SourceId rather than defining a second one. Deliberately NOT included: migrating lance-graph-planner's and deepnsm-v2's arenas onto these types. Doing one without the other would leave two incompatible independence semantics wearing matching comments — worse than leaving both, because the asymmetry is silent. They move together, next. 8 tests; contract 1094 green, fmt clean, no new warnings. Co-Authored-By: Claude --- .../lance-graph-contract/src/causal_audit.rs | 11 +- crates/lance-graph-contract/src/lib.rs | 1 + .../src/source_registry.rs | 391 ++++++++++++++++++ 3 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 crates/lance-graph-contract/src/source_registry.rs diff --git a/crates/lance-graph-contract/src/causal_audit.rs b/crates/lance-graph-contract/src/causal_audit.rs index 7c0399094..9d382e33e 100644 --- a/crates/lance-graph-contract/src/causal_audit.rs +++ b/crates/lance-graph-contract/src/causal_audit.rs @@ -231,13 +231,10 @@ impl SupportBasis { } } -/// 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 SourceId(pub u64); +// Evidence-source identity is CANONICAL in `source_registry` — re-exported, not +// redefined. A second `SourceId` here would be the same duplication the registry +// exists to end (two crates had already shipped two incompatible `Stamp`s). +pub use crate::source_registry::SourceId; /// One piece of evidence for a relation: which kind, from whom, when, how /// strong. diff --git a/crates/lance-graph-contract/src/lib.rs b/crates/lance-graph-contract/src/lib.rs index a5fc19e6f..00b6fc933 100644 --- a/crates/lance-graph-contract/src/lib.rs +++ b/crates/lance-graph-contract/src/lib.rs @@ -148,6 +148,7 @@ pub mod sensorium; pub mod sigma_propagation; pub mod sla; pub mod soa_envelope; +pub mod source_registry; pub mod soa_graph; pub mod soa_view; pub mod splat; diff --git a/crates/lance-graph-contract/src/source_registry.rs b/crates/lance-graph-contract/src/source_registry.rs new file mode 100644 index 000000000..64d52fb82 --- /dev/null +++ b/crates/lance-graph-contract/src/source_registry.rs @@ -0,0 +1,391 @@ +//! Evidence-source identity: arbitrary stable ids → dense local slots → stamps. +//! +//! ## The defect this replaces +//! +//! Two crates independently shipped `pub struct Stamp(pub u64)` with +//! `fn source(id: u32) -> Stamp { Stamp(1u64 << (id % 64)) }`. The modulo is +//! **conservative, not unsound** — a collision makes two distinct sources look +//! *overlapping*, so NARS revision refuses to pool them. It loses evidence; it +//! does not fabricate independence. (Both copies documented this correctly; an +//! audit that claimed otherwise was wrong.) +//! +//! What it *does* destroy is everything downstream of knowing which bit is +//! whom: pooling past 64 sources, leave-one-out, withdrawal, and any reading of +//! an evidence count. Hence the rule this module exists to enforce: +//! +//! > **A term id, domain id, witness id, or corpus id must NEVER be silently +//! > interpreted as a bit position.** +//! +//! An external id may be `300`, `50_000`, or a hash. It can still legitimately +//! occupy local slot 0 — if it is the first source registered in the current +//! bounded horizon. The failure condition is not "id ≥ 64", it is "more than +//! 64 *simultaneously represented* identities". +//! +//! ## Ruling: stamps are ARENA-LOCAL, by containment +//! +//! A [`Stamp`] is meaningful only relative to the registry that minted its +//! bits. Registry A may assign source X to slot 0 while registry B assigns +//! source Z to slot 0 — so `Stamp(0b1)` means different evidence in each, and +//! comparing them across registries is nonsense that `disjoint` would answer +//! confidently. +//! +//! Two designs are safe. **This module takes the first:** +//! +//! 1. **Strong containment** — the owning arena holds the registry, mints every +//! stamp, and performs every `union` / `disjoint`. Callers pass a +//! [`SourceId`]; no stamp crosses an API boundary, so no stamp can be +//! compared against a foreign one. +//! 2. *Registry-bearing stamps* — `{registry: SourceRegistryId, bits}`, with +//! every operation rejecting mismatched registries. Necessary only if stamps +//! must persist or cross boundaries. +//! +//! **Why (1).** Design (2) exists to make evidence from arena A meaningful +//! inside arena B — a handoff between two independently-owned state containers. +//! That is the shape the substrate already deleted at the mailbox layer (#477: +//! no inter-mailbox carrier at all, one writer per mailbox), reintroduced one +//! layer down under a new name. Containment is not merely cheaper here; it is +//! the option consistent with the ratified ownership model. +//! +//! Enforcement is structural, not advisory: [`Stamp`]'s bits are **private**, +//! it has no `Serialize`, and it is constructible only from a [`SourceSlot`] +//! that a [`SourceRegistry`] issued. +//! +//! **The flip condition, named so it is falsifiable:** if replay must +//! reconstruct evidence from *persisted* state rather than rebuilding the arena, +//! containment breaks. The answer then is still 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, +//! checked against a registry digest. That keeps mapping identity addressable +//! by epistemic view instead of smuggling it into the hot carrier. + +/// A stable, arbitrary, sparse identity for an evidence source — a corpus id, a +/// witness id, a term id, a hash. +/// +/// **Never a bit position.** Converting one to a bit index is the defect this +/// module exists to prevent; go through [`SourceRegistry::slot_for`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] +pub struct SourceId(pub u64); + +/// A dense local slot, `0..64`, issued by one [`SourceRegistry`]. +/// +/// Meaningful ONLY relative to the registry that issued it. Deliberately not +/// constructible from a raw integer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[repr(transparent)] +pub struct SourceSlot(u8); + +impl SourceSlot { + /// The slot's index. For diagnostics and dense array indexing within the + /// owning arena — NOT for reconstructing a stamp elsewhere. + #[inline] + #[must_use] + pub const fn index(self) -> u8 { + self.0 + } +} + +/// The registry is full: more than [`SourceRegistry::CAPACITY`] distinct +/// sources are simultaneously represented in one evidence horizon. +/// +/// A real, reportable condition — never silently folded, which is exactly what +/// `id % 64` did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CapacityExceeded { + /// The source that could not be admitted. + pub source: SourceId, + /// How many slots are already in use (always [`SourceRegistry::CAPACITY`]). + pub in_use: usize, +} + +impl core::fmt::Display for CapacityExceeded { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "source registry full ({} slots in use); cannot admit SourceId({})", + self.in_use, self.source.0 + ) + } +} + +impl core::error::Error for CapacityExceeded {} + +/// Maps arbitrary stable [`SourceId`]s onto dense [`SourceSlot`]s for one +/// bounded evidence horizon. +/// +/// Insertion-ordered and linear-scanned: the capacity is 64, so a map would +/// cost more than it saves, and the ordering makes the allocation reproducible +/// for a given insertion sequence. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SourceRegistry { + slots: Vec, +} + +impl SourceRegistry { + /// Slots available — the width of [`Stamp`]'s bitset. + pub const CAPACITY: usize = 64; + + /// An empty registry. + #[must_use] + pub const fn new() -> Self { + Self { slots: Vec::new() } + } + + /// The slot for `source`, allocating one if this is its first appearance. + /// + /// Idempotent: the same `SourceId` always maps to the same slot within one + /// registry. Returns [`CapacityExceeded`] rather than wrapping. + pub fn slot_for(&mut self, source: SourceId) -> Result { + if let Some(slot) = self.lookup(source) { + return Ok(slot); + } + if self.slots.len() >= Self::CAPACITY { + return Err(CapacityExceeded { + source, + in_use: self.slots.len(), + }); + } + let idx = u8::try_from(self.slots.len()).expect("len < CAPACITY <= 64"); + self.slots.push(source); + Ok(SourceSlot(idx)) + } + + /// The slot already held by `source`, if any. Never allocates. + #[must_use] + pub fn lookup(&self, source: SourceId) -> Option { + self.slots + .iter() + .position(|s| *s == source) + .map(|i| SourceSlot(u8::try_from(i).expect("index < CAPACITY"))) + } + + /// Which source holds `slot` — the inverse direction, for attribution. + #[must_use] + pub fn source_of(&self, slot: SourceSlot) -> Option { + self.slots.get(slot.0 as usize).copied() + } + + /// Distinct sources registered so far. + #[inline] + #[must_use] + pub fn len(&self) -> usize { + self.slots.len() + } + + /// Have any sources been registered? + #[inline] + #[must_use] + pub fn is_empty(&self) -> bool { + self.slots.is_empty() + } + + /// Remaining capacity before [`slot_for`](Self::slot_for) starts failing. + #[inline] + #[must_use] + pub fn remaining(&self) -> usize { + Self::CAPACITY - self.slots.len() + } + + /// Mint the stamp for `source`, allocating a slot if needed. + /// + /// The intended entry point for an owning arena: it never hands a + /// [`SourceSlot`] to a caller, so the stamp and its meaning stay together. + pub fn stamp_for(&mut self, source: SourceId) -> Result { + self.slot_for(source).map(Stamp::from_slot) + } +} + +/// An evidential base: which registry slots contributed to a belief. +/// +/// **Arena-local.** The bits are private and there is no way to build one from +/// a raw integer — a `Stamp` can only come from a [`SourceSlot`] its registry +/// issued, or from [`union`](Stamp::union)ing stamps that already exist. Keep +/// it inside the arena that owns the registry; comparing stamps minted by +/// different registries is meaningless and this type deliberately makes that +/// hard to do by accident. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Stamp(u64); + +impl Stamp { + /// The empty stamp — "no recorded source". + /// + /// Load-bearing as a sentinel: NARS revision must NOT pool two beliefs when + /// either side is empty, because "no recorded source" is not evidence of + /// independence. Callers gate on [`is_empty`](Stamp::is_empty). + pub const EMPTY: Self = Self(0); + + /// The stamp of a single registry slot. + #[inline] + #[must_use] + pub const fn from_slot(slot: SourceSlot) -> Self { + Self(1u64 << slot.0) + } + + /// Do these stamps share no source? + /// + /// Only meaningful for stamps from the SAME registry — see the module docs. + #[inline] + #[must_use] + pub const fn disjoint(self, other: Self) -> bool { + self.0 & other.0 == 0 + } + + /// The pooled evidential base. + #[inline] + #[must_use] + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + /// Is this the no-source sentinel? + #[inline] + #[must_use] + pub const fn is_empty(self) -> bool { + self.0 == 0 + } + + /// How many distinct sources this stamp represents — the evidence count + /// that `id % 64` folding made uninterpretable. + #[inline] + #[must_use] + pub const fn count(self) -> u32 { + self.0.count_ones() + } + + /// Does this stamp include `slot`? + #[inline] + #[must_use] + pub const fn contains(self, slot: SourceSlot) -> bool { + self.0 & (1u64 << slot.0) != 0 + } + + /// Withdraw one source's contribution — possible only because slots are + /// stable identities rather than folded hashes. + #[inline] + #[must_use] + pub const fn without(self, slot: SourceSlot) -> Self { + Self(self.0 & !(1u64 << slot.0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The headline property: a LARGE, sparse external id maps cleanly, because + /// the registry allocates by insertion order and never by arithmetic on the + /// id. Under `1 << (id % 64)` these three would have collided into slot 0. + #[test] + fn sparse_external_ids_get_distinct_slots() { + let mut reg = SourceRegistry::new(); + let a = reg.slot_for(SourceId(0)).unwrap(); + let b = reg.slot_for(SourceId(64)).unwrap(); + let c = reg.slot_for(SourceId(128)).unwrap(); + assert_ne!(a, b); + assert_ne!(b, c); + assert_ne!(a, c); + + // …and the stamps they mint are genuinely disjoint. + assert!(Stamp::from_slot(a).disjoint(Stamp::from_slot(b))); + assert!(Stamp::from_slot(b).disjoint(Stamp::from_slot(c))); + } + + /// A 50_000-valued id legitimately takes slot 0 when it registers first. + /// The bound is on SIMULTANEOUS identities, not on id magnitude. + #[test] + fn a_huge_id_may_hold_slot_zero() { + let mut reg = SourceRegistry::new(); + let slot = reg.slot_for(SourceId(50_000)).unwrap(); + assert_eq!(slot.index(), 0); + assert_eq!(reg.source_of(slot), Some(SourceId(50_000))); + } + + #[test] + fn slot_allocation_is_idempotent() { + let mut reg = SourceRegistry::new(); + let first = reg.slot_for(SourceId(7)).unwrap(); + let again = reg.slot_for(SourceId(7)).unwrap(); + assert_eq!(first, again); + assert_eq!(reg.len(), 1, "no second slot burned"); + } + + /// Capacity exhaustion is REPORTED, not folded. This is the whole + /// behavioural difference from `id % 64`. + #[test] + fn capacity_is_reported_never_wrapped() { + let mut reg = SourceRegistry::new(); + for i in 0..SourceRegistry::CAPACITY as u64 { + reg.slot_for(SourceId(i * 1000)).expect("within capacity"); + } + assert_eq!(reg.remaining(), 0); + + let err = reg.slot_for(SourceId(999_999)).unwrap_err(); + assert_eq!(err.in_use, SourceRegistry::CAPACITY); + assert_eq!(err.source, SourceId(999_999)); + + // An ALREADY-registered source still resolves when full — the registry + // is out of new slots, not broken. + assert!(reg.slot_for(SourceId(0)).is_ok()); + } + + /// Evidence counting is interpretable again: N distinct sources ⇒ N bits. + /// Under modulo folding this number was a lower bound of unknown tightness. + #[test] + fn evidence_count_is_exact_over_distinct_sources() { + let mut reg = SourceRegistry::new(); + let mut pooled = Stamp::EMPTY; + for i in 0..10u64 { + pooled = pooled.union(reg.stamp_for(SourceId(i * 64)).unwrap()); + } + assert_eq!(pooled.count(), 10); + + // Re-pooling the SAME sources adds nothing — idempotent, so a repeated + // source cannot inflate the count. + for i in 0..10u64 { + pooled = pooled.union(reg.stamp_for(SourceId(i * 64)).unwrap()); + } + assert_eq!(pooled.count(), 10, "repetition is not corroboration"); + } + + /// Withdrawal — the operation folded bits cannot support. + #[test] + fn withdrawal_removes_exactly_one_source() { + let mut reg = SourceRegistry::new(); + let a = reg.slot_for(SourceId(11)).unwrap(); + let b = reg.slot_for(SourceId(22)).unwrap(); + let pooled = Stamp::from_slot(a).union(Stamp::from_slot(b)); + assert_eq!(pooled.count(), 2); + + let minus_a = pooled.without(a); + assert_eq!(minus_a.count(), 1); + assert!(!minus_a.contains(a)); + assert!(minus_a.contains(b)); + } + + /// The empty sentinel is not accidentally disjoint-with-everything in a way + /// that licenses pooling: `disjoint` says true, so callers MUST gate on + /// `is_empty` separately. Pinned so the guard's necessity stays visible. + #[test] + fn empty_stamp_is_disjoint_with_everything_hence_the_separate_guard() { + let mut reg = SourceRegistry::new(); + let s = reg.stamp_for(SourceId(1)).unwrap(); + assert!(Stamp::EMPTY.disjoint(s)); + assert!(Stamp::EMPTY.is_empty(), "the guard callers must check"); + assert!(!s.is_empty()); + } + + /// Two registries independently assign slot 0 — the concrete reason stamps + /// must not cross registry boundaries. Documents the hazard the containment + /// ruling exists to remove; it is a property of the design, not a bug. + #[test] + fn slot_zero_means_different_sources_in_different_registries() { + let mut a = SourceRegistry::new(); + let mut b = SourceRegistry::new(); + let sa = a.stamp_for(SourceId(111)).unwrap(); + let sb = b.stamp_for(SourceId(222)).unwrap(); + + assert_eq!(sa, sb, "identical bits…"); + assert_eq!(a.source_of(SourceSlot(0)), Some(SourceId(111))); + assert_eq!(b.source_of(SourceSlot(0)), Some(SourceId(222))); + // …yet different evidence. Hence: one registry per arena, stamps stay in. + } +} From f4e1576edcdb15a1e9bb189042ad8d53df2351ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:00:41 +0000 Subject: [PATCH 03/16] contract: settlement as a four-signal field, never a score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1 of the causality-audit fix list, and a correction to my own earlier draft of the matrix. The discriminator is closure x competence. An earlier formulation had entropy on BOTH axes ("crystal = low entropy + high closure, glass = low entropy + low closure"), which silently deleted competence and made the four-cell matrix a restatement of one variable. Entropy describes field concentration — how narrow the surviving space is — and says nothing about whether that concentration is structurally closed or evidentially earned. high closure + high competence -> Crystal high closure + LOW competence -> Glass low closure + high competence -> GroundedUnresolved low closure + low competence -> Fog Glass is the cell worth naming: it presents as settled from the closure side and as ungrounded from the evidence side, so any single score averages it into something unremarkable. That is the argument against a scalar, stated as a test — glass_and_crystal_both_appear_settled. field_entropy and eigenvalue_concentration are kept as refining signals and pinned by test never to change the cell. They distinguish internal geometry (calcified monoculture vs many thin derivations) once the cell is decided. SettlementScope — arena, basin, version, branch, witness horizon — is carried with the signals, and comparable_to refuses mismatched pairs. Closure is a whole-arena property and competence is per-basin; subtracting across that mismatch is a confident number about nothing. The precondition is now structural rather than remembered. No glass_gap() scalar is provided. The subtraction is exactly how four preserved signals collapse back into one, and neither axis is calibrated. The midpoint threshold is hand-chosen and says so, per I-NOISE-FLOOR-JIRAK. 7 tests: all four cells reachable, entropy/eigenvalue provably inert on cell choice, an orthogonality receipt for closure vs competence, scope refusal, and can-fire/can-stay-silent plus threshold-inertness on the monoculture predicate. Contract 1101 green, fmt clean. Co-Authored-By: Claude --- .claude/board/LATEST_STATE.md | 8 + crates/lance-graph-contract/src/lib.rs | 3 +- crates/lance-graph-contract/src/settlement.rs | 302 ++++++++++++++++++ 3 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 crates/lance-graph-contract/src/settlement.rs diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 7e60d4fc0..2c3641bf1 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,11 @@ +## 2026-07-27 — branch `claude/medcare-rs-transcode-ruff-3y2olh` — causality-audit C1 + D1: canonical source registry + the four-signal settlement field + +### Current Contract Inventory — two new modules +- `lance_graph_contract::source_registry::{SourceId, SourceSlot, SourceRegistry, Stamp, CapacityExceeded}` — **ends the two-copy `Stamp(pub u64)` / `1 << (id % 64)` duplication at the canonical location.** Record corrected: the folding is CONSERVATIVE (collision ⇒ false overlap ⇒ revision refuses to pool ⇒ evidence lost, never independence fabricated); both copies said so and the earlier audit claim was wrong. What folding destroys is pooling past 64, leave-one-out, withdrawal, and evidence-count interpretation. Rule: **an external id is never a bit position**; the bound is on SIMULTANEOUS identities, so `SourceId(50_000)` legitimately takes slot 0 if it registers first, and exhaustion is a reported `CapacityExceeded`. **Stamps are arena-local by containment** — private bits, no `Serialize`, constructible only from a registry-issued `SourceSlot`. Registry-bearing stamps were REJECTED: they exist to make arena A's evidence meaningful in arena B, which is the inter-container handoff #477 deleted at the mailbox layer, one layer down. Flip condition named in-source (persisted replay ⇒ a frozen source census keyed by version, still not a per-stamp registry field). 8 tests. +- `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 1101 green, fmt clean, no new warnings. **Deliberately deferred, not forgotten:** migrating `lance-graph-planner`'s and `deepnsm-v2`'s `BeliefArena`s onto the canonical registry. They move TOGETHER — fixing one leaves two incompatible independence semantics wearing matching comments, which is worse than fixing neither because the asymmetry is silent. + ## 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 diff --git a/crates/lance-graph-contract/src/lib.rs b/crates/lance-graph-contract/src/lib.rs index 00b6fc933..fbbc8d296 100644 --- a/crates/lance-graph-contract/src/lib.rs +++ b/crates/lance-graph-contract/src/lib.rs @@ -145,12 +145,13 @@ 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; -pub mod source_registry; pub mod soa_graph; pub mod soa_view; +pub mod source_registry; pub mod splat; /// Standing-mask subscriptions (`dirty ∩ interest`) — one bitwise AND per /// write, no query re-execution. See module docs for the composition rule. 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"); + } +} From cbed5a43183ca240a42ee08481356412dfddc717 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:02:48 +0000 Subject: [PATCH 04/16] =?UTF-8?q?planner:=20VersionedSnapshot=20=E2=80=94?= =?UTF-8?q?=20owned=20identity,=20epistemic=20view=20retained?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C2 of the causality-audit fix list, correcting two things in my own drafts. An earlier draft held `arena: &BeliefArena` next to the snapshot. That couples a historical reading to a live mutable arena — the two can silently disagree — makes the record unmovable and unpersistable, and leaves it ambiguous whether the field means identity or current content. Now it is `arena_id` and the arena is re-read separately when needed. A later draft over-corrected to `dataset_version` + `branch_id`. That loses the epistemic policy the temporal layer exists to carry: a version answers "which revision", a QueryReference answers "which revision was this observer permitted to see, under which mode, at which rung". A reading taken under Retro is not the same observation as one taken under Strict, and a settlement or insight metric that ignores the difference is measuring the lens. So `at` is a QueryReference. No branch_id field either — QueryReference::server_id already names the version line, and duplicating it is how two carriers drift apart. steppable_to() enforces the before/after precondition: same arena, same mode, same rung, same version line, differing only in version — which is what a step is. Two tests: can-fire on a real step, can-stay-silent on non-trivial lens changes, one per scope component. Planner 319 green, fmt clean. Co-Authored-By: Claude --- .../lance-graph-planner/src/nars/insight.rs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) 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, From 89a80f953ba1ab82b9a3381ea48044f3c3e9b82d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:07:02 +0000 Subject: [PATCH 05/16] docs+board: record the source-registry ruling; drop stale libet_offset_us references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board: E-A-LOCAL-BITSET-IS-NOT-SELF-DESCRIBING-PROVENANCE-1. Includes an explicit correction of an earlier claim in the same arc — the `id % 64` folding is conservative (collision produces false OVERLAP, so revision refuses to pool; evidence is lost, independence is never fabricated). Both crates documented this correctly and the audit that said otherwise was wrong. Recorded because getting it backwards would have justified an urgent correctness fix over the real, duller problem: a bitset does not carry the mapping that gives its bits meaning. Docs: the three-tier model diagram and the envelope-audit probe still showed `libet_offset_us` on KanbanMove. Updated to the derived `libet_window_us()`. Co-Authored-By: Claude --- .claude/board/EPIPHANIES.md | 20 ++++++++++++++++++++ docs/architecture/soa-three-tier-model.md | 4 +++- docs/probes/particle-soa-envelope-audit.md | 7 +++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 5c37e9ef4..0809a6440 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,23 @@ +## 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. diff --git a/docs/architecture/soa-three-tier-model.md b/docs/architecture/soa-three-tier-model.md index 875c3ca96..ee0128f5f 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 } + │ 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 From 114e58cf064ea3da3b205f79cec9ca560319071f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:07:59 +0000 Subject: [PATCH 06/16] symbiont + onebrc-probe: finish the libet_offset_us removal in the excluded crates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These two are workspace-excluded, so the field removal did not break the default build and they were missed in the first migration sweep — the failure mode of a breaking change in a repo where `cargo build --workspace` is not the whole repo. symbiont's kanban_loop drops the stamped computation and asserts through libet_window_us(); its LIBET_COMMIT_WINDOW_US import sits inside `mod tests`, since only the tests reference it. onebrc-probe's six sites were plain `libet_offset_us: 0` struct-literal lines, deleted. onebrc-probe checks clean with --all-targets. symbiont links the full Ada stack (lance + surrealdb + OGAR) and is left to CI rather than built here. Co-Authored-By: Claude --- crates/onebrc-probe/Cargo.lock | 1 + crates/onebrc-probe/src/lane_e.rs | 1 - crates/onebrc-probe/src/lane_g.rs | 1 - crates/onebrc-probe/src/lane_i.rs | 2 -- crates/onebrc-probe/src/lane_j.rs | 2 -- crates/symbiont/src/kanban_loop.rs | 15 +++++---------- 6 files changed, 6 insertions(+), 16 deletions(-) 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/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::>(), From 4bf9fd7ce43fec03093de7bb6875037e4c5f0965 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:21:01 +0000 Subject: [PATCH 07/16] C1 part 2: migrate BOTH BeliefArenas onto the canonical source registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lance-graph-planner::nars::belief and deepnsm-v2::belief each drop their local Stamp(pub u64) and re-export contract::source_registry::{SourceId, Stamp, CapacityExceeded}. Each arena owns a PRIVATE SourceRegistry; observe/revise_at take a SourceId and return Result, so capacity exhaustion is reported rather than folded onto an existing slot. A private revise_at_stamp is the shared core, so one logical event mints its slot exactly once. Containment held at the call sites, not just in the type. Stamp is opaque now, so asc_challenge's self-reference guard could no longer build a stamp to compare against. Rather than leak a mint, the arena gained stmt_has_source(stmt, source) — a query that never allocates a slot, since an unregistered source cannot be present. No Stamp crosses the boundary to answer the question. THE MIGRATION FOUND REAL DRIFT. The rule "move both together or neither" was argued from a hypothetical asymmetry. The copies had already diverged: planner: if stamp != Stamp::default() && b.stamp.disjoint(stamp) deepnsm-v2: if b.stamp.disjoint(stamp) The planner's doc calls that empty-stamp guard load-bearing — the no-source sentinel is disjoint from every stamp, so treating it as independent evidence lets a repeated unsourced observation pool into itself and inflate confidence without bound. deepnsm-v2 had no guard. Same struct name, same folding comment, same S4 doc-bullet, different answer to "when may evidence pool". Aligned to the planner's behaviour, documented at the divergence rather than quietly harmonized. Two things made it invisible: nothing compared the copies, and both carried the same explanatory prose — the narration was what stayed in sync, which read as though the code had. Stamp derives Default, so the missing guard would not have surfaced as a compile error either. It was found by reading. planner 319 green (unchanged count — no tests lost), deepnsm-v2 96 green, contract 1101 green, all-targets clean, fmt clean, no new warnings. Co-Authored-By: Claude --- .claude/board/EPIPHANIES.md | 19 ++ .claude/board/LATEST_STATE.md | 9 + crates/deepnsm-v2/src/belief.rs | 205 +++++++++++------- .../examples/gestalt_texture_smoke.rs | 19 +- .../examples/insight_archetype_read.rs | 23 +- .../examples/insight_overlap_smoke.rs | 19 +- .../examples/insight_read.rs | 7 +- .../examples/insight_relation_read.rs | 43 ++-- .../examples/reason_whole_book.rs | 15 +- .../examples/tactic_select_confusion.rs | 34 ++- .../src/nars/basin_resonance.rs | 66 +++--- crates/lance-graph-planner/src/nars/belief.rs | 188 ++++++++++------ .../src/nars/dissolution.rs | 31 +-- .../lance-graph-planner/src/nars/elevation.rs | 47 ++-- .../lance-graph-planner/src/nars/epiphany.rs | 28 ++- .../lance-graph-planner/src/nars/insight.rs | 19 +- .../lance-graph-planner/src/nars/insights.rs | 47 ++-- .../lance-graph-planner/src/nars/reach_out.rs | 46 ++-- .../lance-graph-planner/src/nars/regulate.rs | 27 +-- .../lance-graph-planner/src/nars/tactics.rs | 159 +++++++------- 20 files changed, 639 insertions(+), 412 deletions(-) diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 0809a6440..6cd9b8db9 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,22 @@ +## 2026-07-27 — E-THE-TWO-COPIES-HAD-ALREADY-DRIFTED-1 — **the "migrate both together" rule was justified by a hypothetical asymmetry; the migration found a REAL one.** + +**Status:** FINDING (measured while migrating, not predicted). **Confidence:** High — the divergence is two lines of source. + +The argument for moving `lance-graph-planner::nars::belief` and `deepnsm-v2::belief` onto the canonical registry in ONE change was that fixing one alone would leave "two incompatible independence semantics wearing matching comments." That was reasoning about a risk. Doing the work showed the copies had **already diverged**: + +- **Planner:** `if stamp != Stamp::default() && b.stamp.disjoint(stamp)` — with a doc-comment calling the empty-stamp guard *load-bearing*: the no-source sentinel is disjoint from EVERY stamp, so treating it as independent evidence lets a repeated unsourced observation pool into itself and inflate confidence without bound. +- **deepnsm-v2:** `if b.stamp.disjoint(stamp)` — no guard. The same repeated unsourced observation pools. + +Same struct name, same `source(id) = 1 << (id % 64)`, same S4 doc-bullet, same "CONSERVATIVE folding" comment — and a different answer to *when may evidence pool*. Neither file was wrong about itself; the pair was wrong about each other, and nothing could have noticed, because nothing compared them. + +**Two lessons, and the second is the load-bearing one:** +1. Duplicated types do not merely risk drifting. On a long enough timeline they HAVE drifted, and the copy you are not currently reading is the one that surprises you. +2. **A shared doc-comment is negative evidence of agreement.** Both copies carried the same explanatory prose about conservative folding, which is exactly why the behavioural difference stayed invisible — the prose was the thing being kept in sync, so it read as though the code was. This is `E-VACUOUS-ASSERTION-IS-THE-HOUSE-STYLE-1` in a new place: matching narration standing in for verified sameness. + +Aligned to the planner's (correct) behaviour, with the divergence documented at the site rather than quietly harmonized. A `Default`-derived `Stamp` means `Stamp::default()` still compiled after the migration, so the guard's absence would NOT have surfaced as a compile error — it was found by reading, which is the only thing that could have found it. + +Refs: `E-A-LOCAL-BITSET-IS-NOT-SELF-DESCRIBING-PROVENANCE-1`, `E-THE-UNCONTESTED-AXIS-IS-THE-ONE-THAT-MERGES-1` (nobody had contested whether the two copies agreed). + ## 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. diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 2c3641bf1..c628a79e8 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,12 @@ +## 2026-07-27 — branch `claude/medcare-rs-transcode-ruff-3y2olh` — causality-audit C1 part 2: BOTH BeliefArenas migrated onto the canonical registry (and the drift it exposed) + +### Planner + deepnsm-v2 — the two `Stamp(pub u64)` copies retired +- `lance_graph_planner::nars::belief` and `deepnsm_v2::belief` both drop their local `Stamp` and re-export `contract::source_registry::{SourceId, Stamp, CapacityExceeded}`. Each `BeliefArena` now owns a PRIVATE `SourceRegistry`; `observe`/`revise_at` take a `SourceId` and return `Result` — **capacity exhaustion is reported, never folded**, the behavioural difference from `1 << (id % 64)`. A private `revise_at_stamp` is the shared core so one logical event mints its slot exactly once. +- **Containment held at the call sites, not just the type.** `Stamp` is opaque (private bits), so `asc_challenge`'s self-reference guard could no longer construct a stamp to compare against. Rather than leak a mint, the arena gained `stmt_has_source(stmt, source) -> bool` — a QUERY that never allocates a slot (an unregistered source cannot be present), so no `Stamp` crosses the boundary to answer it. `cr_synthesize` and `reach_out_integrate` take `SourceId`. +- **The migration exposed real drift between the two "identical" copies.** The planner's `revise_at` guards `!stamp.is_empty()` before pooling — its doc calls this load-bearing, because the no-source sentinel is disjoint from EVERY stamp, so treating it as independent evidence lets a repeated unsourced observation pool into itself and inflate confidence without bound. **deepnsm-v2's copy had no such guard.** Two implementations with genuinely different independence semantics, wearing matching comments — precisely the silent asymmetry that made migrating them together non-negotiable rather than tidy. Aligned to the planner's (correct) behaviour, documented in-source at the divergence. + +Gate: deepnsm-v2 96 green; planner lib green (examples in progress at time of writing). Detail: `EPIPHANIES` `E-A-LOCAL-BITSET-IS-NOT-SELF-DESCRIBING-PROVENANCE-1`. + ## 2026-07-27 — branch `claude/medcare-rs-transcode-ruff-3y2olh` — causality-audit C1 + D1: canonical source registry + the four-signal settlement field ### Current Contract Inventory — two new modules diff --git a/crates/deepnsm-v2/src/belief.rs b/crates/deepnsm-v2/src/belief.rs index ba62007fc..cef55cfd7 100644 --- a/crates/deepnsm-v2/src/belief.rs +++ b/crates/deepnsm-v2/src/belief.rs @@ -23,32 +23,22 @@ //! by shared terms; truth moves only by truth functions. No fingerprints. use lance_graph_contract::exploration::NarsTruth; +use lance_graph_contract::source_registry::SourceRegistry; use std::collections::HashMap; -/// Fixed-width evidential stamp: bit *i* = observation source *i* (bounded -/// horizon of 64 sources; sources beyond the horizon fold by modulo, which is -/// CONSERVATIVE — folding can only create false overlap, never false -/// disjointness, so the no-double-count guarantee survives the bound). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct Stamp(pub u64); - -impl Stamp { - /// The stamp of a single observation source. - #[must_use] - pub fn source(id: u32) -> Self { - Stamp(1u64 << (id % 64)) - } - /// Two stamps share no evidence. - #[must_use] - pub fn disjoint(self, other: Self) -> bool { - self.0 & other.0 == 0 - } - /// Pooled evidence base. - #[must_use] - pub fn union(self, other: Self) -> Self { - Stamp(self.0 | other.0) - } -} +/// Evidential provenance types — CANONICAL in +/// `lance_graph_contract::source_registry`, re-exported here. +/// +/// This module and `lance-graph-planner::nars::belief` each used to define +/// their OWN `Stamp(pub u64)` with `source(id) = 1 << (id % 64)`: two copies, +/// two independence semantics, drifting in step. Migrated TOGETHER — fixing one +/// alone would have left a silent asymmetry, which is worse than fixing +/// neither. +/// +/// [`Stamp`] is now opaque: readable, not constructible outside the registry +/// that mints it, because a bitset does not carry the mapping that gives its +/// bits meaning. Stamps are arena-local by containment. +pub use lance_graph_contract::source_registry::{CapacityExceeded, SourceId, Stamp}; /// The copula of a concept-level statement (S3). `Rel` carries an arbitrary /// relational term (an FSM verb id) — stored, queryable, NEVER transitive. @@ -132,6 +122,9 @@ pub enum ReviseOutcome { pub struct BeliefArena { entries: Vec, index: HashMap, + /// This arena's OWN source registry — the mapping that gives its stamps + /// their meaning. Private, and stamps never leave. + registry: SourceRegistry, /// Closure passes run by the last `close_transitive` call. pub passes: u32, /// Whether the last closure reached a true fixed point. @@ -161,8 +154,16 @@ impl BeliefArena { /// absent; otherwise routes through the S4 guard: disjoint → revision /// in place; overlap → CHOICE. The arena NEVER grows a second entry for an /// existing statement — the termination invariant. - pub fn observe(&mut self, stmt: CStmt, truth: NarsTruth, stamp: Stamp) -> ReviseOutcome { - match self.index.get(&stmt) { + /// Capacity exhaustion is REPORTED ([`CapacityExceeded`]), never folded — + /// the behavioural difference from the retired `id % 64`. + pub fn observe( + &mut self, + stmt: CStmt, + truth: NarsTruth, + source: SourceId, + ) -> Result { + let stamp = self.registry.stamp_for(source)?; + Ok(match self.index.get(&stmt) { None => { let id = self.entries.len() as u32; self.entries.push(Belief { @@ -176,17 +177,39 @@ impl BeliefArena { self.index.insert(stmt, id); ReviseOutcome::Admitted { id } } - Some(&id) => self.revise_at(id, truth, stamp), - } + Some(&id) => self.revise_at_stamp(id, truth, stamp), + }) } /// The S4 revision guard on an existing belief. Disjoint stamps → NARS /// evidence pooling (`NarsTruth::revision`) + stamp union + preserved /// |f₁−f₂| depth, IN PLACE (rung untouched). Overlapping stamps → CHOICE: /// keep the higher-confidence truth, count nothing twice. - pub fn revise_at(&mut self, id: u32, new: NarsTruth, stamp: Stamp) -> ReviseOutcome { + /// Capacity exhaustion is REPORTED, never folded (see [`observe`](Self::observe)). + pub fn revise_at( + &mut self, + id: u32, + new: NarsTruth, + source: SourceId, + ) -> Result { + let stamp = self.registry.stamp_for(source)?; + Ok(self.revise_at_stamp(id, new, stamp)) + } + + /// The stamp-taking core, so one logical event mints its slot exactly once. + /// Private: a `Stamp` is only meaningful relative to THIS arena's registry. + /// + /// **The empty-stamp guard was MISSING here** and present in the + /// `lance-graph-planner` twin, which documents it as load-bearing: the + /// no-source sentinel is disjoint from EVERY stamp, so treating it as + /// independent evidence lets a repeated unsourced observation pool into + /// itself and inflate confidence without bound. The two copies had drifted + /// into genuinely different semantics — exactly the asymmetry that made + /// migrating them together non-negotiable. Aligned to the planner's + /// (correct) behaviour: unsourced evidence competes by CHOICE, never pools. + fn revise_at_stamp(&mut self, id: u32, new: NarsTruth, stamp: Stamp) -> ReviseOutcome { let b = &mut self.entries[id as usize]; - if b.stamp.disjoint(stamp) { + if !stamp.is_empty() && b.stamp.disjoint(stamp) { let depth = (b.truth.frequency - new.frequency).abs(); b.contradiction = b.contradiction.max(depth); b.truth = b.truth.revision(&new); @@ -304,7 +327,7 @@ impl BeliefArena { // stamp is disjoint from every observation, so observing // it always pools). The `rung == 0` guard missed the // latter and would drop the observed evidence. - if e.stamp != Stamp::default() { + if !e.stamp.is_empty() { continue; // observation dominates a derivation } if truth.expectation() > e.truth.expectation() + EPS { @@ -319,7 +342,7 @@ impl BeliefArena { self.entries.push(Belief { stmt, truth, - stamp: Stamp::default(), // derived: no observation sources of its own + stamp: Stamp::EMPTY, // derived: no observation sources of its own rung, premises: premises.to_vec(), contradiction: 0.0, @@ -360,17 +383,19 @@ mod tests { // A 10-concept is_a chain with a cycle at the tail (worst case for // termination) … for k in 0..9u16 { - arena.observe( - inh(k, k + 1), - NarsTruth::new(0.9, 0.9), - Stamp::source(k as u32), - ); + arena + .observe(inh(k, k + 1), NarsTruth::new(0.9, 0.9), SourceId(k as u64)) + .unwrap(); } - arena.observe(inh(9, 0), NarsTruth::new(0.9, 0.9), Stamp::source(9)); // cycle - // … and the dialectic: thesis vs antithesis on the SAME statement from - // DISJOINT sources (the CR worked example: w₁=9, w₂=3). + arena + .observe(inh(9, 0), NarsTruth::new(0.9, 0.9), SourceId(9)) // cycle + .unwrap(); + // … and the dialectic: thesis vs antithesis on the SAME statement from + // DISJOINT sources (the CR worked example: w₁=9, w₂=3). let stmt = inh(0, 1); - let out = arena.observe(stmt, NarsTruth::new(0.2, 0.75), Stamp::source(40)); + let out = arena + .observe(stmt, NarsTruth::new(0.2, 0.75), SourceId(40)) + .unwrap(); let ReviseOutcome::Revised { synthesis_c, depth, .. } = out @@ -407,7 +432,9 @@ mod tests { // Revision after closure STILL terminates arena growth: same statement, // new disjoint source → in-place, no new entry. let n = arena.entries().len(); - arena.observe(stmt, NarsTruth::new(0.8, 0.5), Stamp::source(41)); + arena + .observe(stmt, NarsTruth::new(0.8, 0.5), SourceId(41)) + .unwrap(); assert_eq!(arena.entries().len(), n, "revision never mints an entry"); } @@ -418,10 +445,14 @@ mod tests { fn revision_overlapping_stamp_is_rejected() { let mut arena = BeliefArena::new(); let stmt = inh(1, 2); - arena.observe(stmt, NarsTruth::new(0.9, 0.8), Stamp::source(7)); + arena + .observe(stmt, NarsTruth::new(0.9, 0.8), SourceId(7)) + .unwrap(); let before = arena.get(stmt).unwrap().truth; // Same source (overlap) offering "more of the same evidence". - let out = arena.observe(stmt, NarsTruth::new(0.9, 0.6), Stamp::source(7)); + let out = arena + .observe(stmt, NarsTruth::new(0.9, 0.6), SourceId(7)) + .unwrap(); assert_eq!( out, ReviseOutcome::Chosen { @@ -437,7 +468,9 @@ mod tests { "no double count: truth unchanged by overlapping evidence" ); // Overlap with HIGHER confidence: choice swaps, still no pooling. - let out = arena.observe(stmt, NarsTruth::new(0.4, 0.95), Stamp::source(7)); + let out = arena + .observe(stmt, NarsTruth::new(0.4, 0.95), SourceId(7)) + .unwrap(); assert_eq!( out, ReviseOutcome::Chosen { @@ -459,24 +492,28 @@ mod tests { fn verbs_do_not_transit() { let mut arena = BeliefArena::new(); let bit = Copula::Rel(77); // "bit" - arena.observe( - CStmt { - s: 1, - cop: bit, - p: 2, - }, // dog bit man - NarsTruth::new(1.0, 0.9), - Stamp::source(0), - ); - arena.observe( - CStmt { - s: 2, - cop: bit, - p: 3, - }, // man bit sandwich - NarsTruth::new(1.0, 0.9), - Stamp::source(1), - ); + arena + .observe( + CStmt { + s: 1, + cop: bit, + p: 2, + }, // dog bit man + NarsTruth::new(1.0, 0.9), + SourceId(0), + ) + .unwrap(); + arena + .observe( + CStmt { + s: 2, + cop: bit, + p: 3, + }, // man bit sandwich + NarsTruth::new(1.0, 0.9), + SourceId(1), + ) + .unwrap(); arena.close_transitive(16); assert!(arena.reached_fixed_point); assert_eq!(arena.entries().len(), 2, "dog bit sandwich must NOT derive"); @@ -489,8 +526,12 @@ mod tests { .is_none()); // Control: the same shape under Inh DOES transit, with deduction truth. let mut inh_arena = BeliefArena::new(); - inh_arena.observe(inh(1, 2), NarsTruth::new(0.9, 0.8), Stamp::source(0)); - inh_arena.observe(inh(2, 3), NarsTruth::new(1.0, 0.95), Stamp::source(1)); + inh_arena + .observe(inh(1, 2), NarsTruth::new(0.9, 0.8), SourceId(0)) + .unwrap(); + inh_arena + .observe(inh(2, 3), NarsTruth::new(1.0, 0.95), SourceId(1)) + .unwrap(); inh_arena.close_transitive(16); let d = inh_arena.get(inh(1, 3)).expect("Inh transits"); assert!((d.truth.frequency - 0.9).abs() < 1e-6); // f = 0.9·1.0 @@ -504,13 +545,19 @@ mod tests { #[test] fn revision_keeps_rung_in_place() { let mut arena = BeliefArena::new(); - arena.observe(inh(1, 2), NarsTruth::new(0.9, 0.8), Stamp::source(0)); - arena.observe(inh(2, 3), NarsTruth::new(0.9, 0.8), Stamp::source(1)); + arena + .observe(inh(1, 2), NarsTruth::new(0.9, 0.8), SourceId(0)) + .unwrap(); + arena + .observe(inh(2, 3), NarsTruth::new(0.9, 0.8), SourceId(1)) + .unwrap(); arena.close_transitive(16); let derived = inh(1, 3); assert_eq!(arena.get(derived).unwrap().rung, 1); // Independent observation of the derived statement revises it in place… - let out = arena.observe(derived, NarsTruth::new(0.7, 0.6), Stamp::source(9)); + let out = arena + .observe(derived, NarsTruth::new(0.7, 0.6), SourceId(9)) + .unwrap(); assert!(matches!(out, ReviseOutcome::Revised { .. })); // …at its EXISTING rung. assert_eq!( @@ -540,7 +587,7 @@ mod tests { let mut src = 0u32; for group in order { for &(stmt, truth) in group { - arena.observe(stmt, truth, Stamp::source(src)); + arena.observe(stmt, truth, SourceId(src as u64)).unwrap(); src += 1; } } @@ -590,15 +637,21 @@ mod tests { fn closure_does_not_overwrite_a_derived_then_observed_belief() { let mut arena = BeliefArena::new(); // 1) Derive A→C weakly from A→B→C (rung 1, empty stamp). - arena.observe(inh(0, 1), NarsTruth::new(0.6, 0.5), Stamp::source(0)); - arena.observe(inh(1, 2), NarsTruth::new(0.6, 0.5), Stamp::source(1)); + arena + .observe(inh(0, 1), NarsTruth::new(0.6, 0.5), SourceId(0)) + .unwrap(); + arena + .observe(inh(1, 2), NarsTruth::new(0.6, 0.5), SourceId(1)) + .unwrap(); arena.close_transitive(64); assert_eq!(arena.get(inh(0, 2)).unwrap().rung, 1, "A→C derived"); // 2) Observe A→C directly (disjoint source): revision pools evidence in // place at rung 1 — the belief now carries a non-empty stamp. Chosen // so its expectation stays BELOW the strong path's, proving the guard // (not the expectation test) is what protects it. - let out = arena.observe(inh(0, 2), NarsTruth::new(0.55, 0.9), Stamp::source(2)); + let out = arena + .observe(inh(0, 2), NarsTruth::new(0.55, 0.9), SourceId(2)) + .unwrap(); assert!( matches!(out, ReviseOutcome::Revised { .. }), "disjoint → revision (derived belief's zero stamp is disjoint from any source)" @@ -607,8 +660,12 @@ mod tests { // 3) Add a STRONGER path A→D→C (deduction expectation ≈ 0.79, above the // revised A→C) and re-close. The pre-fix code would overwrite A→C with // the pure derivation, dropping the observation. - arena.observe(inh(0, 3), NarsTruth::new(0.95, 0.9), Stamp::source(3)); - arena.observe(inh(3, 2), NarsTruth::new(0.95, 0.9), Stamp::source(4)); + arena + .observe(inh(0, 3), NarsTruth::new(0.95, 0.9), SourceId(3)) + .unwrap(); + arena + .observe(inh(3, 2), NarsTruth::new(0.95, 0.9), SourceId(4)) + .unwrap(); arena.close_transitive(64); let after = arena.get(inh(0, 2)).unwrap().truth; assert_eq!( diff --git a/crates/lance-graph-planner/examples/gestalt_texture_smoke.rs b/crates/lance-graph-planner/examples/gestalt_texture_smoke.rs index 4b25eadc8..a0a51a19d 100644 --- a/crates/lance-graph-planner/examples/gestalt_texture_smoke.rs +++ b/crates/lance-graph-planner/examples/gestalt_texture_smoke.rs @@ -31,10 +31,11 @@ use std::collections::HashMap; +use lance_graph_planner::nars::belief::SourceId; use lance_graph_planner::nars::{ detect_dissolution, rank_basins, rank_epiphany_attractors, reach_out_integrate, staunen, wisdom, Basin, BasinKind, BeliefArena, CStmt, Copula, FeltOutcome, ReachOutConfig, - ResonanceConfig, Snapshot, Stamp, TruthValue, + ResonanceConfig, Snapshot, TruthValue, }; /// A tiny deterministic word→concept-id interner shared across BOTH poets, so the @@ -96,11 +97,13 @@ fn ingest(arena: &mut BeliefArena, lines: &[&str], intern: &mut Interner, src_ba let cs = concepts(line, intern); for w in cs.windows(2) { if w[0] != w[1] { - arena.observe( - inh(w[0], w[1]), - TruthValue::new(0.9, 0.9), - Stamp::source(src_base + n), - ); + arena + .observe( + inh(w[0], w[1]), + TruthValue::new(0.9, 0.9), + SourceId((src_base + n) as u64), + ) + .unwrap(); n += 1; } } @@ -252,7 +255,7 @@ fn main() { &mut felt_arena, inh(rose, flame), TruthValue::new(0.9, 0.9), - Stamp::source(9800), + SourceId(9800), &ReachOutConfig::default(), ); let g_after = Snapshot::of(&felt_arena, 0.0); @@ -265,7 +268,7 @@ fn main() { &mut dull_arena, inh(bank, loan), TruthValue::new(0.9, 0.9), - Stamp::source(9900), + SourceId(9900), &ReachOutConfig::default(), ); diff --git a/crates/lance-graph-planner/examples/insight_archetype_read.rs b/crates/lance-graph-planner/examples/insight_archetype_read.rs index b0aceaf45..15c42733a 100644 --- a/crates/lance-graph-planner/examples/insight_archetype_read.rs +++ b/crates/lance-graph-planner/examples/insight_archetype_read.rs @@ -40,7 +40,8 @@ use lance_graph_contract::grammar::role_keys::Tense; use lance_graph_contract::grammar::tekamolo::TekamoloSlot; use lance_graph_contract::grammar::verb_lexicon::{is_copula, read_verb}; use lance_graph_contract::grammar::verb_table::VerbFamily; -use lance_graph_planner::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; +use lance_graph_planner::nars::belief::SourceId; +use lance_graph_planner::nars::{BeliefArena, CStmt, Copula, TruthValue}; /// Minimal function-word stoplist — the point here is the typed edges, not /// vocabulary tuning. @@ -154,15 +155,17 @@ fn extract(text: &str, intern: &mut Interner) -> Vec { fn arena_of(edges: &[TypedEdge]) -> BeliefArena { let mut arena = BeliefArena::new(); for (i, e) in edges.iter().enumerate() { - arena.observe( - CStmt { - s: e.s, - cop: Copula::Inh, - p: e.p, - }, - TruthValue::new(0.9, 0.9), - Stamp::source(i as u32), - ); + arena + .observe( + CStmt { + s: e.s, + cop: Copula::Inh, + p: e.p, + }, + TruthValue::new(0.9, 0.9), + SourceId(i as u64), + ) + .unwrap(); } arena.close_transitive(256); arena diff --git a/crates/lance-graph-planner/examples/insight_overlap_smoke.rs b/crates/lance-graph-planner/examples/insight_overlap_smoke.rs index 74ed8893d..0fb44eee6 100644 --- a/crates/lance-graph-planner/examples/insight_overlap_smoke.rs +++ b/crates/lance-graph-planner/examples/insight_overlap_smoke.rs @@ -28,10 +28,11 @@ use std::collections::HashMap; +use lance_graph_planner::nars::belief::SourceId; use lance_graph_planner::nars::{ detect_dissolution, rank_basins, rank_epiphany_attractors, reach_out_integrate, staunen, wisdom, BasinKind, BeliefArena, CStmt, Copula, FeltOutcome, ReachOutConfig, ResonanceConfig, - Snapshot, Stamp, TruthValue, + Snapshot, TruthValue, }; /// A tiny deterministic word→concept-id interner (shared across texts, so the @@ -132,11 +133,13 @@ fn ingest(arena: &mut BeliefArena, text: &str, intern: &mut Interner, src_base: let mut n = 0u32; for w in cs.windows(2) { if w[0] != w[1] { - arena.observe( - inh(w[0], w[1]), - TruthValue::new(0.9, 0.9), - Stamp::source(src_base + n), - ); + arena + .observe( + inh(w[0], w[1]), + TruthValue::new(0.9, 0.9), + SourceId((src_base + n) as u64), + ) + .unwrap(); n += 1; } } @@ -240,7 +243,7 @@ fn main() { &mut felt_arena, inh(montague, capulet), TruthValue::new(0.9, 0.9), - Stamp::source(500), + SourceId(500), &ReachOutConfig::default(), ); let g_after = Snapshot::of(&felt_arena, 0.0); @@ -254,7 +257,7 @@ fn main() { &mut dull_arena, inh(gravity, light), TruthValue::new(0.9, 0.9), - Stamp::source(600), + SourceId(600), &ReachOutConfig::default(), ); diff --git a/crates/lance-graph-planner/examples/insight_read.rs b/crates/lance-graph-planner/examples/insight_read.rs index 1ba1e9697..e4273d07d 100644 --- a/crates/lance-graph-planner/examples/insight_read.rs +++ b/crates/lance-graph-planner/examples/insight_read.rs @@ -22,9 +22,10 @@ use std::collections::HashMap; +use lance_graph_planner::nars::belief::SourceId; use lance_graph_planner::nars::{ extract_main_insights, rank_basins, rank_epiphany_attractors, staunen, wisdom, BeliefArena, - CStmt, Copula, InsightConfig, InsightKind, InsightReason, ResonanceConfig, Snapshot, Stamp, + CStmt, Copula, InsightConfig, InsightKind, InsightReason, ResonanceConfig, Snapshot, TruthValue, }; @@ -306,7 +307,9 @@ fn close_from(salient: &[(usize, u16)], window: usize, exclude: Option) -> break; } if ci != cj && exclude != Some(cj) { - arena.observe(inh(ci, cj), TruthValue::new(0.9, 0.9), Stamp::source(src)); + arena + .observe(inh(ci, cj), TruthValue::new(0.9, 0.9), SourceId(src as u64)) + .unwrap(); src = src.wrapping_add(1); } } diff --git a/crates/lance-graph-planner/examples/insight_relation_read.rs b/crates/lance-graph-planner/examples/insight_relation_read.rs index 0042ad431..ac2568913 100644 --- a/crates/lance-graph-planner/examples/insight_relation_read.rs +++ b/crates/lance-graph-planner/examples/insight_relation_read.rs @@ -49,7 +49,8 @@ use std::collections::{HashMap, HashSet}; -use lance_graph_planner::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; +use lance_graph_planner::nars::belief::SourceId; +use lance_graph_planner::nars::{BeliefArena, CStmt, Copula, TruthValue}; /// A modest English stoplist — function words that never carry a concept. Kept /// deterministic and small; the point of this example is the *edges*, not vocab @@ -271,15 +272,17 @@ fn close_sparse(rels: &[Relation], exclude: Option) -> BeliefArena { if exclude == Some(r.s) || exclude == Some(r.o) { continue; } - arena.observe( - CStmt { - s: r.s, - cop: Copula::Inh, - p: r.o, - }, - TruthValue::new(0.9, 0.9), - Stamp::source(src), - ); + arena + .observe( + CStmt { + s: r.s, + cop: Copula::Inh, + p: r.o, + }, + TruthValue::new(0.9, 0.9), + SourceId(src as u64), + ) + .unwrap(); src = src.wrapping_add(1); } arena.close_transitive(512); @@ -302,15 +305,17 @@ fn close_dense(salient: &[(usize, u16)], window: usize, exclude: Option) -> break; } if ci != cj && exclude != Some(cj) { - arena.observe( - CStmt { - s: ci, - cop: Copula::Inh, - p: cj, - }, - TruthValue::new(0.9, 0.9), - Stamp::source(src), - ); + arena + .observe( + CStmt { + s: ci, + cop: Copula::Inh, + p: cj, + }, + TruthValue::new(0.9, 0.9), + SourceId(src as u64), + ) + .unwrap(); src = src.wrapping_add(1); } } diff --git a/crates/lance-graph-planner/examples/reason_whole_book.rs b/crates/lance-graph-planner/examples/reason_whole_book.rs index a3785e8a3..871bc7fbd 100644 --- a/crates/lance-graph-planner/examples/reason_whole_book.rs +++ b/crates/lance-graph-planner/examples/reason_whole_book.rs @@ -26,8 +26,9 @@ //! frontier finite; `ReasoningGap`s should surface where word-level SPO lacks //! the concept structure the tactics need (the E-HERMENEUTIK concept-lift). +use lance_graph_planner::nars::belief::SourceId; use lance_graph_planner::nars::{ - cas_abstract, rcr_abduce, BeliefArena, CStmt, Copula, GapKind, Stamp, Throttle, TruthValue, + cas_abstract, rcr_abduce, BeliefArena, CStmt, Copula, GapKind, Throttle, TruthValue, }; use std::collections::HashMap; use std::time::Instant; @@ -89,11 +90,13 @@ fn main() { }; *subj_degree.entry(s).or_default() += 1; // Observed fact: asserted (freq 1.0), moderate confidence; stamp = verse. - arena.observe( - CStmt { s, cop, p: o }, - TruthValue::new(1.0, 0.9), - Stamp::source(v), - ); + arena + .observe( + CStmt { s, cop, p: o }, + TruthValue::new(1.0, 0.9), + SourceId(v as u64), + ) + .unwrap(); } let observed = arena.entries().len(); println!("── ingest ──"); diff --git a/crates/lance-graph-planner/examples/tactic_select_confusion.rs b/crates/lance-graph-planner/examples/tactic_select_confusion.rs index d807423b5..a2416e7cf 100644 --- a/crates/lance-graph-planner/examples/tactic_select_confusion.rs +++ b/crates/lance-graph-planner/examples/tactic_select_confusion.rs @@ -25,10 +25,11 @@ //! Usage: `cargo run -p lance-graph-planner --example tactic_select_confusion` use lance_graph_contract::sensorium::GraphBias; +use lance_graph_planner::nars::belief::SourceId; use lance_graph_planner::nars::{ asc_challenge, cas_abstract, challenge_target, cr_synthesize, rcr_abduce, tactic_for_bias, - tr_diverge, AscOutcome, BeliefArena, CStmt, Copula, ReviseOutcome, Stamp, TacticChoice, - Throttle, TruthValue, + tr_diverge, AscOutcome, BeliefArena, CStmt, Copula, ReviseOutcome, TacticChoice, Throttle, + TruthValue, }; fn inh(s: u16, p: u16) -> CStmt { @@ -64,7 +65,7 @@ struct Fixture { /// `BeliefArena` is not `Clone` and the revision tactics mutate. fn fires(choice: TacticChoice, fx: &Fixture) -> bool { let mut arena = (fx.build)(); - let counter_stamp = Stamp::source(9999); // disjoint from every fixture source + let counter_stamp = SourceId(9999); // disjoint from every fixture source match choice { TacticChoice::Rcr => !rcr_abduce(&arena, &Throttle::permissive()) .candidates @@ -88,7 +89,8 @@ fn fires(choice: TacticChoice, fx: &Fixture) -> bool { fx.target, TruthValue::new(0.2, 0.8), counter_stamp - ), + ) + .unwrap(), ReviseOutcome::Revised { .. } ), } @@ -102,8 +104,10 @@ fn main() { name: "shared-predicate pair", build: || { let mut a = BeliefArena::new(); - a.observe(inh(1, 9), TruthValue::new(0.9, 0.8), Stamp::source(0)); - a.observe(inh(2, 9), TruthValue::new(0.8, 0.7), Stamp::source(1)); + a.observe(inh(1, 9), TruthValue::new(0.9, 0.8), SourceId(0)) + .unwrap(); + a.observe(inh(2, 9), TruthValue::new(0.8, 0.7), SourceId(1)) + .unwrap(); a }, focus: inh(1, 9), @@ -117,8 +121,10 @@ fn main() { name: "similarity sibling", build: || { let mut a = BeliefArena::new(); - a.observe(inh(1, 9), TruthValue::new(0.9, 0.8), Stamp::source(0)); - a.observe(sim(1, 2), TruthValue::new(0.9, 0.8), Stamp::source(1)); + a.observe(inh(1, 9), TruthValue::new(0.9, 0.8), SourceId(0)) + .unwrap(); + a.observe(sim(1, 2), TruthValue::new(0.9, 0.8), SourceId(1)) + .unwrap(); a }, focus: inh(1, 9), @@ -132,8 +138,10 @@ fn main() { name: "is_a chain", build: || { let mut a = BeliefArena::new(); - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); - a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(1)); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); + a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(1)) + .unwrap(); a }, focus: inh(1, 2), @@ -147,7 +155,8 @@ fn main() { name: "lone belief (challenge)", build: || { let mut a = BeliefArena::new(); - a.observe(inh(1, 2), TruthValue::new(0.8, 0.8), Stamp::source(0)); + a.observe(inh(1, 2), TruthValue::new(0.8, 0.8), SourceId(0)) + .unwrap(); a }, focus: inh(1, 2), @@ -161,7 +170,8 @@ fn main() { name: "lone belief (synthesize)", build: || { let mut a = BeliefArena::new(); - a.observe(inh(1, 2), TruthValue::new(0.9, 0.8), Stamp::source(0)); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.8), SourceId(0)) + .unwrap(); a }, focus: inh(1, 2), diff --git a/crates/lance-graph-planner/src/nars/basin_resonance.rs b/crates/lance-graph-planner/src/nars/basin_resonance.rs index d812e4609..eeda05555 100644 --- a/crates/lance-graph-planner/src/nars/basin_resonance.rs +++ b/crates/lance-graph-planner/src/nars/basin_resonance.rs @@ -48,7 +48,7 @@ //! [`super::dissolution`] (`staunen`/`wisdom` at whole-arena granularity; here //! they are per-basin), [`super::insight`] (the S10 per-step notion). -use super::belief::{BeliefArena, Copula, Stamp}; +use super::belief::{BeliefArena, Copula}; use super::truth::TruthValue; use std::collections::BTreeMap; @@ -139,7 +139,7 @@ pub fn rank_basins(arena: &BeliefArena, cfg: &ResonanceConfig) -> Vec { // Rel (verbs) never join a reasoning basin; Inh/Sim/Impl do. continue; } - let is_evidence = b.stamp != Stamp::default(); + let is_evidence = !b.stamp.is_empty(); let is_derived = b.rung >= 1; let conf = b.truth.confidence; let exp = b.truth.expectation(); @@ -211,7 +211,8 @@ pub fn rank_basins(arena: &BeliefArena, cfg: &ResonanceConfig) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; + use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; + use lance_graph_contract::source_registry::SourceId; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -226,12 +227,18 @@ mod tests { /// conclusions `1→3, 1→4, 2→4` (logical coherence around the chain heads). fn structured_kg() -> BeliefArena { let mut a = BeliefArena::new(); - a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), Stamp::source(0)); - a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), Stamp::source(1)); - a.observe(inh(3, 100), TruthValue::new(0.9, 0.9), Stamp::source(2)); - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(3)); - a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(4)); - a.observe(inh(3, 4), TruthValue::new(0.9, 0.9), Stamp::source(5)); + a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); + a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), SourceId(1)) + .unwrap(); + a.observe(inh(3, 100), TruthValue::new(0.9, 0.9), SourceId(2)) + .unwrap(); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(3)) + .unwrap(); + a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(4)) + .unwrap(); + a.observe(inh(3, 4), TruthValue::new(0.9, 0.9), SourceId(5)) + .unwrap(); a.close_transitive(64); a } @@ -270,11 +277,9 @@ mod tests { (14, 114), (15, 115), ] { - noise.observe( - inh(s, p), - TruthValue::new(0.9, 0.9), - Stamp::source(s as u32), - ); + noise + .observe(inh(s, p), TruthValue::new(0.9, 0.9), SourceId(s as u64)) + .unwrap(); } noise.close_transitive(64); @@ -341,33 +346,39 @@ mod tests { big.observe( inh(s(1), pred), TruthValue::new(0.9, 0.9), - Stamp::source(base as u32), - ); + SourceId(base as u64), + ) + .unwrap(); big.observe( inh(s(2), pred), TruthValue::new(0.9, 0.9), - Stamp::source(base as u32 + 1), - ); + SourceId(base as u64 + 1), + ) + .unwrap(); big.observe( inh(s(3), pred), TruthValue::new(0.9, 0.9), - Stamp::source(base as u32 + 2), - ); + SourceId(base as u64 + 2), + ) + .unwrap(); big.observe( inh(s(1), s(2)), TruthValue::new(0.9, 0.9), - Stamp::source(base as u32 + 3), - ); + SourceId(base as u64 + 3), + ) + .unwrap(); big.observe( inh(s(2), s(3)), TruthValue::new(0.9, 0.9), - Stamp::source(base as u32 + 4), - ); + SourceId(base as u64 + 4), + ) + .unwrap(); big.observe( inh(s(3), s(4)), TruthValue::new(0.9, 0.9), - Stamp::source(base as u32 + 5), - ); + SourceId(base as u64 + 5), + ) + .unwrap(); } big.close_transitive(64); let big_top = rank_basins(&big, &ResonanceConfig::default()) @@ -387,7 +398,8 @@ mod tests { #[test] fn self_loop_is_counted_once() { let mut a = BeliefArena::new(); - a.observe(inh(5, 5), TruthValue::new(0.9, 0.9), Stamp::source(0)); + a.observe(inh(5, 5), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); assert!( !rank_basins(&a, &ResonanceConfig::default()) diff --git a/crates/lance-graph-planner/src/nars/belief.rs b/crates/lance-graph-planner/src/nars/belief.rs index 70530192a..96c4bfc07 100644 --- a/crates/lance-graph-planner/src/nars/belief.rs +++ b/crates/lance-graph-planner/src/nars/belief.rs @@ -22,31 +22,25 @@ //! overlap → CHOICE, no double count. use super::truth::TruthValue; +use lance_graph_contract::source_registry::SourceRegistry; use std::collections::HashMap; -/// Fixed-width evidential stamp: bit *i* = observation source *i* (bounded -/// horizon of 64; sources beyond fold by modulo — CONSERVATIVE: folding can only -/// create false overlap, never false disjointness, so no-double-count survives). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct Stamp(pub u64); - -impl Stamp { - /// The stamp of a single observation source. - #[must_use] - pub fn source(id: u32) -> Self { - Stamp(1u64 << (id % 64)) - } - /// Two stamps share no evidence. - #[must_use] - pub fn disjoint(self, other: Self) -> bool { - self.0 & other.0 == 0 - } - /// Pooled evidence base. - #[must_use] - pub fn union(self, other: Self) -> Self { - Stamp(self.0 | other.0) - } -} +/// Evidential provenance types — CANONICAL in `lance_graph_contract::source_registry`, +/// re-exported here so `crate::nars::Stamp` keeps resolving for existing readers. +/// +/// This module used to define its own `Stamp(pub u64)` with +/// `source(id) = 1 << (id % 64)`, as did `deepnsm-v2`. Two copies, two +/// independence semantics. The folding was CONSERVATIVE (a collision creates +/// false *overlap*, so revision refuses to pool — evidence lost, never +/// double-counted), but it destroyed everything downstream of knowing which bit +/// is whom: pooling past 64 sources, leave-one-out, withdrawal, and any reading +/// of an evidence count. +/// +/// [`Stamp`] is now OPAQUE — readable (`is_empty`/`count`/`disjoint`) but not +/// constructible outside the registry that mints it, because a bitset does not +/// carry the mapping that gives its bits meaning. Stamps are arena-local by +/// containment; see the `source_registry` module docs for the ruling. +pub use lance_graph_contract::source_registry::{CapacityExceeded, SourceId, Stamp}; /// The copula of a concept-level statement (S3). `Rel` carries an arbitrary /// relational term (an FSM verb id) — stored, queryable, NEVER transitive. @@ -129,6 +123,11 @@ pub enum ReviseOutcome { pub struct BeliefArena { entries: Vec, index: HashMap, + /// This arena's OWN source registry — the mapping that gives its stamps + /// their meaning. Private, and stamps never leave, so a stamp minted here + /// can never be compared against one minted by a different arena (where the + /// same bit denotes a different source). + registry: SourceRegistry, /// Closure passes run by the last `close_transitive` call. pub passes: u32, /// Whether the last closure reached a true fixed point. @@ -154,12 +153,38 @@ impl BeliefArena { self.index.get(&stmt).map(|&i| &self.entries[i as usize]) } + /// Does `stmt`'s belief already carry evidence from `source`? + /// + /// The containment-preserving replacement for `belief.stamp.disjoint(s)` at + /// a call site that only wants to know "is this source already counted + /// here?" — asking the question never requires minting a stamp, so no + /// `Stamp` has to cross the arena boundary to answer it. + /// + /// Query-only: an unregistered source cannot be present, so this never + /// allocates a slot and can never exhaust the registry (hence no `Result`). + #[must_use] + pub fn stmt_has_source(&self, stmt: CStmt, source: SourceId) -> bool { + match (self.registry.lookup(source), self.get(stmt)) { + (Some(slot), Some(b)) => b.stamp.contains(slot), + _ => false, + } + } + /// Offer evidence for a statement (rung 0 observation path). Admits it if /// absent; otherwise routes through the S4 guard: disjoint → revision in /// place; overlap → CHOICE. The arena NEVER grows a second entry for an /// existing statement — the termination invariant. - pub fn observe(&mut self, stmt: CStmt, truth: TruthValue, stamp: Stamp) -> ReviseOutcome { - match self.index.get(&stmt) { + /// Capacity exhaustion is REPORTED ([`CapacityExceeded`]), never folded — + /// that is the behavioural difference from the retired `id % 64`, which + /// silently aliased a 65th source onto an existing slot. + pub fn observe( + &mut self, + stmt: CStmt, + truth: TruthValue, + source: SourceId, + ) -> Result { + let stamp = self.registry.stamp_for(source)?; + Ok(match self.index.get(&stmt) { None => { let id = self.entries.len() as u32; self.entries.push(Belief { @@ -173,8 +198,8 @@ impl BeliefArena { self.index.insert(stmt, id); ReviseOutcome::Admitted { id } } - Some(&id) => self.revise_at(id, truth, stamp), - } + Some(&id) => self.revise_at_stamp(id, truth, stamp), + }) } /// The S4 revision guard on an existing belief. A NON-EMPTY incoming stamp @@ -188,9 +213,24 @@ impl BeliefArena { /// it as independent evidence would let a repeated zero-stamped observation /// pool into itself and inflate confidence without bound (`union` also never /// records overlap). Unsourced evidence cannot pool — it competes by CHOICE. - pub fn revise_at(&mut self, id: u32, new: TruthValue, stamp: Stamp) -> ReviseOutcome { + /// Capacity exhaustion is REPORTED, never folded (see [`observe`](Self::observe)). + pub fn revise_at( + &mut self, + id: u32, + new: TruthValue, + source: SourceId, + ) -> Result { + let stamp = self.registry.stamp_for(source)?; + Ok(self.revise_at_stamp(id, new, stamp)) + } + + /// The stamp-taking core, shared by [`observe`](Self::observe) and + /// [`revise_at`](Self::revise_at) so a single logical event mints its slot + /// exactly once. Private: a `Stamp` is only meaningful relative to THIS + /// arena's registry, so it must not be constructible from outside. + fn revise_at_stamp(&mut self, id: u32, new: TruthValue, stamp: Stamp) -> ReviseOutcome { let b = &mut self.entries[id as usize]; - if stamp != Stamp::default() && b.stamp.disjoint(stamp) { + if !stamp.is_empty() && b.stamp.disjoint(stamp) { let depth = (b.truth.frequency - new.frequency).abs(); b.contradiction = b.contradiction.max(depth); b.truth = b.truth.revise(&new); @@ -241,7 +281,7 @@ impl BeliefArena { // both purely-observed (rung 0) and derived-then-observed // (rung ≥ 1, stamp unioned by `revise_at`) beliefs; keying on the // stamp (evidence provenance), never the rung, is the Codex fix. - if e.stamp != Stamp::default() { + if !e.stamp.is_empty() { return false; } if truth.expectation() > e.truth.expectation() + EPS { @@ -257,7 +297,7 @@ impl BeliefArena { self.entries.push(Belief { stmt, truth, - stamp: Stamp::default(), // derived: no observation sources of its own + stamp: Stamp::EMPTY, // derived: no observation sources of its own rung, premises: premises.to_vec(), contradiction: 0.0, @@ -355,15 +395,17 @@ mod tests { fn revision_disjoint_moves_truth_and_terminates() { let mut arena = BeliefArena::new(); for k in 0..9u16 { - arena.observe( - inh(k, k + 1), - TruthValue::new(0.9, 0.9), - Stamp::source(k as u32), - ); + arena + .observe(inh(k, k + 1), TruthValue::new(0.9, 0.9), SourceId(k as u64)) + .unwrap(); } - arena.observe(inh(9, 0), TruthValue::new(0.9, 0.9), Stamp::source(9)); // cycle + arena + .observe(inh(9, 0), TruthValue::new(0.9, 0.9), SourceId(9)) + .unwrap(); // cycle let stmt = inh(0, 1); - let out = arena.observe(stmt, TruthValue::new(0.2, 0.75), Stamp::source(40)); + let out = arena + .observe(stmt, TruthValue::new(0.2, 0.75), SourceId(40)) + .unwrap(); let ReviseOutcome::Revised { synthesis_c, depth, .. } = out @@ -389,30 +431,38 @@ mod tests { fn verbs_do_not_transit() { let mut arena = BeliefArena::new(); let bit = Copula::Rel(77); - arena.observe( - CStmt { - s: 1, - cop: bit, - p: 2, - }, - TruthValue::new(1.0, 0.9), - Stamp::source(0), - ); - arena.observe( - CStmt { - s: 2, - cop: bit, - p: 3, - }, - TruthValue::new(1.0, 0.9), - Stamp::source(1), - ); + arena + .observe( + CStmt { + s: 1, + cop: bit, + p: 2, + }, + TruthValue::new(1.0, 0.9), + SourceId(0), + ) + .unwrap(); + arena + .observe( + CStmt { + s: 2, + cop: bit, + p: 3, + }, + TruthValue::new(1.0, 0.9), + SourceId(1), + ) + .unwrap(); arena.close_transitive(16); assert_eq!(arena.entries().len(), 2, "dog bit sandwich must NOT derive"); let mut inh_arena = BeliefArena::new(); - inh_arena.observe(inh(1, 2), TruthValue::new(0.9, 0.8), Stamp::source(0)); - inh_arena.observe(inh(2, 3), TruthValue::new(1.0, 0.95), Stamp::source(1)); + inh_arena + .observe(inh(1, 2), TruthValue::new(0.9, 0.8), SourceId(0)) + .unwrap(); + inh_arena + .observe(inh(2, 3), TruthValue::new(1.0, 0.95), SourceId(1)) + .unwrap(); inh_arena.close_transitive(16); let d = inh_arena.get(inh(1, 3)).expect("Inh transits"); assert!((d.truth.frequency - 0.9).abs() < 1e-6); @@ -426,7 +476,9 @@ mod tests { fn admit_derived_respects_observation_ground() { let mut arena = BeliefArena::new(); let stmt = inh(2, 1); - arena.observe(stmt, TruthValue::new(0.55, 0.95), Stamp::source(9)); + arena + .observe(stmt, TruthValue::new(0.55, 0.95), SourceId(9)) + .unwrap(); let before = arena.get(stmt).unwrap().truth; let changed = arena.admit_derived(stmt, TruthValue::new(0.99, 0.9), &[0, 0], 1); assert!(!changed, "ground belief not overwritten"); @@ -447,11 +499,19 @@ mod tests { fn empty_incoming_stamp_does_not_pool() { let mut arena = BeliefArena::new(); let stmt = inh(1, 2); - arena.observe(stmt, TruthValue::new(0.8, 0.5), Stamp::source(3)); + let id = match arena + .observe(stmt, TruthValue::new(0.8, 0.5), SourceId(3)) + .unwrap() + { + ReviseOutcome::Admitted { id } => id, + other => panic!("expected Admitted, got {other:?}"), + }; let c0 = arena.get(stmt).unwrap().truth.confidence; - // Same statement offered with an EMPTY stamp, repeatedly. + // Same statement offered with an EMPTY stamp, repeatedly. `SourceId` cannot + // construct an empty stamp (opaque by design), so this drives the private + // stamp-taking core directly to exercise the guard. for _ in 0..10 { - let out = arena.observe(stmt, TruthValue::new(0.8, 0.5), Stamp::default()); + let out = arena.revise_at_stamp(id, TruthValue::new(0.8, 0.5), Stamp::EMPTY); assert!( matches!(out, ReviseOutcome::Chosen { .. }), "empty stamp → CHOICE, got {out:?}" @@ -463,7 +523,9 @@ mod tests { "empty-stamped evidence never pooled: {c0} → {c1}" ); // A REAL disjoint source still pools (the guard is only for empty stamps). - let out = arena.observe(stmt, TruthValue::new(0.8, 0.5), Stamp::source(7)); + let out = arena + .observe(stmt, TruthValue::new(0.8, 0.5), SourceId(7)) + .unwrap(); assert!( matches!(out, ReviseOutcome::Revised { .. }), "real source still revises" diff --git a/crates/lance-graph-planner/src/nars/dissolution.rs b/crates/lance-graph-planner/src/nars/dissolution.rs index 4a601a7a5..9080fed71 100644 --- a/crates/lance-graph-planner/src/nars/dissolution.rs +++ b/crates/lance-graph-planner/src/nars/dissolution.rs @@ -98,7 +98,8 @@ pub fn should_elevate(d: &Dissolution, threshold: f32) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; + use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; + use lance_graph_contract::source_registry::SourceId; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -113,20 +114,20 @@ mod tests { fn score_ingest(core: &[(u16, u16)], new: &[(u16, u16)]) -> Dissolution { let mut arena = BeliefArena::new(); for (i, &(s, p)) in core.iter().enumerate() { - arena.observe( - inh(s, p), - TruthValue::new(0.95, 0.9), - Stamp::source(i as u32), - ); + arena + .observe(inh(s, p), TruthValue::new(0.95, 0.9), SourceId(i as u64)) + .unwrap(); } arena.close_transitive(64); let before = Snapshot::of(&arena, 0.0); for (j, &(s, p)) in new.iter().enumerate() { - arena.observe( - inh(s, p), - TruthValue::new(0.95, 0.9), - Stamp::source(1000 + j as u32), - ); + arena + .observe( + inh(s, p), + TruthValue::new(0.95, 0.9), + SourceId(1000 + j as u64), + ) + .unwrap(); } arena.close_transitive(64); let after = Snapshot::of(&arena, 0.0); @@ -221,8 +222,12 @@ mod tests { #[test] fn poles_read_from_snapshot() { let mut arena = BeliefArena::new(); - arena.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); - arena.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(1)); + arena + .observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); + arena + .observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(1)) + .unwrap(); arena.close_transitive(8); let snap = Snapshot::of(&arena, 0.0); diff --git a/crates/lance-graph-planner/src/nars/elevation.rs b/crates/lance-graph-planner/src/nars/elevation.rs index 4f25b4aa5..76e4f5e29 100644 --- a/crates/lance-graph-planner/src/nars/elevation.rs +++ b/crates/lance-graph-planner/src/nars/elevation.rs @@ -175,7 +175,8 @@ fn has_intermediate_parent(arena: &BeliefArena, s: u16, m: u16) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; + use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; + use lance_graph_contract::source_registry::SourceId; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -193,11 +194,9 @@ mod tests { // STRUCTURED: 5 subjects share predicate 100. let mut arena = BeliefArena::new(); for s in 10u16..15 { - arena.observe( - inh(s, 100), - TruthValue::new(0.9, 0.9), - Stamp::source(s.into()), - ); + arena + .observe(inh(s, 100), TruthValue::new(0.9, 0.9), SourceId(s.into())) + .unwrap(); } let e = elevate_field(&mut arena, 3); assert_eq!(e.clusters_lifted, 1, "one shared-predicate cluster lifted"); @@ -219,11 +218,9 @@ mod tests { // structure to lift. let mut noisy_arena = BeliefArena::new(); for (s, p) in [(10u16, 100u16), (11, 101), (12, 102), (13, 103), (14, 104)] { - noisy_arena.observe( - inh(s, p), - TruthValue::new(0.9, 0.9), - Stamp::source(s.into()), - ); + noisy_arena + .observe(inh(s, p), TruthValue::new(0.9, 0.9), SourceId(s.into())) + .unwrap(); } let e_noise = elevate_field(&mut noisy_arena, 3); assert_eq!( @@ -240,18 +237,18 @@ mod tests { fn minted_parent_propagates_to_children() { let mut arena = BeliefArena::new(); for s in 10u16..15 { - arena.observe( - inh(s, 100), - TruthValue::new(0.9, 0.9), - Stamp::source(s.into()), - ); + arena + .observe(inh(s, 100), TruthValue::new(0.9, 0.9), SourceId(s.into())) + .unwrap(); } let e = elevate_field(&mut arena, 3); let g = e.minted_parents[0]; assert_eq!(g, 101); // A single NEW fact about the abstraction, never about any child. - arena.observe(inh(g, 200), TruthValue::new(0.9, 0.9), Stamp::source(9)); + arena + .observe(inh(g, 200), TruthValue::new(0.9, 0.9), SourceId(9)) + .unwrap(); arena.close_transitive(64); // Without the minted parent, this one `g is_a 200` fact could not @@ -280,8 +277,12 @@ mod tests { #[test] fn min_cluster_threshold_holds() { let mut arena = BeliefArena::new(); - arena.observe(inh(10, 100), TruthValue::new(0.9, 0.9), Stamp::source(0)); - arena.observe(inh(11, 100), TruthValue::new(0.9, 0.9), Stamp::source(1)); + arena + .observe(inh(10, 100), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); + arena + .observe(inh(11, 100), TruthValue::new(0.9, 0.9), SourceId(1)) + .unwrap(); let e3 = elevate_field(&mut arena, 3); assert_eq!(e3.clusters_lifted, 0, "2 subjects < min_cluster 3"); @@ -297,11 +298,9 @@ mod tests { fn elevate_stabilizes_across_repeated_calls() { let mut arena = BeliefArena::new(); for s in 10u16..15 { - arena.observe( - inh(s, 100), - TruthValue::new(0.9, 0.9), - Stamp::source(s.into()), - ); + arena + .observe(inh(s, 100), TruthValue::new(0.9, 0.9), SourceId(s.into())) + .unwrap(); } let first = elevate_field(&mut arena, 3); diff --git a/crates/lance-graph-planner/src/nars/epiphany.rs b/crates/lance-graph-planner/src/nars/epiphany.rs index 0f8e5d9f8..b74e28e65 100644 --- a/crates/lance-graph-planner/src/nars/epiphany.rs +++ b/crates/lance-graph-planner/src/nars/epiphany.rs @@ -76,7 +76,8 @@ pub fn rank_epiphany_attractors( #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; + use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; + use lance_graph_contract::source_registry::SourceId; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -95,11 +96,8 @@ mod tests { // LARGE sparse basin, subject 1: 20 observed + 5 derived. // epiphanies 5, attempts 25, rate 0.20. for p in 100..120u16 { - a.observe( - inh(1, p), - TruthValue::new(0.9, 0.9), - Stamp::source(p as u32), - ); + a.observe(inh(1, p), TruthValue::new(0.9, 0.9), SourceId(p as u64)) + .unwrap(); } for p in 200..205u16 { assert!(a.admit_derived(inh(1, p), TruthValue::new(0.9, 0.9), &[], 1)); @@ -107,7 +105,8 @@ mod tests { // SMALL dense basin, subject 2: 1 observed + 3 derived. // epiphanies 3, attempts 4, rate 0.75. - a.observe(inh(2, 300), TruthValue::new(0.9, 0.9), Stamp::source(300)); + a.observe(inh(2, 300), TruthValue::new(0.9, 0.9), SourceId(300)) + .unwrap(); for p in 301..304u16 { assert!(a.admit_derived(inh(2, p), TruthValue::new(0.9, 0.9), &[], 1)); } @@ -144,12 +143,15 @@ mod tests { let mut a = BeliefArena::default(); // Subject 1: 3 beliefs (clears min_attempts = 2). - a.observe(inh(1, 10), TruthValue::new(0.9, 0.9), Stamp::source(10)); - a.observe(inh(1, 11), TruthValue::new(0.9, 0.9), Stamp::source(11)); + a.observe(inh(1, 10), TruthValue::new(0.9, 0.9), SourceId(10)) + .unwrap(); + a.observe(inh(1, 11), TruthValue::new(0.9, 0.9), SourceId(11)) + .unwrap(); assert!(a.admit_derived(inh(1, 12), TruthValue::new(0.9, 0.9), &[], 1)); // Subject 2: 1 belief (below min_attempts = 2). - a.observe(inh(2, 20), TruthValue::new(0.9, 0.9), Stamp::source(20)); + a.observe(inh(2, 20), TruthValue::new(0.9, 0.9), SourceId(20)) + .unwrap(); let ranked = rank_epiphany_attractors(&a, 2); assert_eq!(ranked.len(), 1); @@ -161,11 +163,13 @@ mod tests { let mut a = BeliefArena::default(); // Subject 5: 1 derived / 2 total = rate 0.5. - a.observe(inh(5, 50), TruthValue::new(0.9, 0.9), Stamp::source(50)); + a.observe(inh(5, 50), TruthValue::new(0.9, 0.9), SourceId(50)) + .unwrap(); assert!(a.admit_derived(inh(5, 51), TruthValue::new(0.9, 0.9), &[], 1)); // Subject 3: 1 derived / 2 total = rate 0.5 (same rate). - a.observe(inh(3, 30), TruthValue::new(0.9, 0.9), Stamp::source(30)); + a.observe(inh(3, 30), TruthValue::new(0.9, 0.9), SourceId(30)) + .unwrap(); assert!(a.admit_derived(inh(3, 31), TruthValue::new(0.9, 0.9), &[], 1)); let ranked = rank_epiphany_attractors(&a, 2); diff --git a/crates/lance-graph-planner/src/nars/insight.rs b/crates/lance-graph-planner/src/nars/insight.rs index cd24950fc..b0aa36585 100644 --- a/crates/lance-graph-planner/src/nars/insight.rs +++ b/crates/lance-graph-planner/src/nars/insight.rs @@ -274,7 +274,8 @@ pub fn flow_state(im: &InsightMush) -> FlowState { #[cfg(test)] mod tests { use super::*; - use crate::nars::{CStmt, Copula, Stamp, TruthValue}; + use crate::nars::{CStmt, Copula, TruthValue}; + use lance_graph_contract::source_registry::SourceId; /// A before→after step is two readings of the SAME arena under the SAME /// lens at different versions. Can-fire. @@ -338,11 +339,9 @@ mod tests { fn score_step(edges: &[(u16, u16)]) -> InsightMush { let mut arena = BeliefArena::new(); for (i, &(s, p)) in edges.iter().enumerate() { - arena.observe( - inh(s, p), - TruthValue::new(0.95, 0.9), - Stamp::source(i as u32), - ); + arena + .observe(inh(s, p), TruthValue::new(0.95, 0.9), SourceId(i as u64)) + .unwrap(); } let before = Snapshot::of(&arena, 0.0); arena.close_transitive(64); @@ -405,8 +404,12 @@ mod tests { #[test] fn signals_reuse_contract_carrier() { let mut arena = BeliefArena::new(); - arena.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); - arena.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(1)); + arena + .observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); + arena + .observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(1)) + .unwrap(); arena.close_transitive(8); let sig: GraphSignals = arena_graph_signals(&arena, 0.0); // 3 statements, 1 derived (1→3) → yield = 1/3. diff --git a/crates/lance-graph-planner/src/nars/insights.rs b/crates/lance-graph-planner/src/nars/insights.rs index 8c5e0c5ee..252b0fbe2 100644 --- a/crates/lance-graph-planner/src/nars/insights.rs +++ b/crates/lance-graph-planner/src/nars/insights.rs @@ -187,7 +187,8 @@ fn inh(s: u16, p: u16) -> CStmt { #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; + use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; + use lance_graph_contract::source_registry::SourceId; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -202,12 +203,18 @@ mod tests { /// `close_transitive` derives into `1→3`, `1→4`, `2→4`. fn structured_kg() -> BeliefArena { let mut a = BeliefArena::new(); - a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), Stamp::source(0)); - a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), Stamp::source(1)); - a.observe(inh(3, 100), TruthValue::new(0.9, 0.9), Stamp::source(2)); - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(3)); - a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(4)); - a.observe(inh(3, 4), TruthValue::new(0.9, 0.9), Stamp::source(5)); + a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); + a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), SourceId(1)) + .unwrap(); + a.observe(inh(3, 100), TruthValue::new(0.9, 0.9), SourceId(2)) + .unwrap(); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(3)) + .unwrap(); + a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(4)) + .unwrap(); + a.observe(inh(3, 4), TruthValue::new(0.9, 0.9), SourceId(5)) + .unwrap(); a.close_transitive(64); a } @@ -253,11 +260,9 @@ mod tests { (14, 114), (15, 115), ] { - noise.observe( - inh(s, p), - TruthValue::new(0.9, 0.9), - Stamp::source(s as u32), - ); + noise + .observe(inh(s, p), TruthValue::new(0.9, 0.9), SourceId(s as u64)) + .unwrap(); } noise.close_transitive(64); @@ -330,11 +335,15 @@ mod tests { // 2 subjects share predicate 100; only 1 predicate in the KG — the prior // `d/total_preds` bug would have scored this bridge 2.0. let mut a = BeliefArena::new(); - a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), Stamp::source(0)); - a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), Stamp::source(1)); + a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); + a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), SourceId(1)) + .unwrap(); // A derived conclusion with a real ladder (so a Conclusion exists to compare). - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(2)); - a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(3)); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(2)) + .unwrap(); + a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(3)) + .unwrap(); a.close_transitive(64); let insights = extract_main_insights(&a, &InsightConfig::default()); @@ -354,8 +363,10 @@ mod tests { fn self_conclusion_is_not_surfaced() { // A 2-cycle 1→2, 2→1 closes into self-loops 1→1 and 2→2. let mut a = BeliefArena::new(); - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); - a.observe(inh(2, 1), TruthValue::new(0.9, 0.9), Stamp::source(1)); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); + a.observe(inh(2, 1), TruthValue::new(0.9, 0.9), SourceId(1)) + .unwrap(); a.close_transitive(64); assert!( a.get(inh(1, 1)).is_some(), diff --git a/crates/lance-graph-planner/src/nars/reach_out.rs b/crates/lance-graph-planner/src/nars/reach_out.rs index 53481ba67..2a520bc95 100644 --- a/crates/lance-graph-planner/src/nars/reach_out.rs +++ b/crates/lance-graph-planner/src/nars/reach_out.rs @@ -18,7 +18,7 @@ //! expansion rising, [`super::insight::Snapshot`]). This module measures it //! by composing the two shipped pieces — no new detector, no new engine. -use super::belief::{BeliefArena, CStmt, Stamp}; +use super::belief::{BeliefArena, CStmt, SourceId}; use super::insight::Snapshot; use super::truth::TruthValue; @@ -76,7 +76,7 @@ pub fn reach_out_integrate( arena: &mut BeliefArena, bridge: CStmt, truth: TruthValue, - stamp: Stamp, + source: SourceId, cfg: &ReachOutConfig, ) -> FeltOutcome { // Establish the baseline at a fixed point FIRST — otherwise a pending @@ -87,7 +87,10 @@ pub fn reach_out_integrate( let derived_before = arena.entries().iter().filter(|b| b.rung >= 1).count(); let quarantined = TruthValue::new(truth.frequency, truth.confidence.min(cfg.quarantine_prior)); - arena.observe(bridge, quarantined, stamp); + // A full registry means the bridge's evidence cannot be admitted + // independently, so nothing lands and the reach-out simply does not fire — + // reported by the derivation count below, never silently pooled. + let _ = arena.observe(bridge, quarantined, source); arena.close_transitive(cfg.max_passes); let after = Snapshot::of(arena, 0.0); @@ -107,7 +110,7 @@ pub fn reach_out_integrate( #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; + use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -125,13 +128,14 @@ mod tests { fn middle_term_click_is_new_insight() { let mut a = BeliefArena::new(); // A is_a M (pre-existing, rung 0). - a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), Stamp::source(0)); + a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); let out = reach_out_integrate( &mut a, inh(9, 2), // M is_a B — the fetched bridge. TruthValue::new(0.9, 0.9), - Stamp::source(1), // disjoint from source 0. + SourceId(1), // disjoint from source 0. &ReachOutConfig::default(), ); @@ -159,13 +163,14 @@ mod tests { #[test] fn lone_concept_is_dull_shadow() { let mut a = BeliefArena::new(); - a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), Stamp::source(0)); + a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); let out = reach_out_integrate( &mut a, inh(100, 101), // fresh, unconnected terms. TruthValue::new(0.9, 0.9), - Stamp::source(1), + SourceId(1), &ReachOutConfig::default(), ); @@ -183,24 +188,28 @@ mod tests { #[test] fn shadow_and_insight_are_size_matched() { let mut insight_arena = BeliefArena::new(); - insight_arena.observe(inh(1, 9), TruthValue::new(0.9, 0.9), Stamp::source(0)); + insight_arena + .observe(inh(1, 9), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); // Exactly one bridge belief added: composes as a middle term. let insight_out = reach_out_integrate( &mut insight_arena, inh(9, 2), TruthValue::new(0.9, 0.9), - Stamp::source(1), + SourceId(1), &ReachOutConfig::default(), ); let mut shadow_arena = BeliefArena::new(); - shadow_arena.observe(inh(1, 9), TruthValue::new(0.9, 0.9), Stamp::source(0)); + shadow_arena + .observe(inh(1, 9), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); // Exactly one bridge belief added: composes with nothing. let shadow_out = reach_out_integrate( &mut shadow_arena, inh(100, 101), TruthValue::new(0.9, 0.9), - Stamp::source(1), + SourceId(1), &ReachOutConfig::default(), ); @@ -214,7 +223,8 @@ mod tests { #[test] fn quarantine_caps_confidence() { let mut a = BeliefArena::new(); - a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), Stamp::source(0)); + a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); let bridge = inh(9, 2); let cfg = ReachOutConfig { @@ -225,7 +235,7 @@ mod tests { &mut a, bridge, TruthValue::new(0.9, 0.95), // high offered confidence. - Stamp::source(1), + SourceId(1), &cfg, ); @@ -246,15 +256,17 @@ mod tests { fn pending_closure_is_not_miscredited_to_an_unrelated_bridge() { let mut a = BeliefArena::new(); // An OPEN chain: A→B, B→C, deliberately NOT closed by the caller. - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); - a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(1)); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); + a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(1)) + .unwrap(); // (no close_transitive here — the arena is passed in un-closed) let out = reach_out_integrate( &mut a, inh(100, 101), // fresh, unrelated to the pending chain. TruthValue::new(0.9, 0.9), - Stamp::source(2), + SourceId(2), &ReachOutConfig::default(), ); diff --git a/crates/lance-graph-planner/src/nars/regulate.rs b/crates/lance-graph-planner/src/nars/regulate.rs index 0790a30c6..8bdc1ed21 100644 --- a/crates/lance-graph-planner/src/nars/regulate.rs +++ b/crates/lance-graph-planner/src/nars/regulate.rs @@ -92,7 +92,8 @@ pub fn regulate_cycle( #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; + use crate::nars::belief::SourceId; + use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -109,8 +110,9 @@ mod tests { a.observe( inh(i, i + 1), TruthValue::new(0.95, 0.9), - Stamp::source(i.into()), - ); + SourceId(i.into()), + ) + .unwrap(); } a.close_transitive(64); a @@ -126,11 +128,8 @@ mod tests { // 5 subjects share predicate 900 — disjoint from the core, non-composing. for s in 200u16..205 { - a.observe( - inh(s, 900), - TruthValue::new(0.9, 0.9), - Stamp::source(s.into()), - ); + a.observe(inh(s, 900), TruthValue::new(0.9, 0.9), SourceId(s.into())) + .unwrap(); } let out = regulate_cycle(&mut a, &before, &CycleConfig::default()); @@ -160,8 +159,9 @@ mod tests { a.observe( inh(i, i + 1), TruthValue::new(0.95, 0.9), - Stamp::source((100 + i).into()), - ); + SourceId((100 + i).into()), + ) + .unwrap(); } let out = regulate_cycle(&mut a, &before, &CycleConfig::default()); @@ -184,11 +184,8 @@ mod tests { let mut a = core_arena(); let before = Snapshot::of(&a, 0.0); for s in 200u16..205 { - a.observe( - inh(s, 900), - TruthValue::new(0.9, 0.9), - Stamp::source(s.into()), - ); + a.observe(inh(s, 900), TruthValue::new(0.9, 0.9), SourceId(s.into())) + .unwrap(); } let first = regulate_cycle(&mut a, &before, &CycleConfig::default()); assert!(first.elevated.is_some(), "first cycle elevates"); diff --git a/crates/lance-graph-planner/src/nars/tactics.rs b/crates/lance-graph-planner/src/nars/tactics.rs index 08da88709..6582f7a7e 100644 --- a/crates/lance-graph-planner/src/nars/tactics.rs +++ b/crates/lance-graph-planner/src/nars/tactics.rs @@ -24,7 +24,7 @@ //! | 8 CAS | abstraction | up=induction `{S→P, S→G} ⊢ G→P`; down=deduction `{G→P, S→G} ⊢ S→P` | [`TruthValue::induction`] / [`TruthValue::deduction`] | Datapath | //! | 11 CR | dialectic | same statement, disjoint → revision; overlap → CHOICE | [`TruthValue::revise`] / CHOICE | Control | -use super::belief::{BeliefArena, CStmt, Copula, ReviseOutcome, Stamp}; +use super::belief::{BeliefArena, CStmt, CapacityExceeded, Copula, ReviseOutcome, SourceId}; use super::truth::TruthValue; use std::collections::HashMap; @@ -473,26 +473,31 @@ pub enum AscOutcome { /// **ASC — self-critique** (recipe #7, Control bucket). Forms the belief's /// refutation target `⟨1−f, c⟩` and admits offered counter-evidence ONLY when it -/// is independently sourced (its stamp is DISJOINT from the belief's) — then -/// revises it in. Counter-evidence overlapping the belief's own sources is -/// BLOCKED (no self-refutation from one's own evidence). +/// is independently sourced (the belief does NOT already carry that source) — +/// then revises it in. Counter-evidence from a source the belief already counts +/// is BLOCKED (no self-refutation from one's own evidence). pub fn asc_challenge( arena: &mut BeliefArena, target: CStmt, counter: TruthValue, - counter_stamp: Stamp, + counter_source: SourceId, ) -> AscOutcome { - let Some(belief) = arena.get(target) else { + if arena.get(target).is_none() { return AscOutcome::NoTarget; - }; - if !belief.stamp.disjoint(counter_stamp) { + } + // The self-reference guard, asked as a QUERY rather than by constructing a + // stamp to compare against: a challenge that reuses evidence the target + // already carries is not independent counter-evidence. + if arena.stmt_has_source(target, counter_source) { return AscOutcome::BlockedSelfReference; } - match arena.observe(target, counter, counter_stamp) { - ReviseOutcome::Revised { + match arena.observe(target, counter, counter_source) { + Ok(ReviseOutcome::Revised { synthesis_c, depth, .. - } => AscOutcome::Revised { synthesis_c, depth }, - _ => AscOutcome::BlockedSelfReference, + }) => AscOutcome::Revised { synthesis_c, depth }, + // A full registry cannot admit independent counter-evidence, so the + // challenge does not land — reported as blocked, never silently pooled. + Ok(_) | Err(_) => AscOutcome::BlockedSelfReference, } } @@ -504,9 +509,9 @@ pub fn cr_synthesize( arena: &mut BeliefArena, stmt: CStmt, truth: TruthValue, - stamp: Stamp, -) -> ReviseOutcome { - arena.observe(stmt, truth, stamp) + source: SourceId, +) -> Result { + arena.observe(stmt, truth, source) } #[cfg(test)] @@ -544,8 +549,12 @@ mod tests { #[test] fn rcr_abduces_shared_predicate_with_weak_truth() { let mut arena = BeliefArena::new(); - arena.observe(inh(1, 9), TruthValue::new(0.9, 0.8), Stamp::source(0)); // A→M - arena.observe(inh(2, 9), TruthValue::new(0.8, 0.7), Stamp::source(1)); // B→M + arena + .observe(inh(1, 9), TruthValue::new(0.9, 0.8), SourceId(0)) + .unwrap(); // A→M + arena + .observe(inh(2, 9), TruthValue::new(0.8, 0.7), SourceId(1)) + .unwrap(); // B→M let fr = rcr_abduce(&arena, &Throttle::permissive()); assert_eq!(fr.candidates.len(), 2, "both directions abduced"); let b_to_a = fr @@ -566,19 +575,21 @@ mod tests { fn rcr_excludes_hub_and_reports_gaps() { let mut arena = BeliefArena::new(); for s in 1..=5u16 { - arena.observe( - inh(s, 9), - TruthValue::new(0.9, 0.8), - Stamp::source(s as u32), - ); + arena + .observe(inh(s, 9), TruthValue::new(0.9, 0.8), SourceId(s as u64)) + .unwrap(); } let fr = rcr_abduce(&arena, &Throttle::new(0.0, usize::MAX, 4)); assert!(fr.candidates.is_empty(), "no candidates through a hub"); assert!(fr.gaps.iter().any(|g| g.kind == GapKind::HubExcluded)); // No shared middle at all → NoSharedMiddle. let mut disjoint = BeliefArena::new(); - disjoint.observe(inh(1, 8), TruthValue::new(0.9, 0.8), Stamp::source(0)); - disjoint.observe(inh(2, 9), TruthValue::new(0.9, 0.8), Stamp::source(1)); + disjoint + .observe(inh(1, 8), TruthValue::new(0.9, 0.8), SourceId(0)) + .unwrap(); + disjoint + .observe(inh(2, 9), TruthValue::new(0.9, 0.8), SourceId(1)) + .unwrap(); assert!(rcr_abduce(&disjoint, &Throttle::permissive()) .gaps .iter() @@ -590,11 +601,9 @@ mod tests { fn rcr_floor_and_budget() { let mut arena = BeliefArena::new(); for s in 1..=4u16 { - arena.observe( - inh(s, 9), - TruthValue::new(0.9, 0.9), - Stamp::source(s as u32), - ); + arena + .observe(inh(s, 9), TruthValue::new(0.9, 0.9), SourceId(s as u64)) + .unwrap(); } assert_eq!( rcr_abduce(&arena, &Throttle::new(0.0, usize::MAX, usize::MAX)) @@ -627,8 +636,12 @@ mod tests { #[test] fn tr_analogy_substitutes_sibling() { let mut arena = BeliefArena::new(); - arena.observe(inh(1, 3), TruthValue::new(0.9, 0.8), Stamp::source(0)); // dog→mammal - arena.observe(sim(1, 2), TruthValue::new(0.7, 0.6), Stamp::source(1)); // dog↔wolf + arena + .observe(inh(1, 3), TruthValue::new(0.9, 0.8), SourceId(0)) + .unwrap(); // dog→mammal + arena + .observe(sim(1, 2), TruthValue::new(0.7, 0.6), SourceId(1)) + .unwrap(); // dog↔wolf let fr = tr_diverge(&arena, inh(1, 3)); assert_eq!(fr.candidates.len(), 1); let c = fr.candidates[0]; @@ -645,9 +658,15 @@ mod tests { #[test] fn cas_up_induction_down_deduction() { let mut arena = BeliefArena::new(); - arena.observe(inh(1, 2), TruthValue::new(0.95, 0.9), Stamp::source(0)); // S→G - arena.observe(inh(1, 3), TruthValue::new(0.9, 0.85), Stamp::source(1)); // S→P - arena.observe(inh(2, 4), TruthValue::new(0.9, 0.85), Stamp::source(2)); // G→Q + arena + .observe(inh(1, 2), TruthValue::new(0.95, 0.9), SourceId(0)) + .unwrap(); // S→G + arena + .observe(inh(1, 3), TruthValue::new(0.9, 0.85), SourceId(1)) + .unwrap(); // S→P + arena + .observe(inh(2, 4), TruthValue::new(0.9, 0.85), SourceId(2)) + .unwrap(); // G→Q let fr = cas_abstract(&arena, 1, &Throttle::permissive()); let up = fr .candidates @@ -688,14 +707,14 @@ mod tests { #[test] fn cas_budget_bounds_a_high_fanout_subject() { let mut arena = BeliefArena::new(); - arena.observe(inh(1, 2), TruthValue::new(0.95, 0.9), Stamp::source(0)); // S→G parent + arena + .observe(inh(1, 2), TruthValue::new(0.95, 0.9), SourceId(0)) + .unwrap(); // S→G parent for p in 10..40u16 { // 30 properties of S → up mints ~30 G→P candidates uncapped. - arena.observe( - inh(1, p), - TruthValue::new(0.9, 0.85), - Stamp::source(p as u32), - ); + arena + .observe(inh(1, p), TruthValue::new(0.9, 0.85), SourceId(p as u64)) + .unwrap(); } let full = cas_abstract(&arena, 1, &Throttle::permissive()); assert!( @@ -711,15 +730,14 @@ mod tests { .any(|g| g.kind == GapKind::BudgetExhausted)); // A hub parent (many inheritors) is barred from the down fan-out. let mut hub = BeliefArena::new(); - hub.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); // S→G + hub.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) + .unwrap(); // S→G for s in 10..30u16 { - hub.observe( - inh(s, 2), - TruthValue::new(0.9, 0.9), - Stamp::source(s as u32), - ); // many ?→G + hub.observe(inh(s, 2), TruthValue::new(0.9, 0.9), SourceId(s as u64)) + .unwrap(); // many ?→G } - hub.observe(inh(2, 99), TruthValue::new(0.9, 0.9), Stamp::source(99)); // G→P + hub.observe(inh(2, 99), TruthValue::new(0.9, 0.9), SourceId(99)) + .unwrap(); // G→P let barred = cas_abstract(&hub, 1, &Throttle::new(0.0, usize::MAX, 4)); assert!(barred.gaps.iter().any(|g| g.kind == GapKind::HubExcluded)); } @@ -730,24 +748,16 @@ mod tests { fn asc_independent_revises_overlap_blocks() { let mut arena = BeliefArena::new(); let stmt = inh(1, 2); - arena.observe(stmt, TruthValue::new(0.9, 0.8), Stamp::source(3)); + arena + .observe(stmt, TruthValue::new(0.9, 0.8), SourceId(3)) + .unwrap(); assert!((challenge_target(arena.get(stmt).unwrap().truth).frequency - 0.1).abs() < 1e-6); assert_eq!( - asc_challenge( - &mut arena, - stmt, - TruthValue::new(0.1, 0.7), - Stamp::source(3) - ), + asc_challenge(&mut arena, stmt, TruthValue::new(0.1, 0.7), SourceId(3)), AscOutcome::BlockedSelfReference ); assert!((arena.get(stmt).unwrap().truth.frequency - 0.9).abs() < 1e-6); - let out = asc_challenge( - &mut arena, - stmt, - TruthValue::new(0.1, 0.7), - Stamp::source(40), - ); + let out = asc_challenge(&mut arena, stmt, TruthValue::new(0.1, 0.7), SourceId(40)); assert!(matches!(out, AscOutcome::Revised { .. })); assert!( arena.get(stmt).unwrap().truth.frequency < 0.9, @@ -758,7 +768,7 @@ mod tests { &mut arena, inh(7, 8), TruthValue::new(0.5, 0.5), - Stamp::source(0) + SourceId(0) ), AscOutcome::NoTarget ); @@ -771,20 +781,11 @@ mod tests { let mut arena = BeliefArena::new(); let stmt = inh(1, 2); assert!(matches!( - cr_synthesize( - &mut arena, - stmt, - TruthValue::new(0.9, 0.8), - Stamp::source(0) - ), + cr_synthesize(&mut arena, stmt, TruthValue::new(0.9, 0.8), SourceId(0)).unwrap(), ReviseOutcome::Admitted { .. } )); - let anti = cr_synthesize( - &mut arena, - stmt, - TruthValue::new(0.2, 0.75), - Stamp::source(1), - ); + let anti = + cr_synthesize(&mut arena, stmt, TruthValue::new(0.2, 0.75), SourceId(1)).unwrap(); let ReviseOutcome::Revised { synthesis_c, depth, .. } = anti @@ -802,10 +803,16 @@ mod tests { fn admitted_candidate_respects_ground() { let mut arena = BeliefArena::new(); let stmt = inh(2, 1); - arena.observe(stmt, TruthValue::new(0.55, 0.95), Stamp::source(9)); + arena + .observe(stmt, TruthValue::new(0.55, 0.95), SourceId(9)) + .unwrap(); let before = arena.get(stmt).unwrap().truth; - arena.observe(inh(1, 9), TruthValue::new(0.99, 0.9), Stamp::source(0)); - arena.observe(inh(2, 9), TruthValue::new(0.99, 0.9), Stamp::source(1)); + arena + .observe(inh(1, 9), TruthValue::new(0.99, 0.9), SourceId(0)) + .unwrap(); + arena + .observe(inh(2, 9), TruthValue::new(0.99, 0.9), SourceId(1)) + .unwrap(); let cand = *rcr_abduce(&arena, &Throttle::permissive()) .candidates .iter() From b085048150a5256b536c36735041f31c49f6d853 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 09:57:39 +0000 Subject: [PATCH 08/16] revert source_registry + both arena migrations; fix the independent bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source-registry migration is WITHDRAWN as a falsified design, not shipped. An evidential base requires evidence-EVENT identity; SourceId modelled source membership, which is a different object. Four facts converged: 1. SourceId is not EvidenceEventId. The disjointness guard exists to stop one event being counted twice through different derivation paths; keying it on sources means one sensor observing twice can never raise confidence. 2. No canonical evidence-event identity exists in this substrate. Verified against the types, not the names: ClassId is u16 while the GUID's classid is a u32 composite, ClassView is a late-bound projection trait, AppPrefix::Core is documented "no render lens", and LanceVersion is a dataset snapshot. Two rows of one class in one commit are indistinguishable; re-observing an unchanged value mutates nothing; one observation can span many rows. The identity belongs to an immutable receipt that does not exist yet. NodeGuid cannot stand in — its own debug_assert message admits identities are reusable, and it is a debug assertion only. 3. A fixed-width digest is safe but useless here. Measured, 20k trials/cell, on genuinely disjoint bases: P(false overlap) = n^2/m at k=1 — 63.4% at n=8 m=64, still 22.2% at m=256 — and k>1 is catastrophic (98% at k=2) even though raising k improves membership queries. Never false-disjoint, so safe; starves revision two times in three, so useless. I had recommended it on the safety half alone. 4. bool disjoint() cannot express the distinction. "Not known to overlap" is not "known disjoint"; both membership and dependence need tri-state. Reverted: source_registry.rs and its exports, both BeliefArena migrations, the Result<_, CapacityExceeded> propagation, and every caller/example change made only for that API. This removes the operational failures both reviewers found — examples panicking past 64 sources, swallowed capacity errors, counter-evidence mislabelled as self-reference, cross-registry comparison. Those were smoke from the wrong furnace, not isolated bugs. CodeRabbit's prescribed fix — reuse a bounded SourceId — was NOT taken. Minting one identity per distinct observation is correct; the ceiling is the defect. Bounding it would have turned the symptoms green while making the semantic error permanent. Restoring the old local Stamp is a rollback to baseline, NOT an endorsement: it still models source membership. It is kept only because it introduces no breaking API and no global ceiling while the correct model is designed. Independent bugs fixed (kept work): - causal_audit: SourceId renamed EvidenceSourceId, now standalone. profile() summed strength across repeated receipts from one source, so three repetitions read as three independent attestations — contradicting the guarantee stated in the module's own doc (codex). Replaced with per-basis BasisProfile { receipt_count, distinct_source_count, total_strength, independent_strength: Option }. total_strength is documented as a volume reading and the test now ASSERTS it cannot distinguish the two cases, so the limitation is behaviour rather than a latent surprise. independent_strength is always None: establishing it needs a dependence model this substrate does not have, and None means "not established", never zero. Also one pass instead of nine rescans. - counterfactual: SubstitutedWorld recorded only the LAST substitution while state reflected all of them, and fabricated an all-zero record for the empty case, making "nothing substituted" indistinguishable from "substituted with zeros". Now carries the complete applied sequence. A module arguing the algebra should say honestly what it did cannot have a field that doesn't. - recipe_kernels: changed_fields hand-rolled 1 << (f as u8) instead of ThoughtMask::of — bypassing the canonical constructor added in the same commit. - scheduler_seam assertions and the soa-three-tier diagram's KanbanMove field list brought in line with the derived-window API. Kept and unaffected: settlement, Tactic::{writes, maturity} + the effect census, the Libet derivation, VersionedSnapshot, the counterfactual rename. The gain that outlives the code: event identity != evidential-base membership != source dependence != object/view identity != dataset version. contract 1093, planner 319, deepnsm-v2 96, cognitive world 5 — all green; --all-targets clean, fmt clean. Co-Authored-By: Claude --- .claude/board/EPIPHANIES.md | 29 +- .claude/board/LATEST_STATE.md | 25 +- crates/deepnsm-v2/src/belief.rs | 205 ++++----- .../src/world/counterfactual.rs | 24 +- .../lance-graph-contract/src/causal_audit.rs | 122 ++++-- crates/lance-graph-contract/src/lib.rs | 1 - .../src/recipe_kernels.rs | 11 +- .../src/source_registry.rs | 391 ------------------ .../examples/gestalt_texture_smoke.rs | 19 +- .../examples/insight_archetype_read.rs | 23 +- .../examples/insight_overlap_smoke.rs | 19 +- .../examples/insight_read.rs | 7 +- .../examples/insight_relation_read.rs | 43 +- .../examples/reason_whole_book.rs | 15 +- .../examples/tactic_select_confusion.rs | 34 +- .../src/nars/basin_resonance.rs | 66 ++- crates/lance-graph-planner/src/nars/belief.rs | 188 +++------ .../src/nars/dissolution.rs | 31 +- .../lance-graph-planner/src/nars/elevation.rs | 47 +-- .../lance-graph-planner/src/nars/epiphany.rs | 28 +- .../lance-graph-planner/src/nars/insight.rs | 19 +- .../lance-graph-planner/src/nars/insights.rs | 47 +-- .../lance-graph-planner/src/nars/reach_out.rs | 46 +-- .../lance-graph-planner/src/nars/regulate.rs | 27 +- .../lance-graph-planner/src/nars/tactics.rs | 159 ++++--- .../surreal_container/tests/scheduler_seam.rs | 4 +- docs/architecture/soa-three-tier-model.md | 2 +- 27 files changed, 558 insertions(+), 1074 deletions(-) delete mode 100644 crates/lance-graph-contract/src/source_registry.rs diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 6cd9b8db9..278990de7 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,21 +1,28 @@ -## 2026-07-27 — E-THE-TWO-COPIES-HAD-ALREADY-DRIFTED-1 — **the "migrate both together" rule was justified by a hypothetical asymmetry; the migration found a REAL one.** +## 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:** FINDING (measured while migrating, not predicted). **Confidence:** High — the divergence is two lines of source. +**Status:** RULING (operator, 2026-07-27) + measurement. **Confidence:** High — every leg was verified in source or measured, not inferred. -The argument for moving `lance-graph-planner::nars::belief` and `deepnsm-v2::belief` onto the canonical registry in ONE change was that fixing one alone would leave "two incompatible independence semantics wearing matching comments." That was reasoning about a risk. Doing the work showed the copies had **already diverged**: +**Five objects, previously one carrier:** +``` +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. -- **Planner:** `if stamp != Stamp::default() && b.stamp.disjoint(stamp)` — with a doc-comment calling the empty-stamp guard *load-bearing*: the no-source sentinel is disjoint from EVERY stamp, so treating it as independent evidence lets a repeated unsourced observation pool into itself and inflate confidence without bound. -- **deepnsm-v2:** `if b.stamp.disjoint(stamp)` — no guard. The same repeated unsourced observation pools. +**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. -Same struct name, same `source(id) = 1 << (id % 64)`, same S4 doc-bullet, same "CONSERVATIVE folding" comment — and a different answer to *when may evidence pool*. Neither file was wrong about itself; the pair was wrong about each other, and nothing could have noticed, because nothing compared them. +**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.** -**Two lessons, and the second is the load-bearing one:** -1. Duplicated types do not merely risk drifting. On a long enough timeline they HAVE drifted, and the copy you are not currently reading is the one that surprises you. -2. **A shared doc-comment is negative evidence of agreement.** Both copies carried the same explanatory prose about conservative folding, which is exactly why the behavioural difference stayed invisible — the prose was the thing being kept in sync, so it read as though the code was. This is `E-VACUOUS-ASSERTION-IS-THE-HOUSE-STYLE-1` in a new place: matching narration standing in for verified sameness. +**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. -Aligned to the planner's (correct) behaviour, with the divergence documented at the site rather than quietly harmonized. A `Default`-derived `Stamp` means `Stamp::default()` still compiled after the migration, so the guard's absence would NOT have surfaced as a compile error — it was found by reading, which is the only thing that could have found it. +**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-UNCONTESTED-AXIS-IS-THE-ONE-THAT-MERGES-1` (nobody had contested whether the two copies agreed). +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.** diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index c628a79e8..10f1d3e8e 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,19 +1,24 @@ -## 2026-07-27 — branch `claude/medcare-rs-transcode-ruff-3y2olh` — causality-audit C1 part 2: BOTH BeliefArenas migrated onto the canonical registry (and the drift it exposed) +## 2026-07-27 — branch `claude/medcare-rs-transcode-ruff-3y2olh` — **C1 WITHDRAWN (falsified design)** + D1 shipped: the four-signal settlement field -### Planner + deepnsm-v2 — the two `Stamp(pub u64)` copies retired -- `lance_graph_planner::nars::belief` and `deepnsm_v2::belief` both drop their local `Stamp` and re-export `contract::source_registry::{SourceId, Stamp, CapacityExceeded}`. Each `BeliefArena` now owns a PRIVATE `SourceRegistry`; `observe`/`revise_at` take a `SourceId` and return `Result` — **capacity exhaustion is reported, never folded**, the behavioural difference from `1 << (id % 64)`. A private `revise_at_stamp` is the shared core so one logical event mints its slot exactly once. -- **Containment held at the call sites, not just the type.** `Stamp` is opaque (private bits), so `asc_challenge`'s self-reference guard could no longer construct a stamp to compare against. Rather than leak a mint, the arena gained `stmt_has_source(stmt, source) -> bool` — a QUERY that never allocates a slot (an unregistered source cannot be present), so no `Stamp` crosses the boundary to answer it. `cr_synthesize` and `reach_out_integrate` take `SourceId`. -- **The migration exposed real drift between the two "identical" copies.** The planner's `revise_at` guards `!stamp.is_empty()` before pooling — its doc calls this load-bearing, because the no-source sentinel is disjoint from EVERY stamp, so treating it as independent evidence lets a repeated unsourced observation pool into itself and inflate confidence without bound. **deepnsm-v2's copy had no such guard.** Two implementations with genuinely different independence semantics, wearing matching comments — precisely the silent asymmetry that made migrating them together non-negotiable rather than tidy. Aligned to the planner's (correct) behaviour, documented in-source at the divergence. +### ⊘ `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. -Gate: deepnsm-v2 96 green; planner lib green (examples in progress at time of writing). Detail: `EPIPHANIES` `E-A-LOCAL-BITSET-IS-NOT-SELF-DESCRIBING-PROVENANCE-1`. +**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. -## 2026-07-27 — branch `claude/medcare-rs-transcode-ruff-3y2olh` — causality-audit C1 + D1: canonical source registry + the four-signal settlement field +**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. -### Current Contract Inventory — two new modules -- `lance_graph_contract::source_registry::{SourceId, SourceSlot, SourceRegistry, Stamp, CapacityExceeded}` — **ends the two-copy `Stamp(pub u64)` / `1 << (id % 64)` duplication at the canonical location.** Record corrected: the folding is CONSERVATIVE (collision ⇒ false overlap ⇒ revision refuses to pool ⇒ evidence lost, never independence fabricated); both copies said so and the earlier audit claim was wrong. What folding destroys is pooling past 64, leave-one-out, withdrawal, and evidence-count interpretation. Rule: **an external id is never a bit position**; the bound is on SIMULTANEOUS identities, so `SourceId(50_000)` legitimately takes slot 0 if it registers first, and exhaustion is a reported `CapacityExceeded`. **Stamps are arena-local by containment** — private bits, no `Serialize`, constructible only from a registry-issued `SourceSlot`. Registry-bearing stamps were REJECTED: they exist to make arena A's evidence meaningful in arena B, which is the inter-container handoff #477 deleted at the mailbox layer, one layer down. Flip condition named in-source (persisted replay ⇒ a frozen source census keyed by version, still not a per-stamp registry field). 8 tests. +**Rollback ≠ endorsement.** The restored local `Stamp` still models source membership. It is the pre-PR baseline, kept only because it introduces no breaking API and no global ceiling while the correct model is designed. + +**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 1101 green, fmt clean, no new warnings. **Deliberately deferred, not forgotten:** migrating `lance-graph-planner`'s and `deepnsm-v2`'s `BeliefArena`s onto the canonical registry. They move TOGETHER — fixing one leaves two incompatible independence semantics wearing matching comments, which is worse than fixing neither because the asymmetry is silent. +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 diff --git a/crates/deepnsm-v2/src/belief.rs b/crates/deepnsm-v2/src/belief.rs index cef55cfd7..ba62007fc 100644 --- a/crates/deepnsm-v2/src/belief.rs +++ b/crates/deepnsm-v2/src/belief.rs @@ -23,22 +23,32 @@ //! by shared terms; truth moves only by truth functions. No fingerprints. use lance_graph_contract::exploration::NarsTruth; -use lance_graph_contract::source_registry::SourceRegistry; use std::collections::HashMap; -/// Evidential provenance types — CANONICAL in -/// `lance_graph_contract::source_registry`, re-exported here. -/// -/// This module and `lance-graph-planner::nars::belief` each used to define -/// their OWN `Stamp(pub u64)` with `source(id) = 1 << (id % 64)`: two copies, -/// two independence semantics, drifting in step. Migrated TOGETHER — fixing one -/// alone would have left a silent asymmetry, which is worse than fixing -/// neither. -/// -/// [`Stamp`] is now opaque: readable, not constructible outside the registry -/// that mints it, because a bitset does not carry the mapping that gives its -/// bits meaning. Stamps are arena-local by containment. -pub use lance_graph_contract::source_registry::{CapacityExceeded, SourceId, Stamp}; +/// Fixed-width evidential stamp: bit *i* = observation source *i* (bounded +/// horizon of 64 sources; sources beyond the horizon fold by modulo, which is +/// CONSERVATIVE — folding can only create false overlap, never false +/// disjointness, so the no-double-count guarantee survives the bound). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Stamp(pub u64); + +impl Stamp { + /// The stamp of a single observation source. + #[must_use] + pub fn source(id: u32) -> Self { + Stamp(1u64 << (id % 64)) + } + /// Two stamps share no evidence. + #[must_use] + pub fn disjoint(self, other: Self) -> bool { + self.0 & other.0 == 0 + } + /// Pooled evidence base. + #[must_use] + pub fn union(self, other: Self) -> Self { + Stamp(self.0 | other.0) + } +} /// The copula of a concept-level statement (S3). `Rel` carries an arbitrary /// relational term (an FSM verb id) — stored, queryable, NEVER transitive. @@ -122,9 +132,6 @@ pub enum ReviseOutcome { pub struct BeliefArena { entries: Vec, index: HashMap, - /// This arena's OWN source registry — the mapping that gives its stamps - /// their meaning. Private, and stamps never leave. - registry: SourceRegistry, /// Closure passes run by the last `close_transitive` call. pub passes: u32, /// Whether the last closure reached a true fixed point. @@ -154,16 +161,8 @@ impl BeliefArena { /// absent; otherwise routes through the S4 guard: disjoint → revision /// in place; overlap → CHOICE. The arena NEVER grows a second entry for an /// existing statement — the termination invariant. - /// Capacity exhaustion is REPORTED ([`CapacityExceeded`]), never folded — - /// the behavioural difference from the retired `id % 64`. - pub fn observe( - &mut self, - stmt: CStmt, - truth: NarsTruth, - source: SourceId, - ) -> Result { - let stamp = self.registry.stamp_for(source)?; - Ok(match self.index.get(&stmt) { + pub fn observe(&mut self, stmt: CStmt, truth: NarsTruth, stamp: Stamp) -> ReviseOutcome { + match self.index.get(&stmt) { None => { let id = self.entries.len() as u32; self.entries.push(Belief { @@ -177,39 +176,17 @@ impl BeliefArena { self.index.insert(stmt, id); ReviseOutcome::Admitted { id } } - Some(&id) => self.revise_at_stamp(id, truth, stamp), - }) + Some(&id) => self.revise_at(id, truth, stamp), + } } /// The S4 revision guard on an existing belief. Disjoint stamps → NARS /// evidence pooling (`NarsTruth::revision`) + stamp union + preserved /// |f₁−f₂| depth, IN PLACE (rung untouched). Overlapping stamps → CHOICE: /// keep the higher-confidence truth, count nothing twice. - /// Capacity exhaustion is REPORTED, never folded (see [`observe`](Self::observe)). - pub fn revise_at( - &mut self, - id: u32, - new: NarsTruth, - source: SourceId, - ) -> Result { - let stamp = self.registry.stamp_for(source)?; - Ok(self.revise_at_stamp(id, new, stamp)) - } - - /// The stamp-taking core, so one logical event mints its slot exactly once. - /// Private: a `Stamp` is only meaningful relative to THIS arena's registry. - /// - /// **The empty-stamp guard was MISSING here** and present in the - /// `lance-graph-planner` twin, which documents it as load-bearing: the - /// no-source sentinel is disjoint from EVERY stamp, so treating it as - /// independent evidence lets a repeated unsourced observation pool into - /// itself and inflate confidence without bound. The two copies had drifted - /// into genuinely different semantics — exactly the asymmetry that made - /// migrating them together non-negotiable. Aligned to the planner's - /// (correct) behaviour: unsourced evidence competes by CHOICE, never pools. - fn revise_at_stamp(&mut self, id: u32, new: NarsTruth, stamp: Stamp) -> ReviseOutcome { + pub fn revise_at(&mut self, id: u32, new: NarsTruth, stamp: Stamp) -> ReviseOutcome { let b = &mut self.entries[id as usize]; - if !stamp.is_empty() && b.stamp.disjoint(stamp) { + if b.stamp.disjoint(stamp) { let depth = (b.truth.frequency - new.frequency).abs(); b.contradiction = b.contradiction.max(depth); b.truth = b.truth.revision(&new); @@ -327,7 +304,7 @@ impl BeliefArena { // stamp is disjoint from every observation, so observing // it always pools). The `rung == 0` guard missed the // latter and would drop the observed evidence. - if !e.stamp.is_empty() { + if e.stamp != Stamp::default() { continue; // observation dominates a derivation } if truth.expectation() > e.truth.expectation() + EPS { @@ -342,7 +319,7 @@ impl BeliefArena { self.entries.push(Belief { stmt, truth, - stamp: Stamp::EMPTY, // derived: no observation sources of its own + stamp: Stamp::default(), // derived: no observation sources of its own rung, premises: premises.to_vec(), contradiction: 0.0, @@ -383,19 +360,17 @@ mod tests { // A 10-concept is_a chain with a cycle at the tail (worst case for // termination) … for k in 0..9u16 { - arena - .observe(inh(k, k + 1), NarsTruth::new(0.9, 0.9), SourceId(k as u64)) - .unwrap(); + arena.observe( + inh(k, k + 1), + NarsTruth::new(0.9, 0.9), + Stamp::source(k as u32), + ); } - arena - .observe(inh(9, 0), NarsTruth::new(0.9, 0.9), SourceId(9)) // cycle - .unwrap(); - // … and the dialectic: thesis vs antithesis on the SAME statement from - // DISJOINT sources (the CR worked example: w₁=9, w₂=3). + arena.observe(inh(9, 0), NarsTruth::new(0.9, 0.9), Stamp::source(9)); // cycle + // … and the dialectic: thesis vs antithesis on the SAME statement from + // DISJOINT sources (the CR worked example: w₁=9, w₂=3). let stmt = inh(0, 1); - let out = arena - .observe(stmt, NarsTruth::new(0.2, 0.75), SourceId(40)) - .unwrap(); + let out = arena.observe(stmt, NarsTruth::new(0.2, 0.75), Stamp::source(40)); let ReviseOutcome::Revised { synthesis_c, depth, .. } = out @@ -432,9 +407,7 @@ mod tests { // Revision after closure STILL terminates arena growth: same statement, // new disjoint source → in-place, no new entry. let n = arena.entries().len(); - arena - .observe(stmt, NarsTruth::new(0.8, 0.5), SourceId(41)) - .unwrap(); + arena.observe(stmt, NarsTruth::new(0.8, 0.5), Stamp::source(41)); assert_eq!(arena.entries().len(), n, "revision never mints an entry"); } @@ -445,14 +418,10 @@ mod tests { fn revision_overlapping_stamp_is_rejected() { let mut arena = BeliefArena::new(); let stmt = inh(1, 2); - arena - .observe(stmt, NarsTruth::new(0.9, 0.8), SourceId(7)) - .unwrap(); + arena.observe(stmt, NarsTruth::new(0.9, 0.8), Stamp::source(7)); let before = arena.get(stmt).unwrap().truth; // Same source (overlap) offering "more of the same evidence". - let out = arena - .observe(stmt, NarsTruth::new(0.9, 0.6), SourceId(7)) - .unwrap(); + let out = arena.observe(stmt, NarsTruth::new(0.9, 0.6), Stamp::source(7)); assert_eq!( out, ReviseOutcome::Chosen { @@ -468,9 +437,7 @@ mod tests { "no double count: truth unchanged by overlapping evidence" ); // Overlap with HIGHER confidence: choice swaps, still no pooling. - let out = arena - .observe(stmt, NarsTruth::new(0.4, 0.95), SourceId(7)) - .unwrap(); + let out = arena.observe(stmt, NarsTruth::new(0.4, 0.95), Stamp::source(7)); assert_eq!( out, ReviseOutcome::Chosen { @@ -492,28 +459,24 @@ mod tests { fn verbs_do_not_transit() { let mut arena = BeliefArena::new(); let bit = Copula::Rel(77); // "bit" - arena - .observe( - CStmt { - s: 1, - cop: bit, - p: 2, - }, // dog bit man - NarsTruth::new(1.0, 0.9), - SourceId(0), - ) - .unwrap(); - arena - .observe( - CStmt { - s: 2, - cop: bit, - p: 3, - }, // man bit sandwich - NarsTruth::new(1.0, 0.9), - SourceId(1), - ) - .unwrap(); + arena.observe( + CStmt { + s: 1, + cop: bit, + p: 2, + }, // dog bit man + NarsTruth::new(1.0, 0.9), + Stamp::source(0), + ); + arena.observe( + CStmt { + s: 2, + cop: bit, + p: 3, + }, // man bit sandwich + NarsTruth::new(1.0, 0.9), + Stamp::source(1), + ); arena.close_transitive(16); assert!(arena.reached_fixed_point); assert_eq!(arena.entries().len(), 2, "dog bit sandwich must NOT derive"); @@ -526,12 +489,8 @@ mod tests { .is_none()); // Control: the same shape under Inh DOES transit, with deduction truth. let mut inh_arena = BeliefArena::new(); - inh_arena - .observe(inh(1, 2), NarsTruth::new(0.9, 0.8), SourceId(0)) - .unwrap(); - inh_arena - .observe(inh(2, 3), NarsTruth::new(1.0, 0.95), SourceId(1)) - .unwrap(); + inh_arena.observe(inh(1, 2), NarsTruth::new(0.9, 0.8), Stamp::source(0)); + inh_arena.observe(inh(2, 3), NarsTruth::new(1.0, 0.95), Stamp::source(1)); inh_arena.close_transitive(16); let d = inh_arena.get(inh(1, 3)).expect("Inh transits"); assert!((d.truth.frequency - 0.9).abs() < 1e-6); // f = 0.9·1.0 @@ -545,19 +504,13 @@ mod tests { #[test] fn revision_keeps_rung_in_place() { let mut arena = BeliefArena::new(); - arena - .observe(inh(1, 2), NarsTruth::new(0.9, 0.8), SourceId(0)) - .unwrap(); - arena - .observe(inh(2, 3), NarsTruth::new(0.9, 0.8), SourceId(1)) - .unwrap(); + arena.observe(inh(1, 2), NarsTruth::new(0.9, 0.8), Stamp::source(0)); + arena.observe(inh(2, 3), NarsTruth::new(0.9, 0.8), Stamp::source(1)); arena.close_transitive(16); let derived = inh(1, 3); assert_eq!(arena.get(derived).unwrap().rung, 1); // Independent observation of the derived statement revises it in place… - let out = arena - .observe(derived, NarsTruth::new(0.7, 0.6), SourceId(9)) - .unwrap(); + let out = arena.observe(derived, NarsTruth::new(0.7, 0.6), Stamp::source(9)); assert!(matches!(out, ReviseOutcome::Revised { .. })); // …at its EXISTING rung. assert_eq!( @@ -587,7 +540,7 @@ mod tests { let mut src = 0u32; for group in order { for &(stmt, truth) in group { - arena.observe(stmt, truth, SourceId(src as u64)).unwrap(); + arena.observe(stmt, truth, Stamp::source(src)); src += 1; } } @@ -637,21 +590,15 @@ mod tests { fn closure_does_not_overwrite_a_derived_then_observed_belief() { let mut arena = BeliefArena::new(); // 1) Derive A→C weakly from A→B→C (rung 1, empty stamp). - arena - .observe(inh(0, 1), NarsTruth::new(0.6, 0.5), SourceId(0)) - .unwrap(); - arena - .observe(inh(1, 2), NarsTruth::new(0.6, 0.5), SourceId(1)) - .unwrap(); + arena.observe(inh(0, 1), NarsTruth::new(0.6, 0.5), Stamp::source(0)); + arena.observe(inh(1, 2), NarsTruth::new(0.6, 0.5), Stamp::source(1)); arena.close_transitive(64); assert_eq!(arena.get(inh(0, 2)).unwrap().rung, 1, "A→C derived"); // 2) Observe A→C directly (disjoint source): revision pools evidence in // place at rung 1 — the belief now carries a non-empty stamp. Chosen // so its expectation stays BELOW the strong path's, proving the guard // (not the expectation test) is what protects it. - let out = arena - .observe(inh(0, 2), NarsTruth::new(0.55, 0.9), SourceId(2)) - .unwrap(); + let out = arena.observe(inh(0, 2), NarsTruth::new(0.55, 0.9), Stamp::source(2)); assert!( matches!(out, ReviseOutcome::Revised { .. }), "disjoint → revision (derived belief's zero stamp is disjoint from any source)" @@ -660,12 +607,8 @@ mod tests { // 3) Add a STRONGER path A→D→C (deduction expectation ≈ 0.79, above the // revised A→C) and re-close. The pre-fix code would overwrite A→C with // the pure derivation, dropping the observation. - arena - .observe(inh(0, 3), NarsTruth::new(0.95, 0.9), SourceId(3)) - .unwrap(); - arena - .observe(inh(3, 2), NarsTruth::new(0.95, 0.9), SourceId(4)) - .unwrap(); + arena.observe(inh(0, 3), NarsTruth::new(0.95, 0.9), Stamp::source(3)); + arena.observe(inh(3, 2), NarsTruth::new(0.95, 0.9), Stamp::source(4)); arena.close_transitive(64); let after = arena.get(inh(0, 2)).unwrap().truth; assert_eq!( diff --git a/crates/lance-graph-cognitive/src/world/counterfactual.rs b/crates/lance-graph-cognitive/src/world/counterfactual.rs index 0774a1b4b..ac2cc9ee9 100644 --- a/crates/lance-graph-cognitive/src/world/counterfactual.rs +++ b/crates/lance-graph-cognitive/src/world/counterfactual.rs @@ -37,7 +37,15 @@ use crate::Fingerprint; #[derive(Debug, Clone)] pub struct SubstitutedWorld { /// The substitution applied. - pub substitution: BindingSubstitution, + /// 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). @@ -81,7 +89,7 @@ pub fn substitute_binding(world: &Fingerprint, substitution: &BindingSubstitutio let divergence = world.hamming(&new_state) as f32 / TOTAL_BITS as f32; SubstitutedWorld { - substitution: substitution.clone(), + substitutions: vec![substitution.clone()], state: new_state, divergence, } @@ -109,15 +117,7 @@ pub fn multi_substitute_binding( } let divergence = world.hamming(¤t) as f32 / TOTAL_BITS as f32; SubstitutedWorld { - substitution: if let Some(last) = substitutions.last() { - last.clone() - } else { - BindingSubstitution { - target: Fingerprint::zero(), - original: Fingerprint::zero(), - replacement: Fingerprint::zero(), - } - }, + substitutions: substitutions.to_vec(), state: current, divergence, } @@ -210,7 +210,7 @@ mod tests { let variable = Fingerprint::from_content("unchanged"); let world = base.bind(&variable); - // Intervening with same value should produce identical world + // Substituting with the same value should produce an identical world let identity = BindingSubstitution { target: variable.clone(), original: variable.clone(), diff --git a/crates/lance-graph-contract/src/causal_audit.rs b/crates/lance-graph-contract/src/causal_audit.rs index 9d382e33e..a41b90018 100644 --- a/crates/lance-graph-contract/src/causal_audit.rs +++ b/crates/lance-graph-contract/src/causal_audit.rs @@ -231,10 +231,13 @@ impl SupportBasis { } } -// Evidence-source identity is CANONICAL in `source_registry` — re-exported, not -// redefined. A second `SourceId` here would be the same duplication the registry -// exists to end (two crates had already shipped two incompatible `Stamp`s). -pub use crate::source_registry::SourceId; +/// 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. @@ -248,7 +251,7 @@ pub struct SupportReceipt { /// Which kind of support this is. pub basis: SupportBasis, /// Who supplied it — a stable external identity. - pub source: SourceId, + pub source: EvidenceSourceId, /// When it was recorded. /// /// **Known gap:** this is a storage revision, NOT an epistemic view. It @@ -305,7 +308,7 @@ impl SupportLedger { /// 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: SourceId) -> usize { + 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() @@ -318,7 +321,7 @@ impl SupportLedger { /// hot path reads [`SupportProfile`], not this. #[must_use] pub fn distinct_sources_for(&self, basis: SupportBasis) -> usize { - let mut seen: Vec = Vec::new(); + 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); @@ -337,17 +340,25 @@ impl SupportLedger { /// Project to the compact [`SupportProfile`] for the SIMD / fixed-width path. #[must_use] + /// Project the ledger. 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; - p.receipt_counts[slot] = p.receipt_counts[slot].saturating_add(1); - p.strength[slot] = p.strength[slot].saturating_add(r.strength); - } - for basis in SupportBasis::ALL { - p.distinct_sources[basis as usize] = - u8::try_from(self.distinct_sources_for(basis)).unwrap_or(u8::MAX); + 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 } @@ -368,21 +379,54 @@ impl SupportBasis { ]; } +/// 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. /// -/// Keeps `receipt_counts` and `distinct_sources` SEPARATE on purpose: one -/// source attesting three times and three sources attesting once each share a -/// `basis_mask` and must not share a corroboration reading. +/// 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, - /// Receipts recorded per basis. - pub receipt_counts: [u8; 9], - /// DISTINCT sources per basis — the corroboration reading. - pub distinct_sources: [u8; 9], - /// Summed receipt strength per basis (saturating). - pub strength: [u8; 9], + /// 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 { @@ -447,7 +491,7 @@ mod tests { fn receipt(basis: SupportBasis, source: u64, strength: u8) -> SupportReceipt { SupportReceipt { basis, - source: SourceId(source), + source: EvidenceSourceId(source), at: DatasetVersion(1), strength, } @@ -488,14 +532,28 @@ mod tests { } 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!(a.receipt_counts, b.receipt_counts, "…and so are raw counts"); - assert_eq!(a.distinct_sources[SupportBasis::TextAttested as usize], 3); - assert_eq!(b.distinct_sources[SupportBasis::TextAttested as usize], 1); - assert_ne!( - a.distinct_sources, b.distinct_sources, - "corroboration must distinguish them" + 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. @@ -570,11 +628,11 @@ mod tests { led.record(receipt(SupportBasis::DerivationalTrace, 1, 10)); led.record(receipt(SupportBasis::TextAttested, 2, 10)); - assert_eq!(led.withdraw_source(SourceId(1)), 2); + assert_eq!(led.withdraw_source(EvidenceSourceId(1)), 2); assert_eq!(led.receipts().len(), 1); - assert_eq!(led.receipts()[0].source, SourceId(2)); + assert_eq!(led.receipts()[0].source, EvidenceSourceId(2)); assert_eq!( - led.withdraw_source(SourceId(42)), + led.withdraw_source(EvidenceSourceId(42)), 0, "absent source is a no-op" ); diff --git a/crates/lance-graph-contract/src/lib.rs b/crates/lance-graph-contract/src/lib.rs index fbbc8d296..5f569d721 100644 --- a/crates/lance-graph-contract/src/lib.rs +++ b/crates/lance-graph-contract/src/lib.rs @@ -151,7 +151,6 @@ pub mod sla; pub mod soa_envelope; pub mod soa_graph; pub mod soa_view; -pub mod source_registry; pub mod splat; /// Standing-mask subscriptions (`dirty ∩ interest`) — one bitwise AND per /// write, no query re-execution. See module docs for the composition rule. diff --git a/crates/lance-graph-contract/src/recipe_kernels.rs b/crates/lance-graph-contract/src/recipe_kernels.rs index a6f2d1447..352f459c3 100644 --- a/crates/lance-graph-contract/src/recipe_kernels.rs +++ b/crates/lance-graph-contract/src/recipe_kernels.rs @@ -1302,8 +1302,13 @@ mod effect_census { /// 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 { - let mut bits = 0u8; - let mut set = |f: ThoughtField| bits |= 1 << (f as u8); + // 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); } @@ -1328,7 +1333,7 @@ mod effect_census { if before.beliefs != after.beliefs { set(ThoughtField::Beliefs); } - ThoughtMask(bits) + ThoughtMask::of(&fields) } /// **No kernel may mutate a field it did not declare.** diff --git a/crates/lance-graph-contract/src/source_registry.rs b/crates/lance-graph-contract/src/source_registry.rs deleted file mode 100644 index 64d52fb82..000000000 --- a/crates/lance-graph-contract/src/source_registry.rs +++ /dev/null @@ -1,391 +0,0 @@ -//! Evidence-source identity: arbitrary stable ids → dense local slots → stamps. -//! -//! ## The defect this replaces -//! -//! Two crates independently shipped `pub struct Stamp(pub u64)` with -//! `fn source(id: u32) -> Stamp { Stamp(1u64 << (id % 64)) }`. The modulo is -//! **conservative, not unsound** — a collision makes two distinct sources look -//! *overlapping*, so NARS revision refuses to pool them. It loses evidence; it -//! does not fabricate independence. (Both copies documented this correctly; an -//! audit that claimed otherwise was wrong.) -//! -//! What it *does* destroy is everything downstream of knowing which bit is -//! whom: pooling past 64 sources, leave-one-out, withdrawal, and any reading of -//! an evidence count. Hence the rule this module exists to enforce: -//! -//! > **A term id, domain id, witness id, or corpus id must NEVER be silently -//! > interpreted as a bit position.** -//! -//! An external id may be `300`, `50_000`, or a hash. It can still legitimately -//! occupy local slot 0 — if it is the first source registered in the current -//! bounded horizon. The failure condition is not "id ≥ 64", it is "more than -//! 64 *simultaneously represented* identities". -//! -//! ## Ruling: stamps are ARENA-LOCAL, by containment -//! -//! A [`Stamp`] is meaningful only relative to the registry that minted its -//! bits. Registry A may assign source X to slot 0 while registry B assigns -//! source Z to slot 0 — so `Stamp(0b1)` means different evidence in each, and -//! comparing them across registries is nonsense that `disjoint` would answer -//! confidently. -//! -//! Two designs are safe. **This module takes the first:** -//! -//! 1. **Strong containment** — the owning arena holds the registry, mints every -//! stamp, and performs every `union` / `disjoint`. Callers pass a -//! [`SourceId`]; no stamp crosses an API boundary, so no stamp can be -//! compared against a foreign one. -//! 2. *Registry-bearing stamps* — `{registry: SourceRegistryId, bits}`, with -//! every operation rejecting mismatched registries. Necessary only if stamps -//! must persist or cross boundaries. -//! -//! **Why (1).** Design (2) exists to make evidence from arena A meaningful -//! inside arena B — a handoff between two independently-owned state containers. -//! That is the shape the substrate already deleted at the mailbox layer (#477: -//! no inter-mailbox carrier at all, one writer per mailbox), reintroduced one -//! layer down under a new name. Containment is not merely cheaper here; it is -//! the option consistent with the ratified ownership model. -//! -//! Enforcement is structural, not advisory: [`Stamp`]'s bits are **private**, -//! it has no `Serialize`, and it is constructible only from a [`SourceSlot`] -//! that a [`SourceRegistry`] issued. -//! -//! **The flip condition, named so it is falsifiable:** if replay must -//! reconstruct evidence from *persisted* state rather than rebuilding the arena, -//! containment breaks. The answer then is still 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, -//! checked against a registry digest. That keeps mapping identity addressable -//! by epistemic view instead of smuggling it into the hot carrier. - -/// A stable, arbitrary, sparse identity for an evidence source — a corpus id, a -/// witness id, a term id, a hash. -/// -/// **Never a bit position.** Converting one to a bit index is the defect this -/// module exists to prevent; go through [`SourceRegistry::slot_for`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] -pub struct SourceId(pub u64); - -/// A dense local slot, `0..64`, issued by one [`SourceRegistry`]. -/// -/// Meaningful ONLY relative to the registry that issued it. Deliberately not -/// constructible from a raw integer. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[repr(transparent)] -pub struct SourceSlot(u8); - -impl SourceSlot { - /// The slot's index. For diagnostics and dense array indexing within the - /// owning arena — NOT for reconstructing a stamp elsewhere. - #[inline] - #[must_use] - pub const fn index(self) -> u8 { - self.0 - } -} - -/// The registry is full: more than [`SourceRegistry::CAPACITY`] distinct -/// sources are simultaneously represented in one evidence horizon. -/// -/// A real, reportable condition — never silently folded, which is exactly what -/// `id % 64` did. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CapacityExceeded { - /// The source that could not be admitted. - pub source: SourceId, - /// How many slots are already in use (always [`SourceRegistry::CAPACITY`]). - pub in_use: usize, -} - -impl core::fmt::Display for CapacityExceeded { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!( - f, - "source registry full ({} slots in use); cannot admit SourceId({})", - self.in_use, self.source.0 - ) - } -} - -impl core::error::Error for CapacityExceeded {} - -/// Maps arbitrary stable [`SourceId`]s onto dense [`SourceSlot`]s for one -/// bounded evidence horizon. -/// -/// Insertion-ordered and linear-scanned: the capacity is 64, so a map would -/// cost more than it saves, and the ordering makes the allocation reproducible -/// for a given insertion sequence. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SourceRegistry { - slots: Vec, -} - -impl SourceRegistry { - /// Slots available — the width of [`Stamp`]'s bitset. - pub const CAPACITY: usize = 64; - - /// An empty registry. - #[must_use] - pub const fn new() -> Self { - Self { slots: Vec::new() } - } - - /// The slot for `source`, allocating one if this is its first appearance. - /// - /// Idempotent: the same `SourceId` always maps to the same slot within one - /// registry. Returns [`CapacityExceeded`] rather than wrapping. - pub fn slot_for(&mut self, source: SourceId) -> Result { - if let Some(slot) = self.lookup(source) { - return Ok(slot); - } - if self.slots.len() >= Self::CAPACITY { - return Err(CapacityExceeded { - source, - in_use: self.slots.len(), - }); - } - let idx = u8::try_from(self.slots.len()).expect("len < CAPACITY <= 64"); - self.slots.push(source); - Ok(SourceSlot(idx)) - } - - /// The slot already held by `source`, if any. Never allocates. - #[must_use] - pub fn lookup(&self, source: SourceId) -> Option { - self.slots - .iter() - .position(|s| *s == source) - .map(|i| SourceSlot(u8::try_from(i).expect("index < CAPACITY"))) - } - - /// Which source holds `slot` — the inverse direction, for attribution. - #[must_use] - pub fn source_of(&self, slot: SourceSlot) -> Option { - self.slots.get(slot.0 as usize).copied() - } - - /// Distinct sources registered so far. - #[inline] - #[must_use] - pub fn len(&self) -> usize { - self.slots.len() - } - - /// Have any sources been registered? - #[inline] - #[must_use] - pub fn is_empty(&self) -> bool { - self.slots.is_empty() - } - - /// Remaining capacity before [`slot_for`](Self::slot_for) starts failing. - #[inline] - #[must_use] - pub fn remaining(&self) -> usize { - Self::CAPACITY - self.slots.len() - } - - /// Mint the stamp for `source`, allocating a slot if needed. - /// - /// The intended entry point for an owning arena: it never hands a - /// [`SourceSlot`] to a caller, so the stamp and its meaning stay together. - pub fn stamp_for(&mut self, source: SourceId) -> Result { - self.slot_for(source).map(Stamp::from_slot) - } -} - -/// An evidential base: which registry slots contributed to a belief. -/// -/// **Arena-local.** The bits are private and there is no way to build one from -/// a raw integer — a `Stamp` can only come from a [`SourceSlot`] its registry -/// issued, or from [`union`](Stamp::union)ing stamps that already exist. Keep -/// it inside the arena that owns the registry; comparing stamps minted by -/// different registries is meaningless and this type deliberately makes that -/// hard to do by accident. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct Stamp(u64); - -impl Stamp { - /// The empty stamp — "no recorded source". - /// - /// Load-bearing as a sentinel: NARS revision must NOT pool two beliefs when - /// either side is empty, because "no recorded source" is not evidence of - /// independence. Callers gate on [`is_empty`](Stamp::is_empty). - pub const EMPTY: Self = Self(0); - - /// The stamp of a single registry slot. - #[inline] - #[must_use] - pub const fn from_slot(slot: SourceSlot) -> Self { - Self(1u64 << slot.0) - } - - /// Do these stamps share no source? - /// - /// Only meaningful for stamps from the SAME registry — see the module docs. - #[inline] - #[must_use] - pub const fn disjoint(self, other: Self) -> bool { - self.0 & other.0 == 0 - } - - /// The pooled evidential base. - #[inline] - #[must_use] - pub const fn union(self, other: Self) -> Self { - Self(self.0 | other.0) - } - - /// Is this the no-source sentinel? - #[inline] - #[must_use] - pub const fn is_empty(self) -> bool { - self.0 == 0 - } - - /// How many distinct sources this stamp represents — the evidence count - /// that `id % 64` folding made uninterpretable. - #[inline] - #[must_use] - pub const fn count(self) -> u32 { - self.0.count_ones() - } - - /// Does this stamp include `slot`? - #[inline] - #[must_use] - pub const fn contains(self, slot: SourceSlot) -> bool { - self.0 & (1u64 << slot.0) != 0 - } - - /// Withdraw one source's contribution — possible only because slots are - /// stable identities rather than folded hashes. - #[inline] - #[must_use] - pub const fn without(self, slot: SourceSlot) -> Self { - Self(self.0 & !(1u64 << slot.0)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The headline property: a LARGE, sparse external id maps cleanly, because - /// the registry allocates by insertion order and never by arithmetic on the - /// id. Under `1 << (id % 64)` these three would have collided into slot 0. - #[test] - fn sparse_external_ids_get_distinct_slots() { - let mut reg = SourceRegistry::new(); - let a = reg.slot_for(SourceId(0)).unwrap(); - let b = reg.slot_for(SourceId(64)).unwrap(); - let c = reg.slot_for(SourceId(128)).unwrap(); - assert_ne!(a, b); - assert_ne!(b, c); - assert_ne!(a, c); - - // …and the stamps they mint are genuinely disjoint. - assert!(Stamp::from_slot(a).disjoint(Stamp::from_slot(b))); - assert!(Stamp::from_slot(b).disjoint(Stamp::from_slot(c))); - } - - /// A 50_000-valued id legitimately takes slot 0 when it registers first. - /// The bound is on SIMULTANEOUS identities, not on id magnitude. - #[test] - fn a_huge_id_may_hold_slot_zero() { - let mut reg = SourceRegistry::new(); - let slot = reg.slot_for(SourceId(50_000)).unwrap(); - assert_eq!(slot.index(), 0); - assert_eq!(reg.source_of(slot), Some(SourceId(50_000))); - } - - #[test] - fn slot_allocation_is_idempotent() { - let mut reg = SourceRegistry::new(); - let first = reg.slot_for(SourceId(7)).unwrap(); - let again = reg.slot_for(SourceId(7)).unwrap(); - assert_eq!(first, again); - assert_eq!(reg.len(), 1, "no second slot burned"); - } - - /// Capacity exhaustion is REPORTED, not folded. This is the whole - /// behavioural difference from `id % 64`. - #[test] - fn capacity_is_reported_never_wrapped() { - let mut reg = SourceRegistry::new(); - for i in 0..SourceRegistry::CAPACITY as u64 { - reg.slot_for(SourceId(i * 1000)).expect("within capacity"); - } - assert_eq!(reg.remaining(), 0); - - let err = reg.slot_for(SourceId(999_999)).unwrap_err(); - assert_eq!(err.in_use, SourceRegistry::CAPACITY); - assert_eq!(err.source, SourceId(999_999)); - - // An ALREADY-registered source still resolves when full — the registry - // is out of new slots, not broken. - assert!(reg.slot_for(SourceId(0)).is_ok()); - } - - /// Evidence counting is interpretable again: N distinct sources ⇒ N bits. - /// Under modulo folding this number was a lower bound of unknown tightness. - #[test] - fn evidence_count_is_exact_over_distinct_sources() { - let mut reg = SourceRegistry::new(); - let mut pooled = Stamp::EMPTY; - for i in 0..10u64 { - pooled = pooled.union(reg.stamp_for(SourceId(i * 64)).unwrap()); - } - assert_eq!(pooled.count(), 10); - - // Re-pooling the SAME sources adds nothing — idempotent, so a repeated - // source cannot inflate the count. - for i in 0..10u64 { - pooled = pooled.union(reg.stamp_for(SourceId(i * 64)).unwrap()); - } - assert_eq!(pooled.count(), 10, "repetition is not corroboration"); - } - - /// Withdrawal — the operation folded bits cannot support. - #[test] - fn withdrawal_removes_exactly_one_source() { - let mut reg = SourceRegistry::new(); - let a = reg.slot_for(SourceId(11)).unwrap(); - let b = reg.slot_for(SourceId(22)).unwrap(); - let pooled = Stamp::from_slot(a).union(Stamp::from_slot(b)); - assert_eq!(pooled.count(), 2); - - let minus_a = pooled.without(a); - assert_eq!(minus_a.count(), 1); - assert!(!minus_a.contains(a)); - assert!(minus_a.contains(b)); - } - - /// The empty sentinel is not accidentally disjoint-with-everything in a way - /// that licenses pooling: `disjoint` says true, so callers MUST gate on - /// `is_empty` separately. Pinned so the guard's necessity stays visible. - #[test] - fn empty_stamp_is_disjoint_with_everything_hence_the_separate_guard() { - let mut reg = SourceRegistry::new(); - let s = reg.stamp_for(SourceId(1)).unwrap(); - assert!(Stamp::EMPTY.disjoint(s)); - assert!(Stamp::EMPTY.is_empty(), "the guard callers must check"); - assert!(!s.is_empty()); - } - - /// Two registries independently assign slot 0 — the concrete reason stamps - /// must not cross registry boundaries. Documents the hazard the containment - /// ruling exists to remove; it is a property of the design, not a bug. - #[test] - fn slot_zero_means_different_sources_in_different_registries() { - let mut a = SourceRegistry::new(); - let mut b = SourceRegistry::new(); - let sa = a.stamp_for(SourceId(111)).unwrap(); - let sb = b.stamp_for(SourceId(222)).unwrap(); - - assert_eq!(sa, sb, "identical bits…"); - assert_eq!(a.source_of(SourceSlot(0)), Some(SourceId(111))); - assert_eq!(b.source_of(SourceSlot(0)), Some(SourceId(222))); - // …yet different evidence. Hence: one registry per arena, stamps stay in. - } -} diff --git a/crates/lance-graph-planner/examples/gestalt_texture_smoke.rs b/crates/lance-graph-planner/examples/gestalt_texture_smoke.rs index a0a51a19d..4b25eadc8 100644 --- a/crates/lance-graph-planner/examples/gestalt_texture_smoke.rs +++ b/crates/lance-graph-planner/examples/gestalt_texture_smoke.rs @@ -31,11 +31,10 @@ use std::collections::HashMap; -use lance_graph_planner::nars::belief::SourceId; use lance_graph_planner::nars::{ detect_dissolution, rank_basins, rank_epiphany_attractors, reach_out_integrate, staunen, wisdom, Basin, BasinKind, BeliefArena, CStmt, Copula, FeltOutcome, ReachOutConfig, - ResonanceConfig, Snapshot, TruthValue, + ResonanceConfig, Snapshot, Stamp, TruthValue, }; /// A tiny deterministic word→concept-id interner shared across BOTH poets, so the @@ -97,13 +96,11 @@ fn ingest(arena: &mut BeliefArena, lines: &[&str], intern: &mut Interner, src_ba let cs = concepts(line, intern); for w in cs.windows(2) { if w[0] != w[1] { - arena - .observe( - inh(w[0], w[1]), - TruthValue::new(0.9, 0.9), - SourceId((src_base + n) as u64), - ) - .unwrap(); + arena.observe( + inh(w[0], w[1]), + TruthValue::new(0.9, 0.9), + Stamp::source(src_base + n), + ); n += 1; } } @@ -255,7 +252,7 @@ fn main() { &mut felt_arena, inh(rose, flame), TruthValue::new(0.9, 0.9), - SourceId(9800), + Stamp::source(9800), &ReachOutConfig::default(), ); let g_after = Snapshot::of(&felt_arena, 0.0); @@ -268,7 +265,7 @@ fn main() { &mut dull_arena, inh(bank, loan), TruthValue::new(0.9, 0.9), - SourceId(9900), + Stamp::source(9900), &ReachOutConfig::default(), ); diff --git a/crates/lance-graph-planner/examples/insight_archetype_read.rs b/crates/lance-graph-planner/examples/insight_archetype_read.rs index 15c42733a..b0aceaf45 100644 --- a/crates/lance-graph-planner/examples/insight_archetype_read.rs +++ b/crates/lance-graph-planner/examples/insight_archetype_read.rs @@ -40,8 +40,7 @@ use lance_graph_contract::grammar::role_keys::Tense; use lance_graph_contract::grammar::tekamolo::TekamoloSlot; use lance_graph_contract::grammar::verb_lexicon::{is_copula, read_verb}; use lance_graph_contract::grammar::verb_table::VerbFamily; -use lance_graph_planner::nars::belief::SourceId; -use lance_graph_planner::nars::{BeliefArena, CStmt, Copula, TruthValue}; +use lance_graph_planner::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; /// Minimal function-word stoplist — the point here is the typed edges, not /// vocabulary tuning. @@ -155,17 +154,15 @@ fn extract(text: &str, intern: &mut Interner) -> Vec { fn arena_of(edges: &[TypedEdge]) -> BeliefArena { let mut arena = BeliefArena::new(); for (i, e) in edges.iter().enumerate() { - arena - .observe( - CStmt { - s: e.s, - cop: Copula::Inh, - p: e.p, - }, - TruthValue::new(0.9, 0.9), - SourceId(i as u64), - ) - .unwrap(); + arena.observe( + CStmt { + s: e.s, + cop: Copula::Inh, + p: e.p, + }, + TruthValue::new(0.9, 0.9), + Stamp::source(i as u32), + ); } arena.close_transitive(256); arena diff --git a/crates/lance-graph-planner/examples/insight_overlap_smoke.rs b/crates/lance-graph-planner/examples/insight_overlap_smoke.rs index 0fb44eee6..74ed8893d 100644 --- a/crates/lance-graph-planner/examples/insight_overlap_smoke.rs +++ b/crates/lance-graph-planner/examples/insight_overlap_smoke.rs @@ -28,11 +28,10 @@ use std::collections::HashMap; -use lance_graph_planner::nars::belief::SourceId; use lance_graph_planner::nars::{ detect_dissolution, rank_basins, rank_epiphany_attractors, reach_out_integrate, staunen, wisdom, BasinKind, BeliefArena, CStmt, Copula, FeltOutcome, ReachOutConfig, ResonanceConfig, - Snapshot, TruthValue, + Snapshot, Stamp, TruthValue, }; /// A tiny deterministic word→concept-id interner (shared across texts, so the @@ -133,13 +132,11 @@ fn ingest(arena: &mut BeliefArena, text: &str, intern: &mut Interner, src_base: let mut n = 0u32; for w in cs.windows(2) { if w[0] != w[1] { - arena - .observe( - inh(w[0], w[1]), - TruthValue::new(0.9, 0.9), - SourceId((src_base + n) as u64), - ) - .unwrap(); + arena.observe( + inh(w[0], w[1]), + TruthValue::new(0.9, 0.9), + Stamp::source(src_base + n), + ); n += 1; } } @@ -243,7 +240,7 @@ fn main() { &mut felt_arena, inh(montague, capulet), TruthValue::new(0.9, 0.9), - SourceId(500), + Stamp::source(500), &ReachOutConfig::default(), ); let g_after = Snapshot::of(&felt_arena, 0.0); @@ -257,7 +254,7 @@ fn main() { &mut dull_arena, inh(gravity, light), TruthValue::new(0.9, 0.9), - SourceId(600), + Stamp::source(600), &ReachOutConfig::default(), ); diff --git a/crates/lance-graph-planner/examples/insight_read.rs b/crates/lance-graph-planner/examples/insight_read.rs index e4273d07d..1ba1e9697 100644 --- a/crates/lance-graph-planner/examples/insight_read.rs +++ b/crates/lance-graph-planner/examples/insight_read.rs @@ -22,10 +22,9 @@ use std::collections::HashMap; -use lance_graph_planner::nars::belief::SourceId; use lance_graph_planner::nars::{ extract_main_insights, rank_basins, rank_epiphany_attractors, staunen, wisdom, BeliefArena, - CStmt, Copula, InsightConfig, InsightKind, InsightReason, ResonanceConfig, Snapshot, + CStmt, Copula, InsightConfig, InsightKind, InsightReason, ResonanceConfig, Snapshot, Stamp, TruthValue, }; @@ -307,9 +306,7 @@ fn close_from(salient: &[(usize, u16)], window: usize, exclude: Option) -> break; } if ci != cj && exclude != Some(cj) { - arena - .observe(inh(ci, cj), TruthValue::new(0.9, 0.9), SourceId(src as u64)) - .unwrap(); + arena.observe(inh(ci, cj), TruthValue::new(0.9, 0.9), Stamp::source(src)); src = src.wrapping_add(1); } } diff --git a/crates/lance-graph-planner/examples/insight_relation_read.rs b/crates/lance-graph-planner/examples/insight_relation_read.rs index ac2568913..0042ad431 100644 --- a/crates/lance-graph-planner/examples/insight_relation_read.rs +++ b/crates/lance-graph-planner/examples/insight_relation_read.rs @@ -49,8 +49,7 @@ use std::collections::{HashMap, HashSet}; -use lance_graph_planner::nars::belief::SourceId; -use lance_graph_planner::nars::{BeliefArena, CStmt, Copula, TruthValue}; +use lance_graph_planner::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; /// A modest English stoplist — function words that never carry a concept. Kept /// deterministic and small; the point of this example is the *edges*, not vocab @@ -272,17 +271,15 @@ fn close_sparse(rels: &[Relation], exclude: Option) -> BeliefArena { if exclude == Some(r.s) || exclude == Some(r.o) { continue; } - arena - .observe( - CStmt { - s: r.s, - cop: Copula::Inh, - p: r.o, - }, - TruthValue::new(0.9, 0.9), - SourceId(src as u64), - ) - .unwrap(); + arena.observe( + CStmt { + s: r.s, + cop: Copula::Inh, + p: r.o, + }, + TruthValue::new(0.9, 0.9), + Stamp::source(src), + ); src = src.wrapping_add(1); } arena.close_transitive(512); @@ -305,17 +302,15 @@ fn close_dense(salient: &[(usize, u16)], window: usize, exclude: Option) -> break; } if ci != cj && exclude != Some(cj) { - arena - .observe( - CStmt { - s: ci, - cop: Copula::Inh, - p: cj, - }, - TruthValue::new(0.9, 0.9), - SourceId(src as u64), - ) - .unwrap(); + arena.observe( + CStmt { + s: ci, + cop: Copula::Inh, + p: cj, + }, + TruthValue::new(0.9, 0.9), + Stamp::source(src), + ); src = src.wrapping_add(1); } } diff --git a/crates/lance-graph-planner/examples/reason_whole_book.rs b/crates/lance-graph-planner/examples/reason_whole_book.rs index 871bc7fbd..a3785e8a3 100644 --- a/crates/lance-graph-planner/examples/reason_whole_book.rs +++ b/crates/lance-graph-planner/examples/reason_whole_book.rs @@ -26,9 +26,8 @@ //! frontier finite; `ReasoningGap`s should surface where word-level SPO lacks //! the concept structure the tactics need (the E-HERMENEUTIK concept-lift). -use lance_graph_planner::nars::belief::SourceId; use lance_graph_planner::nars::{ - cas_abstract, rcr_abduce, BeliefArena, CStmt, Copula, GapKind, Throttle, TruthValue, + cas_abstract, rcr_abduce, BeliefArena, CStmt, Copula, GapKind, Stamp, Throttle, TruthValue, }; use std::collections::HashMap; use std::time::Instant; @@ -90,13 +89,11 @@ fn main() { }; *subj_degree.entry(s).or_default() += 1; // Observed fact: asserted (freq 1.0), moderate confidence; stamp = verse. - arena - .observe( - CStmt { s, cop, p: o }, - TruthValue::new(1.0, 0.9), - SourceId(v as u64), - ) - .unwrap(); + arena.observe( + CStmt { s, cop, p: o }, + TruthValue::new(1.0, 0.9), + Stamp::source(v), + ); } let observed = arena.entries().len(); println!("── ingest ──"); diff --git a/crates/lance-graph-planner/examples/tactic_select_confusion.rs b/crates/lance-graph-planner/examples/tactic_select_confusion.rs index a2416e7cf..d807423b5 100644 --- a/crates/lance-graph-planner/examples/tactic_select_confusion.rs +++ b/crates/lance-graph-planner/examples/tactic_select_confusion.rs @@ -25,11 +25,10 @@ //! Usage: `cargo run -p lance-graph-planner --example tactic_select_confusion` use lance_graph_contract::sensorium::GraphBias; -use lance_graph_planner::nars::belief::SourceId; use lance_graph_planner::nars::{ asc_challenge, cas_abstract, challenge_target, cr_synthesize, rcr_abduce, tactic_for_bias, - tr_diverge, AscOutcome, BeliefArena, CStmt, Copula, ReviseOutcome, TacticChoice, Throttle, - TruthValue, + tr_diverge, AscOutcome, BeliefArena, CStmt, Copula, ReviseOutcome, Stamp, TacticChoice, + Throttle, TruthValue, }; fn inh(s: u16, p: u16) -> CStmt { @@ -65,7 +64,7 @@ struct Fixture { /// `BeliefArena` is not `Clone` and the revision tactics mutate. fn fires(choice: TacticChoice, fx: &Fixture) -> bool { let mut arena = (fx.build)(); - let counter_stamp = SourceId(9999); // disjoint from every fixture source + let counter_stamp = Stamp::source(9999); // disjoint from every fixture source match choice { TacticChoice::Rcr => !rcr_abduce(&arena, &Throttle::permissive()) .candidates @@ -89,8 +88,7 @@ fn fires(choice: TacticChoice, fx: &Fixture) -> bool { fx.target, TruthValue::new(0.2, 0.8), counter_stamp - ) - .unwrap(), + ), ReviseOutcome::Revised { .. } ), } @@ -104,10 +102,8 @@ fn main() { name: "shared-predicate pair", build: || { let mut a = BeliefArena::new(); - a.observe(inh(1, 9), TruthValue::new(0.9, 0.8), SourceId(0)) - .unwrap(); - a.observe(inh(2, 9), TruthValue::new(0.8, 0.7), SourceId(1)) - .unwrap(); + a.observe(inh(1, 9), TruthValue::new(0.9, 0.8), Stamp::source(0)); + a.observe(inh(2, 9), TruthValue::new(0.8, 0.7), Stamp::source(1)); a }, focus: inh(1, 9), @@ -121,10 +117,8 @@ fn main() { name: "similarity sibling", build: || { let mut a = BeliefArena::new(); - a.observe(inh(1, 9), TruthValue::new(0.9, 0.8), SourceId(0)) - .unwrap(); - a.observe(sim(1, 2), TruthValue::new(0.9, 0.8), SourceId(1)) - .unwrap(); + a.observe(inh(1, 9), TruthValue::new(0.9, 0.8), Stamp::source(0)); + a.observe(sim(1, 2), TruthValue::new(0.9, 0.8), Stamp::source(1)); a }, focus: inh(1, 9), @@ -138,10 +132,8 @@ fn main() { name: "is_a chain", build: || { let mut a = BeliefArena::new(); - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); - a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(1)) - .unwrap(); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); + a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(1)); a }, focus: inh(1, 2), @@ -155,8 +147,7 @@ fn main() { name: "lone belief (challenge)", build: || { let mut a = BeliefArena::new(); - a.observe(inh(1, 2), TruthValue::new(0.8, 0.8), SourceId(0)) - .unwrap(); + a.observe(inh(1, 2), TruthValue::new(0.8, 0.8), Stamp::source(0)); a }, focus: inh(1, 2), @@ -170,8 +161,7 @@ fn main() { name: "lone belief (synthesize)", build: || { let mut a = BeliefArena::new(); - a.observe(inh(1, 2), TruthValue::new(0.9, 0.8), SourceId(0)) - .unwrap(); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.8), Stamp::source(0)); a }, focus: inh(1, 2), diff --git a/crates/lance-graph-planner/src/nars/basin_resonance.rs b/crates/lance-graph-planner/src/nars/basin_resonance.rs index eeda05555..d812e4609 100644 --- a/crates/lance-graph-planner/src/nars/basin_resonance.rs +++ b/crates/lance-graph-planner/src/nars/basin_resonance.rs @@ -48,7 +48,7 @@ //! [`super::dissolution`] (`staunen`/`wisdom` at whole-arena granularity; here //! they are per-basin), [`super::insight`] (the S10 per-step notion). -use super::belief::{BeliefArena, Copula}; +use super::belief::{BeliefArena, Copula, Stamp}; use super::truth::TruthValue; use std::collections::BTreeMap; @@ -139,7 +139,7 @@ pub fn rank_basins(arena: &BeliefArena, cfg: &ResonanceConfig) -> Vec { // Rel (verbs) never join a reasoning basin; Inh/Sim/Impl do. continue; } - let is_evidence = !b.stamp.is_empty(); + let is_evidence = b.stamp != Stamp::default(); let is_derived = b.rung >= 1; let conf = b.truth.confidence; let exp = b.truth.expectation(); @@ -211,8 +211,7 @@ pub fn rank_basins(arena: &BeliefArena, cfg: &ResonanceConfig) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; - use lance_graph_contract::source_registry::SourceId; + use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -227,18 +226,12 @@ mod tests { /// conclusions `1→3, 1→4, 2→4` (logical coherence around the chain heads). fn structured_kg() -> BeliefArena { let mut a = BeliefArena::new(); - a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); - a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), SourceId(1)) - .unwrap(); - a.observe(inh(3, 100), TruthValue::new(0.9, 0.9), SourceId(2)) - .unwrap(); - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(3)) - .unwrap(); - a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(4)) - .unwrap(); - a.observe(inh(3, 4), TruthValue::new(0.9, 0.9), SourceId(5)) - .unwrap(); + a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), Stamp::source(0)); + a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), Stamp::source(1)); + a.observe(inh(3, 100), TruthValue::new(0.9, 0.9), Stamp::source(2)); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(3)); + a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(4)); + a.observe(inh(3, 4), TruthValue::new(0.9, 0.9), Stamp::source(5)); a.close_transitive(64); a } @@ -277,9 +270,11 @@ mod tests { (14, 114), (15, 115), ] { - noise - .observe(inh(s, p), TruthValue::new(0.9, 0.9), SourceId(s as u64)) - .unwrap(); + noise.observe( + inh(s, p), + TruthValue::new(0.9, 0.9), + Stamp::source(s as u32), + ); } noise.close_transitive(64); @@ -346,39 +341,33 @@ mod tests { big.observe( inh(s(1), pred), TruthValue::new(0.9, 0.9), - SourceId(base as u64), - ) - .unwrap(); + Stamp::source(base as u32), + ); big.observe( inh(s(2), pred), TruthValue::new(0.9, 0.9), - SourceId(base as u64 + 1), - ) - .unwrap(); + Stamp::source(base as u32 + 1), + ); big.observe( inh(s(3), pred), TruthValue::new(0.9, 0.9), - SourceId(base as u64 + 2), - ) - .unwrap(); + Stamp::source(base as u32 + 2), + ); big.observe( inh(s(1), s(2)), TruthValue::new(0.9, 0.9), - SourceId(base as u64 + 3), - ) - .unwrap(); + Stamp::source(base as u32 + 3), + ); big.observe( inh(s(2), s(3)), TruthValue::new(0.9, 0.9), - SourceId(base as u64 + 4), - ) - .unwrap(); + Stamp::source(base as u32 + 4), + ); big.observe( inh(s(3), s(4)), TruthValue::new(0.9, 0.9), - SourceId(base as u64 + 5), - ) - .unwrap(); + Stamp::source(base as u32 + 5), + ); } big.close_transitive(64); let big_top = rank_basins(&big, &ResonanceConfig::default()) @@ -398,8 +387,7 @@ mod tests { #[test] fn self_loop_is_counted_once() { let mut a = BeliefArena::new(); - a.observe(inh(5, 5), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); + a.observe(inh(5, 5), TruthValue::new(0.9, 0.9), Stamp::source(0)); assert!( !rank_basins(&a, &ResonanceConfig::default()) diff --git a/crates/lance-graph-planner/src/nars/belief.rs b/crates/lance-graph-planner/src/nars/belief.rs index 96c4bfc07..70530192a 100644 --- a/crates/lance-graph-planner/src/nars/belief.rs +++ b/crates/lance-graph-planner/src/nars/belief.rs @@ -22,25 +22,31 @@ //! overlap → CHOICE, no double count. use super::truth::TruthValue; -use lance_graph_contract::source_registry::SourceRegistry; use std::collections::HashMap; -/// Evidential provenance types — CANONICAL in `lance_graph_contract::source_registry`, -/// re-exported here so `crate::nars::Stamp` keeps resolving for existing readers. -/// -/// This module used to define its own `Stamp(pub u64)` with -/// `source(id) = 1 << (id % 64)`, as did `deepnsm-v2`. Two copies, two -/// independence semantics. The folding was CONSERVATIVE (a collision creates -/// false *overlap*, so revision refuses to pool — evidence lost, never -/// double-counted), but it destroyed everything downstream of knowing which bit -/// is whom: pooling past 64 sources, leave-one-out, withdrawal, and any reading -/// of an evidence count. -/// -/// [`Stamp`] is now OPAQUE — readable (`is_empty`/`count`/`disjoint`) but not -/// constructible outside the registry that mints it, because a bitset does not -/// carry the mapping that gives its bits meaning. Stamps are arena-local by -/// containment; see the `source_registry` module docs for the ruling. -pub use lance_graph_contract::source_registry::{CapacityExceeded, SourceId, Stamp}; +/// Fixed-width evidential stamp: bit *i* = observation source *i* (bounded +/// horizon of 64; sources beyond fold by modulo — CONSERVATIVE: folding can only +/// create false overlap, never false disjointness, so no-double-count survives). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Stamp(pub u64); + +impl Stamp { + /// The stamp of a single observation source. + #[must_use] + pub fn source(id: u32) -> Self { + Stamp(1u64 << (id % 64)) + } + /// Two stamps share no evidence. + #[must_use] + pub fn disjoint(self, other: Self) -> bool { + self.0 & other.0 == 0 + } + /// Pooled evidence base. + #[must_use] + pub fn union(self, other: Self) -> Self { + Stamp(self.0 | other.0) + } +} /// The copula of a concept-level statement (S3). `Rel` carries an arbitrary /// relational term (an FSM verb id) — stored, queryable, NEVER transitive. @@ -123,11 +129,6 @@ pub enum ReviseOutcome { pub struct BeliefArena { entries: Vec, index: HashMap, - /// This arena's OWN source registry — the mapping that gives its stamps - /// their meaning. Private, and stamps never leave, so a stamp minted here - /// can never be compared against one minted by a different arena (where the - /// same bit denotes a different source). - registry: SourceRegistry, /// Closure passes run by the last `close_transitive` call. pub passes: u32, /// Whether the last closure reached a true fixed point. @@ -153,38 +154,12 @@ impl BeliefArena { self.index.get(&stmt).map(|&i| &self.entries[i as usize]) } - /// Does `stmt`'s belief already carry evidence from `source`? - /// - /// The containment-preserving replacement for `belief.stamp.disjoint(s)` at - /// a call site that only wants to know "is this source already counted - /// here?" — asking the question never requires minting a stamp, so no - /// `Stamp` has to cross the arena boundary to answer it. - /// - /// Query-only: an unregistered source cannot be present, so this never - /// allocates a slot and can never exhaust the registry (hence no `Result`). - #[must_use] - pub fn stmt_has_source(&self, stmt: CStmt, source: SourceId) -> bool { - match (self.registry.lookup(source), self.get(stmt)) { - (Some(slot), Some(b)) => b.stamp.contains(slot), - _ => false, - } - } - /// Offer evidence for a statement (rung 0 observation path). Admits it if /// absent; otherwise routes through the S4 guard: disjoint → revision in /// place; overlap → CHOICE. The arena NEVER grows a second entry for an /// existing statement — the termination invariant. - /// Capacity exhaustion is REPORTED ([`CapacityExceeded`]), never folded — - /// that is the behavioural difference from the retired `id % 64`, which - /// silently aliased a 65th source onto an existing slot. - pub fn observe( - &mut self, - stmt: CStmt, - truth: TruthValue, - source: SourceId, - ) -> Result { - let stamp = self.registry.stamp_for(source)?; - Ok(match self.index.get(&stmt) { + pub fn observe(&mut self, stmt: CStmt, truth: TruthValue, stamp: Stamp) -> ReviseOutcome { + match self.index.get(&stmt) { None => { let id = self.entries.len() as u32; self.entries.push(Belief { @@ -198,8 +173,8 @@ impl BeliefArena { self.index.insert(stmt, id); ReviseOutcome::Admitted { id } } - Some(&id) => self.revise_at_stamp(id, truth, stamp), - }) + Some(&id) => self.revise_at(id, truth, stamp), + } } /// The S4 revision guard on an existing belief. A NON-EMPTY incoming stamp @@ -213,24 +188,9 @@ impl BeliefArena { /// it as independent evidence would let a repeated zero-stamped observation /// pool into itself and inflate confidence without bound (`union` also never /// records overlap). Unsourced evidence cannot pool — it competes by CHOICE. - /// Capacity exhaustion is REPORTED, never folded (see [`observe`](Self::observe)). - pub fn revise_at( - &mut self, - id: u32, - new: TruthValue, - source: SourceId, - ) -> Result { - let stamp = self.registry.stamp_for(source)?; - Ok(self.revise_at_stamp(id, new, stamp)) - } - - /// The stamp-taking core, shared by [`observe`](Self::observe) and - /// [`revise_at`](Self::revise_at) so a single logical event mints its slot - /// exactly once. Private: a `Stamp` is only meaningful relative to THIS - /// arena's registry, so it must not be constructible from outside. - fn revise_at_stamp(&mut self, id: u32, new: TruthValue, stamp: Stamp) -> ReviseOutcome { + pub fn revise_at(&mut self, id: u32, new: TruthValue, stamp: Stamp) -> ReviseOutcome { let b = &mut self.entries[id as usize]; - if !stamp.is_empty() && b.stamp.disjoint(stamp) { + if stamp != Stamp::default() && b.stamp.disjoint(stamp) { let depth = (b.truth.frequency - new.frequency).abs(); b.contradiction = b.contradiction.max(depth); b.truth = b.truth.revise(&new); @@ -281,7 +241,7 @@ impl BeliefArena { // both purely-observed (rung 0) and derived-then-observed // (rung ≥ 1, stamp unioned by `revise_at`) beliefs; keying on the // stamp (evidence provenance), never the rung, is the Codex fix. - if !e.stamp.is_empty() { + if e.stamp != Stamp::default() { return false; } if truth.expectation() > e.truth.expectation() + EPS { @@ -297,7 +257,7 @@ impl BeliefArena { self.entries.push(Belief { stmt, truth, - stamp: Stamp::EMPTY, // derived: no observation sources of its own + stamp: Stamp::default(), // derived: no observation sources of its own rung, premises: premises.to_vec(), contradiction: 0.0, @@ -395,17 +355,15 @@ mod tests { fn revision_disjoint_moves_truth_and_terminates() { let mut arena = BeliefArena::new(); for k in 0..9u16 { - arena - .observe(inh(k, k + 1), TruthValue::new(0.9, 0.9), SourceId(k as u64)) - .unwrap(); + arena.observe( + inh(k, k + 1), + TruthValue::new(0.9, 0.9), + Stamp::source(k as u32), + ); } - arena - .observe(inh(9, 0), TruthValue::new(0.9, 0.9), SourceId(9)) - .unwrap(); // cycle + arena.observe(inh(9, 0), TruthValue::new(0.9, 0.9), Stamp::source(9)); // cycle let stmt = inh(0, 1); - let out = arena - .observe(stmt, TruthValue::new(0.2, 0.75), SourceId(40)) - .unwrap(); + let out = arena.observe(stmt, TruthValue::new(0.2, 0.75), Stamp::source(40)); let ReviseOutcome::Revised { synthesis_c, depth, .. } = out @@ -431,38 +389,30 @@ mod tests { fn verbs_do_not_transit() { let mut arena = BeliefArena::new(); let bit = Copula::Rel(77); - arena - .observe( - CStmt { - s: 1, - cop: bit, - p: 2, - }, - TruthValue::new(1.0, 0.9), - SourceId(0), - ) - .unwrap(); - arena - .observe( - CStmt { - s: 2, - cop: bit, - p: 3, - }, - TruthValue::new(1.0, 0.9), - SourceId(1), - ) - .unwrap(); + arena.observe( + CStmt { + s: 1, + cop: bit, + p: 2, + }, + TruthValue::new(1.0, 0.9), + Stamp::source(0), + ); + arena.observe( + CStmt { + s: 2, + cop: bit, + p: 3, + }, + TruthValue::new(1.0, 0.9), + Stamp::source(1), + ); arena.close_transitive(16); assert_eq!(arena.entries().len(), 2, "dog bit sandwich must NOT derive"); let mut inh_arena = BeliefArena::new(); - inh_arena - .observe(inh(1, 2), TruthValue::new(0.9, 0.8), SourceId(0)) - .unwrap(); - inh_arena - .observe(inh(2, 3), TruthValue::new(1.0, 0.95), SourceId(1)) - .unwrap(); + inh_arena.observe(inh(1, 2), TruthValue::new(0.9, 0.8), Stamp::source(0)); + inh_arena.observe(inh(2, 3), TruthValue::new(1.0, 0.95), Stamp::source(1)); inh_arena.close_transitive(16); let d = inh_arena.get(inh(1, 3)).expect("Inh transits"); assert!((d.truth.frequency - 0.9).abs() < 1e-6); @@ -476,9 +426,7 @@ mod tests { fn admit_derived_respects_observation_ground() { let mut arena = BeliefArena::new(); let stmt = inh(2, 1); - arena - .observe(stmt, TruthValue::new(0.55, 0.95), SourceId(9)) - .unwrap(); + arena.observe(stmt, TruthValue::new(0.55, 0.95), Stamp::source(9)); let before = arena.get(stmt).unwrap().truth; let changed = arena.admit_derived(stmt, TruthValue::new(0.99, 0.9), &[0, 0], 1); assert!(!changed, "ground belief not overwritten"); @@ -499,19 +447,11 @@ mod tests { fn empty_incoming_stamp_does_not_pool() { let mut arena = BeliefArena::new(); let stmt = inh(1, 2); - let id = match arena - .observe(stmt, TruthValue::new(0.8, 0.5), SourceId(3)) - .unwrap() - { - ReviseOutcome::Admitted { id } => id, - other => panic!("expected Admitted, got {other:?}"), - }; + arena.observe(stmt, TruthValue::new(0.8, 0.5), Stamp::source(3)); let c0 = arena.get(stmt).unwrap().truth.confidence; - // Same statement offered with an EMPTY stamp, repeatedly. `SourceId` cannot - // construct an empty stamp (opaque by design), so this drives the private - // stamp-taking core directly to exercise the guard. + // Same statement offered with an EMPTY stamp, repeatedly. for _ in 0..10 { - let out = arena.revise_at_stamp(id, TruthValue::new(0.8, 0.5), Stamp::EMPTY); + let out = arena.observe(stmt, TruthValue::new(0.8, 0.5), Stamp::default()); assert!( matches!(out, ReviseOutcome::Chosen { .. }), "empty stamp → CHOICE, got {out:?}" @@ -523,9 +463,7 @@ mod tests { "empty-stamped evidence never pooled: {c0} → {c1}" ); // A REAL disjoint source still pools (the guard is only for empty stamps). - let out = arena - .observe(stmt, TruthValue::new(0.8, 0.5), SourceId(7)) - .unwrap(); + let out = arena.observe(stmt, TruthValue::new(0.8, 0.5), Stamp::source(7)); assert!( matches!(out, ReviseOutcome::Revised { .. }), "real source still revises" diff --git a/crates/lance-graph-planner/src/nars/dissolution.rs b/crates/lance-graph-planner/src/nars/dissolution.rs index 9080fed71..4a601a7a5 100644 --- a/crates/lance-graph-planner/src/nars/dissolution.rs +++ b/crates/lance-graph-planner/src/nars/dissolution.rs @@ -98,8 +98,7 @@ pub fn should_elevate(d: &Dissolution, threshold: f32) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; - use lance_graph_contract::source_registry::SourceId; + use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -114,20 +113,20 @@ mod tests { fn score_ingest(core: &[(u16, u16)], new: &[(u16, u16)]) -> Dissolution { let mut arena = BeliefArena::new(); for (i, &(s, p)) in core.iter().enumerate() { - arena - .observe(inh(s, p), TruthValue::new(0.95, 0.9), SourceId(i as u64)) - .unwrap(); + arena.observe( + inh(s, p), + TruthValue::new(0.95, 0.9), + Stamp::source(i as u32), + ); } arena.close_transitive(64); let before = Snapshot::of(&arena, 0.0); for (j, &(s, p)) in new.iter().enumerate() { - arena - .observe( - inh(s, p), - TruthValue::new(0.95, 0.9), - SourceId(1000 + j as u64), - ) - .unwrap(); + arena.observe( + inh(s, p), + TruthValue::new(0.95, 0.9), + Stamp::source(1000 + j as u32), + ); } arena.close_transitive(64); let after = Snapshot::of(&arena, 0.0); @@ -222,12 +221,8 @@ mod tests { #[test] fn poles_read_from_snapshot() { let mut arena = BeliefArena::new(); - arena - .observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); - arena - .observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(1)) - .unwrap(); + arena.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); + arena.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(1)); arena.close_transitive(8); let snap = Snapshot::of(&arena, 0.0); diff --git a/crates/lance-graph-planner/src/nars/elevation.rs b/crates/lance-graph-planner/src/nars/elevation.rs index 76e4f5e29..4f25b4aa5 100644 --- a/crates/lance-graph-planner/src/nars/elevation.rs +++ b/crates/lance-graph-planner/src/nars/elevation.rs @@ -175,8 +175,7 @@ fn has_intermediate_parent(arena: &BeliefArena, s: u16, m: u16) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; - use lance_graph_contract::source_registry::SourceId; + use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -194,9 +193,11 @@ mod tests { // STRUCTURED: 5 subjects share predicate 100. let mut arena = BeliefArena::new(); for s in 10u16..15 { - arena - .observe(inh(s, 100), TruthValue::new(0.9, 0.9), SourceId(s.into())) - .unwrap(); + arena.observe( + inh(s, 100), + TruthValue::new(0.9, 0.9), + Stamp::source(s.into()), + ); } let e = elevate_field(&mut arena, 3); assert_eq!(e.clusters_lifted, 1, "one shared-predicate cluster lifted"); @@ -218,9 +219,11 @@ mod tests { // structure to lift. let mut noisy_arena = BeliefArena::new(); for (s, p) in [(10u16, 100u16), (11, 101), (12, 102), (13, 103), (14, 104)] { - noisy_arena - .observe(inh(s, p), TruthValue::new(0.9, 0.9), SourceId(s.into())) - .unwrap(); + noisy_arena.observe( + inh(s, p), + TruthValue::new(0.9, 0.9), + Stamp::source(s.into()), + ); } let e_noise = elevate_field(&mut noisy_arena, 3); assert_eq!( @@ -237,18 +240,18 @@ mod tests { fn minted_parent_propagates_to_children() { let mut arena = BeliefArena::new(); for s in 10u16..15 { - arena - .observe(inh(s, 100), TruthValue::new(0.9, 0.9), SourceId(s.into())) - .unwrap(); + arena.observe( + inh(s, 100), + TruthValue::new(0.9, 0.9), + Stamp::source(s.into()), + ); } let e = elevate_field(&mut arena, 3); let g = e.minted_parents[0]; assert_eq!(g, 101); // A single NEW fact about the abstraction, never about any child. - arena - .observe(inh(g, 200), TruthValue::new(0.9, 0.9), SourceId(9)) - .unwrap(); + arena.observe(inh(g, 200), TruthValue::new(0.9, 0.9), Stamp::source(9)); arena.close_transitive(64); // Without the minted parent, this one `g is_a 200` fact could not @@ -277,12 +280,8 @@ mod tests { #[test] fn min_cluster_threshold_holds() { let mut arena = BeliefArena::new(); - arena - .observe(inh(10, 100), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); - arena - .observe(inh(11, 100), TruthValue::new(0.9, 0.9), SourceId(1)) - .unwrap(); + arena.observe(inh(10, 100), TruthValue::new(0.9, 0.9), Stamp::source(0)); + arena.observe(inh(11, 100), TruthValue::new(0.9, 0.9), Stamp::source(1)); let e3 = elevate_field(&mut arena, 3); assert_eq!(e3.clusters_lifted, 0, "2 subjects < min_cluster 3"); @@ -298,9 +297,11 @@ mod tests { fn elevate_stabilizes_across_repeated_calls() { let mut arena = BeliefArena::new(); for s in 10u16..15 { - arena - .observe(inh(s, 100), TruthValue::new(0.9, 0.9), SourceId(s.into())) - .unwrap(); + arena.observe( + inh(s, 100), + TruthValue::new(0.9, 0.9), + Stamp::source(s.into()), + ); } let first = elevate_field(&mut arena, 3); diff --git a/crates/lance-graph-planner/src/nars/epiphany.rs b/crates/lance-graph-planner/src/nars/epiphany.rs index b74e28e65..0f8e5d9f8 100644 --- a/crates/lance-graph-planner/src/nars/epiphany.rs +++ b/crates/lance-graph-planner/src/nars/epiphany.rs @@ -76,8 +76,7 @@ pub fn rank_epiphany_attractors( #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; - use lance_graph_contract::source_registry::SourceId; + use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -96,8 +95,11 @@ mod tests { // LARGE sparse basin, subject 1: 20 observed + 5 derived. // epiphanies 5, attempts 25, rate 0.20. for p in 100..120u16 { - a.observe(inh(1, p), TruthValue::new(0.9, 0.9), SourceId(p as u64)) - .unwrap(); + a.observe( + inh(1, p), + TruthValue::new(0.9, 0.9), + Stamp::source(p as u32), + ); } for p in 200..205u16 { assert!(a.admit_derived(inh(1, p), TruthValue::new(0.9, 0.9), &[], 1)); @@ -105,8 +107,7 @@ mod tests { // SMALL dense basin, subject 2: 1 observed + 3 derived. // epiphanies 3, attempts 4, rate 0.75. - a.observe(inh(2, 300), TruthValue::new(0.9, 0.9), SourceId(300)) - .unwrap(); + a.observe(inh(2, 300), TruthValue::new(0.9, 0.9), Stamp::source(300)); for p in 301..304u16 { assert!(a.admit_derived(inh(2, p), TruthValue::new(0.9, 0.9), &[], 1)); } @@ -143,15 +144,12 @@ mod tests { let mut a = BeliefArena::default(); // Subject 1: 3 beliefs (clears min_attempts = 2). - a.observe(inh(1, 10), TruthValue::new(0.9, 0.9), SourceId(10)) - .unwrap(); - a.observe(inh(1, 11), TruthValue::new(0.9, 0.9), SourceId(11)) - .unwrap(); + a.observe(inh(1, 10), TruthValue::new(0.9, 0.9), Stamp::source(10)); + a.observe(inh(1, 11), TruthValue::new(0.9, 0.9), Stamp::source(11)); assert!(a.admit_derived(inh(1, 12), TruthValue::new(0.9, 0.9), &[], 1)); // Subject 2: 1 belief (below min_attempts = 2). - a.observe(inh(2, 20), TruthValue::new(0.9, 0.9), SourceId(20)) - .unwrap(); + a.observe(inh(2, 20), TruthValue::new(0.9, 0.9), Stamp::source(20)); let ranked = rank_epiphany_attractors(&a, 2); assert_eq!(ranked.len(), 1); @@ -163,13 +161,11 @@ mod tests { let mut a = BeliefArena::default(); // Subject 5: 1 derived / 2 total = rate 0.5. - a.observe(inh(5, 50), TruthValue::new(0.9, 0.9), SourceId(50)) - .unwrap(); + a.observe(inh(5, 50), TruthValue::new(0.9, 0.9), Stamp::source(50)); assert!(a.admit_derived(inh(5, 51), TruthValue::new(0.9, 0.9), &[], 1)); // Subject 3: 1 derived / 2 total = rate 0.5 (same rate). - a.observe(inh(3, 30), TruthValue::new(0.9, 0.9), SourceId(30)) - .unwrap(); + a.observe(inh(3, 30), TruthValue::new(0.9, 0.9), Stamp::source(30)); assert!(a.admit_derived(inh(3, 31), TruthValue::new(0.9, 0.9), &[], 1)); let ranked = rank_epiphany_attractors(&a, 2); diff --git a/crates/lance-graph-planner/src/nars/insight.rs b/crates/lance-graph-planner/src/nars/insight.rs index b0aa36585..cd24950fc 100644 --- a/crates/lance-graph-planner/src/nars/insight.rs +++ b/crates/lance-graph-planner/src/nars/insight.rs @@ -274,8 +274,7 @@ pub fn flow_state(im: &InsightMush) -> FlowState { #[cfg(test)] mod tests { use super::*; - use crate::nars::{CStmt, Copula, TruthValue}; - use lance_graph_contract::source_registry::SourceId; + 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. @@ -339,9 +338,11 @@ mod tests { fn score_step(edges: &[(u16, u16)]) -> InsightMush { let mut arena = BeliefArena::new(); for (i, &(s, p)) in edges.iter().enumerate() { - arena - .observe(inh(s, p), TruthValue::new(0.95, 0.9), SourceId(i as u64)) - .unwrap(); + arena.observe( + inh(s, p), + TruthValue::new(0.95, 0.9), + Stamp::source(i as u32), + ); } let before = Snapshot::of(&arena, 0.0); arena.close_transitive(64); @@ -404,12 +405,8 @@ mod tests { #[test] fn signals_reuse_contract_carrier() { let mut arena = BeliefArena::new(); - arena - .observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); - arena - .observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(1)) - .unwrap(); + arena.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); + arena.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(1)); arena.close_transitive(8); let sig: GraphSignals = arena_graph_signals(&arena, 0.0); // 3 statements, 1 derived (1→3) → yield = 1/3. diff --git a/crates/lance-graph-planner/src/nars/insights.rs b/crates/lance-graph-planner/src/nars/insights.rs index 252b0fbe2..8c5e0c5ee 100644 --- a/crates/lance-graph-planner/src/nars/insights.rs +++ b/crates/lance-graph-planner/src/nars/insights.rs @@ -187,8 +187,7 @@ fn inh(s: u16, p: u16) -> CStmt { #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; - use lance_graph_contract::source_registry::SourceId; + use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -203,18 +202,12 @@ mod tests { /// `close_transitive` derives into `1→3`, `1→4`, `2→4`. fn structured_kg() -> BeliefArena { let mut a = BeliefArena::new(); - a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); - a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), SourceId(1)) - .unwrap(); - a.observe(inh(3, 100), TruthValue::new(0.9, 0.9), SourceId(2)) - .unwrap(); - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(3)) - .unwrap(); - a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(4)) - .unwrap(); - a.observe(inh(3, 4), TruthValue::new(0.9, 0.9), SourceId(5)) - .unwrap(); + a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), Stamp::source(0)); + a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), Stamp::source(1)); + a.observe(inh(3, 100), TruthValue::new(0.9, 0.9), Stamp::source(2)); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(3)); + a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(4)); + a.observe(inh(3, 4), TruthValue::new(0.9, 0.9), Stamp::source(5)); a.close_transitive(64); a } @@ -260,9 +253,11 @@ mod tests { (14, 114), (15, 115), ] { - noise - .observe(inh(s, p), TruthValue::new(0.9, 0.9), SourceId(s as u64)) - .unwrap(); + noise.observe( + inh(s, p), + TruthValue::new(0.9, 0.9), + Stamp::source(s as u32), + ); } noise.close_transitive(64); @@ -335,15 +330,11 @@ mod tests { // 2 subjects share predicate 100; only 1 predicate in the KG — the prior // `d/total_preds` bug would have scored this bridge 2.0. let mut a = BeliefArena::new(); - a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); - a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), SourceId(1)) - .unwrap(); + a.observe(inh(1, 100), TruthValue::new(0.9, 0.9), Stamp::source(0)); + a.observe(inh(2, 100), TruthValue::new(0.9, 0.9), Stamp::source(1)); // A derived conclusion with a real ladder (so a Conclusion exists to compare). - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(2)) - .unwrap(); - a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(3)) - .unwrap(); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(2)); + a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(3)); a.close_transitive(64); let insights = extract_main_insights(&a, &InsightConfig::default()); @@ -363,10 +354,8 @@ mod tests { fn self_conclusion_is_not_surfaced() { // A 2-cycle 1→2, 2→1 closes into self-loops 1→1 and 2→2. let mut a = BeliefArena::new(); - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); - a.observe(inh(2, 1), TruthValue::new(0.9, 0.9), SourceId(1)) - .unwrap(); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); + a.observe(inh(2, 1), TruthValue::new(0.9, 0.9), Stamp::source(1)); a.close_transitive(64); assert!( a.get(inh(1, 1)).is_some(), diff --git a/crates/lance-graph-planner/src/nars/reach_out.rs b/crates/lance-graph-planner/src/nars/reach_out.rs index 2a520bc95..53481ba67 100644 --- a/crates/lance-graph-planner/src/nars/reach_out.rs +++ b/crates/lance-graph-planner/src/nars/reach_out.rs @@ -18,7 +18,7 @@ //! expansion rising, [`super::insight::Snapshot`]). This module measures it //! by composing the two shipped pieces — no new detector, no new engine. -use super::belief::{BeliefArena, CStmt, SourceId}; +use super::belief::{BeliefArena, CStmt, Stamp}; use super::insight::Snapshot; use super::truth::TruthValue; @@ -76,7 +76,7 @@ pub fn reach_out_integrate( arena: &mut BeliefArena, bridge: CStmt, truth: TruthValue, - source: SourceId, + stamp: Stamp, cfg: &ReachOutConfig, ) -> FeltOutcome { // Establish the baseline at a fixed point FIRST — otherwise a pending @@ -87,10 +87,7 @@ pub fn reach_out_integrate( let derived_before = arena.entries().iter().filter(|b| b.rung >= 1).count(); let quarantined = TruthValue::new(truth.frequency, truth.confidence.min(cfg.quarantine_prior)); - // A full registry means the bridge's evidence cannot be admitted - // independently, so nothing lands and the reach-out simply does not fire — - // reported by the derivation count below, never silently pooled. - let _ = arena.observe(bridge, quarantined, source); + arena.observe(bridge, quarantined, stamp); arena.close_transitive(cfg.max_passes); let after = Snapshot::of(arena, 0.0); @@ -110,7 +107,7 @@ pub fn reach_out_integrate( #[cfg(test)] mod tests { use super::*; - use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; + use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -128,14 +125,13 @@ mod tests { fn middle_term_click_is_new_insight() { let mut a = BeliefArena::new(); // A is_a M (pre-existing, rung 0). - a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); + a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), Stamp::source(0)); let out = reach_out_integrate( &mut a, inh(9, 2), // M is_a B — the fetched bridge. TruthValue::new(0.9, 0.9), - SourceId(1), // disjoint from source 0. + Stamp::source(1), // disjoint from source 0. &ReachOutConfig::default(), ); @@ -163,14 +159,13 @@ mod tests { #[test] fn lone_concept_is_dull_shadow() { let mut a = BeliefArena::new(); - a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); + a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), Stamp::source(0)); let out = reach_out_integrate( &mut a, inh(100, 101), // fresh, unconnected terms. TruthValue::new(0.9, 0.9), - SourceId(1), + Stamp::source(1), &ReachOutConfig::default(), ); @@ -188,28 +183,24 @@ mod tests { #[test] fn shadow_and_insight_are_size_matched() { let mut insight_arena = BeliefArena::new(); - insight_arena - .observe(inh(1, 9), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); + insight_arena.observe(inh(1, 9), TruthValue::new(0.9, 0.9), Stamp::source(0)); // Exactly one bridge belief added: composes as a middle term. let insight_out = reach_out_integrate( &mut insight_arena, inh(9, 2), TruthValue::new(0.9, 0.9), - SourceId(1), + Stamp::source(1), &ReachOutConfig::default(), ); let mut shadow_arena = BeliefArena::new(); - shadow_arena - .observe(inh(1, 9), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); + shadow_arena.observe(inh(1, 9), TruthValue::new(0.9, 0.9), Stamp::source(0)); // Exactly one bridge belief added: composes with nothing. let shadow_out = reach_out_integrate( &mut shadow_arena, inh(100, 101), TruthValue::new(0.9, 0.9), - SourceId(1), + Stamp::source(1), &ReachOutConfig::default(), ); @@ -223,8 +214,7 @@ mod tests { #[test] fn quarantine_caps_confidence() { let mut a = BeliefArena::new(); - a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); + a.observe(inh(1, 9), TruthValue::new(0.9, 0.9), Stamp::source(0)); let bridge = inh(9, 2); let cfg = ReachOutConfig { @@ -235,7 +225,7 @@ mod tests { &mut a, bridge, TruthValue::new(0.9, 0.95), // high offered confidence. - SourceId(1), + Stamp::source(1), &cfg, ); @@ -256,17 +246,15 @@ mod tests { fn pending_closure_is_not_miscredited_to_an_unrelated_bridge() { let mut a = BeliefArena::new(); // An OPEN chain: A→B, B→C, deliberately NOT closed by the caller. - a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); - a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), SourceId(1)) - .unwrap(); + a.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); + a.observe(inh(2, 3), TruthValue::new(0.9, 0.9), Stamp::source(1)); // (no close_transitive here — the arena is passed in un-closed) let out = reach_out_integrate( &mut a, inh(100, 101), // fresh, unrelated to the pending chain. TruthValue::new(0.9, 0.9), - SourceId(2), + Stamp::source(2), &ReachOutConfig::default(), ); diff --git a/crates/lance-graph-planner/src/nars/regulate.rs b/crates/lance-graph-planner/src/nars/regulate.rs index 8bdc1ed21..0790a30c6 100644 --- a/crates/lance-graph-planner/src/nars/regulate.rs +++ b/crates/lance-graph-planner/src/nars/regulate.rs @@ -92,8 +92,7 @@ pub fn regulate_cycle( #[cfg(test)] mod tests { use super::*; - use crate::nars::belief::SourceId; - use crate::nars::{BeliefArena, CStmt, Copula, TruthValue}; + use crate::nars::{BeliefArena, CStmt, Copula, Stamp, TruthValue}; fn inh(s: u16, p: u16) -> CStmt { CStmt { @@ -110,9 +109,8 @@ mod tests { a.observe( inh(i, i + 1), TruthValue::new(0.95, 0.9), - SourceId(i.into()), - ) - .unwrap(); + Stamp::source(i.into()), + ); } a.close_transitive(64); a @@ -128,8 +126,11 @@ mod tests { // 5 subjects share predicate 900 — disjoint from the core, non-composing. for s in 200u16..205 { - a.observe(inh(s, 900), TruthValue::new(0.9, 0.9), SourceId(s.into())) - .unwrap(); + a.observe( + inh(s, 900), + TruthValue::new(0.9, 0.9), + Stamp::source(s.into()), + ); } let out = regulate_cycle(&mut a, &before, &CycleConfig::default()); @@ -159,9 +160,8 @@ mod tests { a.observe( inh(i, i + 1), TruthValue::new(0.95, 0.9), - SourceId((100 + i).into()), - ) - .unwrap(); + Stamp::source((100 + i).into()), + ); } let out = regulate_cycle(&mut a, &before, &CycleConfig::default()); @@ -184,8 +184,11 @@ mod tests { let mut a = core_arena(); let before = Snapshot::of(&a, 0.0); for s in 200u16..205 { - a.observe(inh(s, 900), TruthValue::new(0.9, 0.9), SourceId(s.into())) - .unwrap(); + a.observe( + inh(s, 900), + TruthValue::new(0.9, 0.9), + Stamp::source(s.into()), + ); } let first = regulate_cycle(&mut a, &before, &CycleConfig::default()); assert!(first.elevated.is_some(), "first cycle elevates"); diff --git a/crates/lance-graph-planner/src/nars/tactics.rs b/crates/lance-graph-planner/src/nars/tactics.rs index 6582f7a7e..08da88709 100644 --- a/crates/lance-graph-planner/src/nars/tactics.rs +++ b/crates/lance-graph-planner/src/nars/tactics.rs @@ -24,7 +24,7 @@ //! | 8 CAS | abstraction | up=induction `{S→P, S→G} ⊢ G→P`; down=deduction `{G→P, S→G} ⊢ S→P` | [`TruthValue::induction`] / [`TruthValue::deduction`] | Datapath | //! | 11 CR | dialectic | same statement, disjoint → revision; overlap → CHOICE | [`TruthValue::revise`] / CHOICE | Control | -use super::belief::{BeliefArena, CStmt, CapacityExceeded, Copula, ReviseOutcome, SourceId}; +use super::belief::{BeliefArena, CStmt, Copula, ReviseOutcome, Stamp}; use super::truth::TruthValue; use std::collections::HashMap; @@ -473,31 +473,26 @@ pub enum AscOutcome { /// **ASC — self-critique** (recipe #7, Control bucket). Forms the belief's /// refutation target `⟨1−f, c⟩` and admits offered counter-evidence ONLY when it -/// is independently sourced (the belief does NOT already carry that source) — -/// then revises it in. Counter-evidence from a source the belief already counts -/// is BLOCKED (no self-refutation from one's own evidence). +/// is independently sourced (its stamp is DISJOINT from the belief's) — then +/// revises it in. Counter-evidence overlapping the belief's own sources is +/// BLOCKED (no self-refutation from one's own evidence). pub fn asc_challenge( arena: &mut BeliefArena, target: CStmt, counter: TruthValue, - counter_source: SourceId, + counter_stamp: Stamp, ) -> AscOutcome { - if arena.get(target).is_none() { + let Some(belief) = arena.get(target) else { return AscOutcome::NoTarget; - } - // The self-reference guard, asked as a QUERY rather than by constructing a - // stamp to compare against: a challenge that reuses evidence the target - // already carries is not independent counter-evidence. - if arena.stmt_has_source(target, counter_source) { + }; + if !belief.stamp.disjoint(counter_stamp) { return AscOutcome::BlockedSelfReference; } - match arena.observe(target, counter, counter_source) { - Ok(ReviseOutcome::Revised { + match arena.observe(target, counter, counter_stamp) { + ReviseOutcome::Revised { synthesis_c, depth, .. - }) => AscOutcome::Revised { synthesis_c, depth }, - // A full registry cannot admit independent counter-evidence, so the - // challenge does not land — reported as blocked, never silently pooled. - Ok(_) | Err(_) => AscOutcome::BlockedSelfReference, + } => AscOutcome::Revised { synthesis_c, depth }, + _ => AscOutcome::BlockedSelfReference, } } @@ -509,9 +504,9 @@ pub fn cr_synthesize( arena: &mut BeliefArena, stmt: CStmt, truth: TruthValue, - source: SourceId, -) -> Result { - arena.observe(stmt, truth, source) + stamp: Stamp, +) -> ReviseOutcome { + arena.observe(stmt, truth, stamp) } #[cfg(test)] @@ -549,12 +544,8 @@ mod tests { #[test] fn rcr_abduces_shared_predicate_with_weak_truth() { let mut arena = BeliefArena::new(); - arena - .observe(inh(1, 9), TruthValue::new(0.9, 0.8), SourceId(0)) - .unwrap(); // A→M - arena - .observe(inh(2, 9), TruthValue::new(0.8, 0.7), SourceId(1)) - .unwrap(); // B→M + arena.observe(inh(1, 9), TruthValue::new(0.9, 0.8), Stamp::source(0)); // A→M + arena.observe(inh(2, 9), TruthValue::new(0.8, 0.7), Stamp::source(1)); // B→M let fr = rcr_abduce(&arena, &Throttle::permissive()); assert_eq!(fr.candidates.len(), 2, "both directions abduced"); let b_to_a = fr @@ -575,21 +566,19 @@ mod tests { fn rcr_excludes_hub_and_reports_gaps() { let mut arena = BeliefArena::new(); for s in 1..=5u16 { - arena - .observe(inh(s, 9), TruthValue::new(0.9, 0.8), SourceId(s as u64)) - .unwrap(); + arena.observe( + inh(s, 9), + TruthValue::new(0.9, 0.8), + Stamp::source(s as u32), + ); } let fr = rcr_abduce(&arena, &Throttle::new(0.0, usize::MAX, 4)); assert!(fr.candidates.is_empty(), "no candidates through a hub"); assert!(fr.gaps.iter().any(|g| g.kind == GapKind::HubExcluded)); // No shared middle at all → NoSharedMiddle. let mut disjoint = BeliefArena::new(); - disjoint - .observe(inh(1, 8), TruthValue::new(0.9, 0.8), SourceId(0)) - .unwrap(); - disjoint - .observe(inh(2, 9), TruthValue::new(0.9, 0.8), SourceId(1)) - .unwrap(); + disjoint.observe(inh(1, 8), TruthValue::new(0.9, 0.8), Stamp::source(0)); + disjoint.observe(inh(2, 9), TruthValue::new(0.9, 0.8), Stamp::source(1)); assert!(rcr_abduce(&disjoint, &Throttle::permissive()) .gaps .iter() @@ -601,9 +590,11 @@ mod tests { fn rcr_floor_and_budget() { let mut arena = BeliefArena::new(); for s in 1..=4u16 { - arena - .observe(inh(s, 9), TruthValue::new(0.9, 0.9), SourceId(s as u64)) - .unwrap(); + arena.observe( + inh(s, 9), + TruthValue::new(0.9, 0.9), + Stamp::source(s as u32), + ); } assert_eq!( rcr_abduce(&arena, &Throttle::new(0.0, usize::MAX, usize::MAX)) @@ -636,12 +627,8 @@ mod tests { #[test] fn tr_analogy_substitutes_sibling() { let mut arena = BeliefArena::new(); - arena - .observe(inh(1, 3), TruthValue::new(0.9, 0.8), SourceId(0)) - .unwrap(); // dog→mammal - arena - .observe(sim(1, 2), TruthValue::new(0.7, 0.6), SourceId(1)) - .unwrap(); // dog↔wolf + arena.observe(inh(1, 3), TruthValue::new(0.9, 0.8), Stamp::source(0)); // dog→mammal + arena.observe(sim(1, 2), TruthValue::new(0.7, 0.6), Stamp::source(1)); // dog↔wolf let fr = tr_diverge(&arena, inh(1, 3)); assert_eq!(fr.candidates.len(), 1); let c = fr.candidates[0]; @@ -658,15 +645,9 @@ mod tests { #[test] fn cas_up_induction_down_deduction() { let mut arena = BeliefArena::new(); - arena - .observe(inh(1, 2), TruthValue::new(0.95, 0.9), SourceId(0)) - .unwrap(); // S→G - arena - .observe(inh(1, 3), TruthValue::new(0.9, 0.85), SourceId(1)) - .unwrap(); // S→P - arena - .observe(inh(2, 4), TruthValue::new(0.9, 0.85), SourceId(2)) - .unwrap(); // G→Q + arena.observe(inh(1, 2), TruthValue::new(0.95, 0.9), Stamp::source(0)); // S→G + arena.observe(inh(1, 3), TruthValue::new(0.9, 0.85), Stamp::source(1)); // S→P + arena.observe(inh(2, 4), TruthValue::new(0.9, 0.85), Stamp::source(2)); // G→Q let fr = cas_abstract(&arena, 1, &Throttle::permissive()); let up = fr .candidates @@ -707,14 +688,14 @@ mod tests { #[test] fn cas_budget_bounds_a_high_fanout_subject() { let mut arena = BeliefArena::new(); - arena - .observe(inh(1, 2), TruthValue::new(0.95, 0.9), SourceId(0)) - .unwrap(); // S→G parent + arena.observe(inh(1, 2), TruthValue::new(0.95, 0.9), Stamp::source(0)); // S→G parent for p in 10..40u16 { // 30 properties of S → up mints ~30 G→P candidates uncapped. - arena - .observe(inh(1, p), TruthValue::new(0.9, 0.85), SourceId(p as u64)) - .unwrap(); + arena.observe( + inh(1, p), + TruthValue::new(0.9, 0.85), + Stamp::source(p as u32), + ); } let full = cas_abstract(&arena, 1, &Throttle::permissive()); assert!( @@ -730,14 +711,15 @@ mod tests { .any(|g| g.kind == GapKind::BudgetExhausted)); // A hub parent (many inheritors) is barred from the down fan-out. let mut hub = BeliefArena::new(); - hub.observe(inh(1, 2), TruthValue::new(0.9, 0.9), SourceId(0)) - .unwrap(); // S→G + hub.observe(inh(1, 2), TruthValue::new(0.9, 0.9), Stamp::source(0)); // S→G for s in 10..30u16 { - hub.observe(inh(s, 2), TruthValue::new(0.9, 0.9), SourceId(s as u64)) - .unwrap(); // many ?→G + hub.observe( + inh(s, 2), + TruthValue::new(0.9, 0.9), + Stamp::source(s as u32), + ); // many ?→G } - hub.observe(inh(2, 99), TruthValue::new(0.9, 0.9), SourceId(99)) - .unwrap(); // G→P + hub.observe(inh(2, 99), TruthValue::new(0.9, 0.9), Stamp::source(99)); // G→P let barred = cas_abstract(&hub, 1, &Throttle::new(0.0, usize::MAX, 4)); assert!(barred.gaps.iter().any(|g| g.kind == GapKind::HubExcluded)); } @@ -748,16 +730,24 @@ mod tests { fn asc_independent_revises_overlap_blocks() { let mut arena = BeliefArena::new(); let stmt = inh(1, 2); - arena - .observe(stmt, TruthValue::new(0.9, 0.8), SourceId(3)) - .unwrap(); + arena.observe(stmt, TruthValue::new(0.9, 0.8), Stamp::source(3)); assert!((challenge_target(arena.get(stmt).unwrap().truth).frequency - 0.1).abs() < 1e-6); assert_eq!( - asc_challenge(&mut arena, stmt, TruthValue::new(0.1, 0.7), SourceId(3)), + asc_challenge( + &mut arena, + stmt, + TruthValue::new(0.1, 0.7), + Stamp::source(3) + ), AscOutcome::BlockedSelfReference ); assert!((arena.get(stmt).unwrap().truth.frequency - 0.9).abs() < 1e-6); - let out = asc_challenge(&mut arena, stmt, TruthValue::new(0.1, 0.7), SourceId(40)); + let out = asc_challenge( + &mut arena, + stmt, + TruthValue::new(0.1, 0.7), + Stamp::source(40), + ); assert!(matches!(out, AscOutcome::Revised { .. })); assert!( arena.get(stmt).unwrap().truth.frequency < 0.9, @@ -768,7 +758,7 @@ mod tests { &mut arena, inh(7, 8), TruthValue::new(0.5, 0.5), - SourceId(0) + Stamp::source(0) ), AscOutcome::NoTarget ); @@ -781,11 +771,20 @@ mod tests { let mut arena = BeliefArena::new(); let stmt = inh(1, 2); assert!(matches!( - cr_synthesize(&mut arena, stmt, TruthValue::new(0.9, 0.8), SourceId(0)).unwrap(), + cr_synthesize( + &mut arena, + stmt, + TruthValue::new(0.9, 0.8), + Stamp::source(0) + ), ReviseOutcome::Admitted { .. } )); - let anti = - cr_synthesize(&mut arena, stmt, TruthValue::new(0.2, 0.75), SourceId(1)).unwrap(); + let anti = cr_synthesize( + &mut arena, + stmt, + TruthValue::new(0.2, 0.75), + Stamp::source(1), + ); let ReviseOutcome::Revised { synthesis_c, depth, .. } = anti @@ -803,16 +802,10 @@ mod tests { fn admitted_candidate_respects_ground() { let mut arena = BeliefArena::new(); let stmt = inh(2, 1); - arena - .observe(stmt, TruthValue::new(0.55, 0.95), SourceId(9)) - .unwrap(); + arena.observe(stmt, TruthValue::new(0.55, 0.95), Stamp::source(9)); let before = arena.get(stmt).unwrap().truth; - arena - .observe(inh(1, 9), TruthValue::new(0.99, 0.9), SourceId(0)) - .unwrap(); - arena - .observe(inh(2, 9), TruthValue::new(0.99, 0.9), SourceId(1)) - .unwrap(); + arena.observe(inh(1, 9), TruthValue::new(0.99, 0.9), Stamp::source(0)); + arena.observe(inh(2, 9), TruthValue::new(0.99, 0.9), Stamp::source(1)); let cand = *rcr_abduce(&arena, &Throttle::permissive()) .candidates .iter() diff --git a/crates/surreal_container/tests/scheduler_seam.rs b/crates/surreal_container/tests/scheduler_seam.rs index 759e06deb..bb482aee3 100644 --- a/crates/surreal_container/tests/scheduler_seam.rs +++ b/crates/surreal_container/tests/scheduler_seam.rs @@ -81,7 +81,7 @@ fn libet_anchor_only_on_sigma_commit_crossing() { assert_eq!( crossing.libet_window_us(), Some(LIBET_COMMIT_WINDOW_US), - "the Σ-commit crossing must carry the -550ms Libet anchor" + "the Σ-commit crossing must open the canonical Libet window" ); for from in [KanbanColumn::CognitiveWork, KanbanColumn::Evaluation, KanbanColumn::Plan] { @@ -91,7 +91,7 @@ fn libet_anchor_only_on_sigma_commit_crossing() { assert_eq!( mv.libet_window_us(), None, - "{from:?} is not the Σ-commit crossing — Libet offset must be 0" + "{from:?} is not the Σ-commit crossing — no Libet window opens" ); } } diff --git a/docs/architecture/soa-three-tier-model.md b/docs/architecture/soa-three-tier-model.md index ee0128f5f..83eb5270b 100644 --- a/docs/architecture/soa-three-tier-model.md +++ b/docs/architecture/soa-three-tier-model.md @@ -67,7 +67,7 @@ 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, witness_chain_position } + 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 From b30673df7eb9e4b804c2fabb60ad258eda06742a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:06:22 +0000 Subject: [PATCH 09/16] =?UTF-8?q?docs:=20architecture-assimilation=20prime?= =?UTF-8?q?r=20=E2=80=94=20identity=20/=20temporal=20/=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-grounded audit written before any further carrier is proposed. Every claim carries file:line; nothing is inferred from a name. Records the two operator rulings as premises (V3 GUID is always ClassId:AppId:ClassView; always SoA-owned, zero-copy, never serialized) and verifies the second in soa_envelope.rs:16-19. Headline correction to my own earlier analysis: ClassId and the GUID's classid are ONE carrier seen through different projections, not two identity systems. NodeGuid is byte-identical to FacetCascade, whose facet_classid is ClassId(canon,high) ++ AppId(custom,low) and whose six 8:8 tiers end in family:identity. The instance IS in the V3 unit; my claim that the address carries no instance was analysing the 4-byte classid alone. Records seven verified gaps as MISSING rather than substituting the nearest available field, and a do-not-invent list derived from the code. No code changed. --- .../identity-temporal-evidence-primer.md | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 .claude/knowledge/identity-temporal-evidence-primer.md diff --git a/.claude/knowledge/identity-temporal-evidence-primer.md b/.claude/knowledge/identity-temporal-evidence-primer.md new file mode 100644 index 000000000..c57fa7e7d --- /dev/null +++ b/.claude/knowledge/identity-temporal-evidence-primer.md @@ -0,0 +1,242 @@ +# 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"* | **RESERVED-DORMANT** — no code path sets non-zero `server_id` or `Some(hlc_tick)`; `default()`/`at()` hardcode `0`/`None` (`temporal.rs:126-151`) | +| **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`). + +--- + +## 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. + +--- + +## 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. From 410feb35b14d1ebd142de1e66edb5b7adf20faeb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:07:47 +0000 Subject: [PATCH 10/16] =?UTF-8?q?docs:=20primer=20=E2=80=94=20add=20SoA/Ka?= =?UTF-8?q?nban=20ownership=20map=20+=20the=20BeliefArena=20island=20findi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the V3 conformance invariants into the primer and records the load-bearing structural fact: MailboxSoA is the owning SoA (one fixed-size array per column) and it owns its Kanban — `phase` is pub(crate), mutated only through MailboxSoaOwner::advance_phase, alongside current_cycle. One SoA, one Kanban, one sole mutator. BeliefArena is NOT that. It is `entries: Vec` plus a HashMap — array-of- structs on the heap, with no SoA columns, no Kanban, no mailbox_id, no V3 GUID addressing, and premises stored as arena indices. It is an object-shaped island. This reframes the whole PR #854 evidence discussion: both the old Stamp and the withdrawn SourceRegistry are heap structures inside a container that is already outside V3 ownership, so neither could have been substrate-conformant however well-typed. SourceRegistry added a third heap map inside the island and called the result containment. Adds four do-not-invent rules derived from this: do not judge a design by Serialize or by reconstructibility from serialized records (V3 is never serialized, so neither is evidence of anything); do not let a Rust struct's shape override the substrate; do not treat arena-local as containment — in V3 containment is SoA ownership plus write-on-behalf, not privacy of a heap field. No code changed. --- .../identity-temporal-evidence-primer.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/.claude/knowledge/identity-temporal-evidence-primer.md b/.claude/knowledge/identity-temporal-evidence-primer.md index c57fa7e7d..4eb21effa 100644 --- a/.claude/knowledge/identity-temporal-evidence-primer.md +++ b/.claude/knowledge/identity-temporal-evidence-primer.md @@ -175,6 +175,60 @@ uses a **debug-only** `debug_assert!(w <= 63)` (`causal-edge/src/edge.rs:953`); --- +## 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. + +--- + ## 6. Ownership / persistence / replay - `SoaEnvelope` (`soa_envelope.rs:170`) — the owner of the in-place backing store. @@ -227,6 +281,16 @@ uses a **debug-only** `debug_assert!(w <= 63)` (`causal-edge/src/edge.rs:953`); 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. --- From 033241cdd4d93f75aa4d6a9535cb79d542a4b451 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:14:45 +0000 Subject: [PATCH 11/16] =?UTF-8?q?docs:=20primer=20=E2=80=94=20the=20fire-a?= =?UTF-8?q?nd-forget=20execution=20model=20and=20what=20PR=20#854=20broke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the V3 execution model (V3 GUID -> SoA state -> SoA-owned Kanban -> fire-and- forget -> async BatchWriter, ~64k updates / 550 ms => ~8.6 us per logical update) and grounds it in batch_writer.rs, whose module doc is the contract: cast() returns CastId, not Result — "the thinker reports (casts) and moves on — 'melden macht frei' — it is NEVER refused"; "There is no confirmation bookkeeping here (E-ACK-ELIMINATED-1)"; "Do not add a confirmation ledger (a persisted id->version map) under any name"; "P is a DESCRIPTOR ... never owned delta bytes". Records the distinction between two Results that look alike: try_advance_phase's Result is a lifecycle-legality guard (one enum compare, no shared state, documented as the inline kanbanstep) and is conformant. The withdrawn observe -> Result<_, CapacityExceeded> was resource-exhaustion control flow on a path documented as never-refusing, with an O(n<=64) scan and a &mut on shared registry state per update. SourceRegistry is a persistent id->slot map that gates the path and can refuse — the forbidden confirmation-ledger shape under another name. This also explains the reviewer findings as consequences of the shape rather than as separate bugs: the examples' .unwrap() (panic) and reach_out_integrate's let _ = (swallow) are the only two ways to answer a refusal that should not exist. Adds do-not-invent rules 15-17: no fallible/allocating/shared-mutable work before emission; no confirmation ledger under any name; no owned bytes on a cast payload. No code changed. --- .../identity-temporal-evidence-primer.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/.claude/knowledge/identity-temporal-evidence-primer.md b/.claude/knowledge/identity-temporal-evidence-primer.md index 4eb21effa..3478656af 100644 --- a/.claude/knowledge/identity-temporal-evidence-primer.md +++ b/.claude/knowledge/identity-temporal-evidence-primer.md @@ -229,6 +229,55 @@ it can be substrate-conformant**, however well-typed it is. --- +## 5.7 The execution model — fire-and-forget, and what PR #854 broke + +``` +V3 GUID → SoA-owned state → SoA-owned Kanban transition → fire-and-forget → async BatchWriter +``` +Budget: **~64,000 updates / 550 ms ⇒ ~8.6 µs per logical update**, and that is the +budget for the *whole* transition, not for one helper. + +**`BatchWriter

`** — `lance-graph-planner/src/batch_writer.rs:55`. Its module doc +IS the contract: + +- `pub fn cast(&mut self, on_behalf: MailboxId, moves: Vec, payload: P) -> CastId` + (`:92`) — **returns `CastId`, not `Result`.** *"the thinker reports (casts) and + moves on — 'melden macht frei' — it is **NEVER refused**."* +- *"There is no confirmation bookkeeping here — by ruling (operator, 2026-07-17, + `E-ACK-ELIMINATED-1`)."* Durability evidence is the written row's own + `LanceVersion`, read via `QueryReference::at` + deinterlace. +- *"**Do not add a confirmation ledger (a persisted id→version map) under any + name.**"* +- *"`P` is a DESCRIPTOR — (mailbox, dirty row-range, cycle) — never owned delta + bytes. Deltas stay in the SoA backing store; the sink reads them through + `NodeRowPacket::as_le_bytes` at flush time."* +- Intent records are *"ephemeral staging, not a durable WAL"*. + +### Two Results that look alike and are not + +| | `try_advance_phase -> Result` (`soa_view.rs:309`) | `observe -> Result<_, CapacityExceeded>` (PR #854, withdrawn) | +|---|---|---| +| what it decides | is this transition **legal in the lifecycle DAG** (`from.can_transition_to(to)`) | did a **shared allocator run out of slots** | +| cost | one enum compare | O(n≤64) linear scan + possible push, per update | +| shared mutable state | none | `&mut self.registry` on **every** observe | +| category | pre-emission legality guard — documented as *"the in-stream synchronous kanbanstep… fired inline"* | **a refusal on a path documented as never-refusing** | + +The first is conformant. The second inserts resource-exhaustion control flow into +fire-and-forget, and forces every caller to branch — which is exactly why the +examples reached for `.unwrap()` (panic) and `reach_out_integrate` reached for +`let _ =` (swallow). **Both reviewer-reported symptoms are the shape, not the code.** + +`SourceRegistry` is a **persistent id→slot map that gates the path and can refuse** +— the forbidden confirmation-ledger shape under another name. + +### What belongs where +Evidence-overlap checks, provenance accumulation, receipt creation, settlement +computation and causal classification are all **accumulate-and-amortise** shaped: +they are BatchWriter work. Anything that must run *before* emission has to be +O(1), allocation-free, and unable to fail. + +--- + ## 6. Ownership / persistence / replay - `SoaEnvelope` (`soa_envelope.rs:170`) — the owner of the in-place backing store. @@ -291,6 +340,14 @@ it can be substrate-conformant**, however well-typed it is. 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 + fire-and-forget emission.** `cast()` is *"NEVER refused"*. Work on that path + must be O(1), allocation-free, and infallible; everything else is + BatchWriter work. Budget: ~8.6 µs per update (64k / 550 ms). +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. --- From 09d97f5a057cb86319ff561b20388068efa4bcc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:24:22 +0000 Subject: [PATCH 12/16] =?UTF-8?q?docs:=20primer=20=E2=80=94=20parallel=20c?= =?UTF-8?q?ohort=20SLA,=20temporal=20deinterlacing,=20two=20retractions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the execution model and labels every claim by certainty. RETRACTED: the per-update budget. 550ms/64,000 = 8.6us assumed serial execution. The SLA is a wall-clock envelope for a PARALLEL cohort — ~64k updates occupy one overlapping interval and their costs are not summed. The question is whether the cohort converges, deinterlaces and becomes available within 550ms, never whether each update finishes in 8.6us. RETRACTED: calling SourceRegistry "the forbidden confirmation-ledger shape under another name". E-ACK-ELIMINATED-1 forbids a persisted id->version map for write durability; SourceRegistry is an id->slot map for source interning — structurally similar, not covered by that ruling. The contention and synchronous-refusal objections stand on their own and are now framed correctly: the danger is serializing a parallel cohort around one mutable allocator and making slot meaning depend on insertion order (hence replay-unstable under concurrency), not the cost of an O(<=64) scan. CORRECTED: HLC is not merely reserved. deinterlace sorts on (hlc_tick ?? lance_version, lance_version) with a documented Codex-P2 fix on the fallback, gates each row through .dispatchable(mode), and is covered by four tests including no_hindsight_streamed_known_game. The mechanism is implemented and tested; only production wiring is dormant (no call site sets a non-zero server_id). Certainty labels now distinguish [OWNER-SPECIFIED] (the 64k/550ms envelope and compute masking — targets the code does not yet state; 550_000 appears only as LIBET_COMMIT_WINDOW_US, 64k only in onebrc-probe preset names) from [CODE-PROVEN] (ahead-firing casts, write coalescing, "never inspects P", the synchronous inline kanbanstep) from [CODE-AUDIT] (parallelism lives in ractor actor-per-mailbox and SoA row-batch delivery; no rayon/par_iter exists in the substrate crates). Adds rules 18-20: physical completion order is not epistemic order; Lance versions are the standing wave, not durability acks; repository absence is not disproof of owner-specified architecture — report where an invariant is not yet explicit. No code changed. --- .../identity-temporal-evidence-primer.md | 165 +++++++++++++----- 1 file changed, 118 insertions(+), 47 deletions(-) diff --git a/.claude/knowledge/identity-temporal-evidence-primer.md b/.claude/knowledge/identity-temporal-evidence-primer.md index 3478656af..cd439eff5 100644 --- a/.claude/knowledge/identity-temporal-evidence-primer.md +++ b/.claude/knowledge/identity-temporal-evidence-primer.md @@ -104,7 +104,7 @@ convergence). Neither is universally right — that is why both accessors exist. | 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"* | **RESERVED-DORMANT** — no code path sets non-zero `server_id` or `Some(hlc_tick)`; `default()`/`at()` hardcode `0`/`None` (`temporal.rs:126-151`) | +| 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** | @@ -229,52 +229,114 @@ it can be substrate-conformant**, however well-typed it is. --- -## 5.7 The execution model — fire-and-forget, and what PR #854 broke +## 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. ``` -V3 GUID → SoA-owned state → SoA-owned Kanban transition → fire-and-forget → async BatchWriter +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. ``` -Budget: **~64,000 updates / 550 ms ⇒ ~8.6 µs per logical update**, and that is the -budget for the *whole* transition, not for one helper. - -**`BatchWriter

`** — `lance-graph-planner/src/batch_writer.rs:55`. Its module doc -IS the contract: - -- `pub fn cast(&mut self, on_behalf: MailboxId, moves: Vec, payload: P) -> CastId` - (`:92`) — **returns `CastId`, not `Result`.** *"the thinker reports (casts) and - moves on — 'melden macht frei' — it is **NEVER refused**."* -- *"There is no confirmation bookkeeping here — by ruling (operator, 2026-07-17, - `E-ACK-ELIMINATED-1`)."* Durability evidence is the written row's own - `LanceVersion`, read via `QueryReference::at` + deinterlace. -- *"**Do not add a confirmation ledger (a persisted id→version map) under any - name.**"* -- *"`P` is a DESCRIPTOR — (mailbox, dirty row-range, cycle) — never owned delta - bytes. Deltas stay in the SoA backing store; the sink reads them through - `NodeRowPacket::as_le_bytes` at flush time."* -- Intent records are *"ephemeral staging, not a durable WAL"*. - -### Two Results that look alike and are not - -| | `try_advance_phase -> Result` (`soa_view.rs:309`) | `observe -> Result<_, CapacityExceeded>` (PR #854, withdrawn) | -|---|---|---| -| what it decides | is this transition **legal in the lifecycle DAG** (`from.can_transition_to(to)`) | did a **shared allocator run out of slots** | -| cost | one enum compare | O(n≤64) linear scan + possible push, per update | -| shared mutable state | none | `&mut self.registry` on **every** observe | -| category | pre-emission legality guard — documented as *"the in-stream synchronous kanbanstep… fired inline"* | **a refusal on a path documented as never-refusing** | - -The first is conformant. The second inserts resource-exhaustion control flow into -fire-and-forget, and forces every caller to branch — which is exactly why the -examples reached for `.unwrap()` (panic) and `reach_out_integrate` reached for -`let _ =` (swallow). **Both reviewer-reported symptoms are the shape, not the code.** - -`SourceRegistry` is a **persistent id→slot map that gates the path and can refuse** -— the forbidden confirmation-ledger shape under another name. -### What belongs where -Evidence-overlap checks, provenance accumulation, receipt creation, settlement -computation and causal classification are all **accumulate-and-amortise** shaped: -they are BatchWriter work. Anything that must run *before* emission has to be -O(1), allocation-free, and unable to fail. +### ⚠ 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. + +### Certainty labels +- **[OWNER-SPECIFIED]** ~64k updates execute in parallel within a 550 ms + wall-clock SLA. *Not located in code* — `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. **This is a target the code does not yet state.** +- **[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. --- @@ -340,14 +402,23 @@ O(1), allocation-free, and unable to fail. 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 - fire-and-forget emission.** `cast()` is *"NEVER refused"*. Work on that path - must be O(1), allocation-free, and infallible; everything else is - BatchWriter work. Budget: ~8.6 µs per update (64k / 550 ms). +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. --- From ed2bb333adb2ca96a5be831aa1eabf6bbe90e51d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:26:52 +0000 Subject: [PATCH 13/16] =?UTF-8?q?docs:=20primer=20=E2=80=94=20capacity=20i?= =?UTF-8?q?s=20an=20addressing/cache=20envelope,=20not=20a=20concurrency?= =?UTF-8?q?=20ceiling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separates four dimensions that had been collapsing into each other: address width, active population, cache envelope, and wall-clock SLA. None may be substituted for another — 16-bit addressing does not mean 64k sequential operations, 64k parallel updates does not imply a 550ms/64k per-update latency, 32 MiB cache-friendliness does not mean larger populations are unaddressable, and 4M addressable does not mean the same cache behaviour as 64k. Records the scaling ladder: ~64k (16-bit, 32 MiB, preferred cache-friendly) → ~262k (18-bit, 128 MiB) → ~4M (2 GiB, memory-resident rather than L3-resident). The ownership model is scale-invariant across these; what changes is physical locality and memory latency. Above the cache envelope the expected structural response is partitioning into several SoAs, each keeping its own Kanban — but no multi-SoA partitioning policy is stated in the substrate crates, so that is marked CODE-AUDIT REQUIRED rather than asserted. The capacity figures are NOT arbitrary and are no longer labelled purely owner-specified: they are exactly derivable from a code-asserted constant. canonical_node.rs:735 asserts size_of::() == 512, and 2^16 x 512 B = 32 MiB while 2^22 x 512 B = 2 GiB, both landing on the nose. Recorded as an ARITHMETIC CHECK. Two caveats attached so the numbers are not misapplied. NodeRow's 512 B is the canonical node row (key|edges|value); MailboxSoA's per-row footprint is a different and materially smaller sum, and must be computed before the 32 MiB figure is applied to a mailbox SoA. And 16-bit addressing is not expressed in the SoA API at all — MailboxSoA is with row: usize accessors, and the u16 fields present (entity_type, expert) are values, not addresses. Also drops the earlier "64k is a hard ceiling" framing wherever it appeared. No code changed. --- .../identity-temporal-evidence-primer.md | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/.claude/knowledge/identity-temporal-evidence-primer.md b/.claude/knowledge/identity-temporal-evidence-primer.md index cd439eff5..68883c7e2 100644 --- a/.claude/knowledge/identity-temporal-evidence-primer.md +++ b/.claude/knowledge/identity-temporal-evidence-primer.md @@ -256,12 +256,65 @@ 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. + +**⚠ Two caveats before reusing those numbers:** +1. `NodeRow` (512 B = `key 16 | edges 16 | value 480`) is the **canonical node** + row. `MailboxSoA`'s per-row footprint is a **different, materially smaller** + sum (`f32 + u8 + u32 + u32 + CausalEdge64 + qualia + meta + u16 + u64 + u16 + + u8`, plus three `[u8; 12]` style lanes). **Compute it before applying the + 32 MiB figure to a mailbox SoA** — the two row shapes are not interchangeable. +2. **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 16-bit address width is [OWNER-SPECIFIED]; the + code does not encode it as a row-index type. + +### 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* — `550_000` appears only as + 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. **This is a target the code does not yet state.** + `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`; 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"*; From 03432c356025482ec9d78e0e5e15e4596e72baa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:31:04 +0000 Subject: [PATCH 14/16] =?UTF-8?q?docs:=20primer=20=E2=80=94=20retract=20th?= =?UTF-8?q?e=20"MailboxSoA=20row=20is=20smaller"=20caveat;=20512=20B=20is?= =?UTF-8?q?=20uniform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator correction, verified in code. Every SoA row reserves 512 bytes on paper, always; only the baked Lance columnar form may omit empties, and that is storage-layer compression which does not change the logical stride. The previous revision 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 to a mailbox SoA. That was a category error: it listed the hot columns and mistook them for the whole row. canonical_node.rs is explicit that they are tenants INSIDE the reserved slab — `pub value: [u8; 480], // 32..512 (reserved — comes after)` (:729); "32..512 are the class-resolved value slab. Sum = 512 = stride." (:764); and the 480-byte value is named as carrying energy/meta/qualia/entity_type (:708) — precisely the MailboxSoA columns. EdgeBlock likewise: "the 16 bytes are ALWAYS reserved (zeroed when unused)" (:640), "always reserved, never shrunk" (:8), under RESERVE-DON'T-RECLAIM (:16). soa_envelope.rs:124 enforces it with an error variant for a column-width sum that does not equal the declared row stride. NodeRow is the AoS view and MailboxSoA the SoA projection of the same 512-byte row. So the 32 MiB / 2 GiB arithmetic applies uniformly, with no per-crate recomputation. The second caveat stands unchanged: 16-bit addressing is not expressed in the SoA API — MailboxSoA is with row: usize accessors, and the u16 fields present are values, not addresses. No code changed. --- .../identity-temporal-evidence-primer.md | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/.claude/knowledge/identity-temporal-evidence-primer.md b/.claude/knowledge/identity-temporal-evidence-primer.md index 68883c7e2..d12f29485 100644 --- a/.claude/knowledge/identity-temporal-evidence-primer.md +++ b/.claude/knowledge/identity-temporal-evidence-primer.md @@ -276,17 +276,37 @@ footprints are *exactly* derivable from a code-asserted constant: So the capacity ladder is **internally consistent with the canonical node**, not arbitrary. -**⚠ Two caveats before reusing those numbers:** -1. `NodeRow` (512 B = `key 16 | edges 16 | value 480`) is the **canonical node** - row. `MailboxSoA`'s per-row footprint is a **different, materially smaller** - sum (`f32 + u8 + u32 + u32 + CausalEdge64 + qualia + meta + u16 + u64 + u16 + - u8`, plus three `[u8; 12]` style lanes). **Compute it before applying the - 32 MiB figure to a mailbox SoA** — the two row shapes are not interchangeable. -2. **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 16-bit address width is [OWNER-SPECIFIED]; the - code does not encode it as a row-index type. +**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 | @@ -313,8 +333,9 @@ stated in the substrate crates.)* *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`; the *address widths* themselves are not encoded - in any row-index type (`row: usize` throughout). + `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"*; From ebf0b09f56061f227b8c106beb1e2d409496e3b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:35:15 +0000 Subject: [PATCH 15/16] contract: fix duplicate #[must_use] on SupportLedger::profile (CI -D warnings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My profile() rewrite inserted a new doc block BETWEEN the existing #[must_use] and the fn, producing doc / attr / doc / attr — so the attribute was specified twice. `unused_attributes` is deny-by-default under CI's -D warnings, and it is already a future-hard-error, so this broke the build. Merged into one doc block with one attribute; the original summary line is kept as the first line. Process note: I ran cargo test, cargo check --all-targets and cargo fmt locally but NOT `cargo clippy -- -D warnings`, which is the gate CI actually runs. Test and check both accept a duplicate attribute; only clippy's deny-level lint set rejects it. Verified with the CI-equivalent command this time. Full -D warnings sweep of every crate this branch touches: lance-graph-contract clean (was the failure) lance-graph-planner clean deepnsm-v2 clean Two pre-existing failures found in files this branch does NOT touch, reported rather than fixed: lance-graph-ontology/src/hydrators/owl.rs (deprecated oxrdf::Subject) reached via supervisor / cognitive-shader-driver, and lance-graph-cognitive grammar/triangle.rs (derivable Default impl). contract 1093 green. --- crates/lance-graph-contract/src/causal_audit.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/lance-graph-contract/src/causal_audit.rs b/crates/lance-graph-contract/src/causal_audit.rs index a41b90018..80f21ad13 100644 --- a/crates/lance-graph-contract/src/causal_audit.rs +++ b/crates/lance-graph-contract/src/causal_audit.rs @@ -339,9 +339,9 @@ impl SupportLedger { } /// Project to the compact [`SupportProfile`] for the SIMD / fixed-width path. - #[must_use] - /// Project the ledger. ONE pass — the earlier form rescanned every receipt - /// nine times through `distinct_sources_for` (codex/CodeRabbit, PR #854). + /// + /// 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. From b0711f84f990525656778affa03330943a8e8d69 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 10:39:06 +0000 Subject: [PATCH 16/16] address open review threads: fmt wrapping, fence language, and a wrong claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three threads were still open after the partial revert; six others went outdated or resolved with it. 1. counterfactual.rs substitute_binding — hand-wrapped the 103-column signature to rustfmt's shape (CodeRabbit). Hand-wrapped rather than formatted because `cargo fmt` CANNOT RUN on this crate at all: container_bs/mod.rs:35 declares `#[cfg(test)] pub mod tests;` with no container_bs/tests.rs and no container_bs/tests/ directory. rustfmt walks modules regardless of cfg, so it aborts with "failed to resolve mod `tests`" and the entire crate is unformattable. That declaration is PRE-EXISTING and not in this branch's diff; reported rather than fixed, because deleting it versus restoring a missing tests.rs are different repairs and I cannot tell which was intended. 2. EPIPHANIES.md — added the `text` language to the bare fence (markdownlint MD040). 3. LATEST_STATE.md — CORRECTED A WRONG CLAIM OF MY OWN. The "Rollback ≠ endorsement" note said the restored Stamp "introduces no breaking API and no global ceiling". The second half is false: Stamp::source(id) = 1 << (id % 64) is a bounded 64-slot horizon in which ids 0 and 64 alias. What the rollback removes is the runtime capacity FAILURE on the pre-cast path, not the bounded membership semantics. Now says exactly that, keeps the conservative-direction note (aliasing manufactures false overlap, never false disjointness, so revision under-pools rather than double-counts), and retains exact evidence-event identity as the standing remedy. Worth recording: the primer states this distinction precisely and the board file contradicted it. Same session, same author, two files — the sloppier wording landed where a reader is most likely to stop. cognitive world tests 5 green; contract clippy -D warnings clean. --- .claude/board/EPIPHANIES.md | 2 +- .claude/board/LATEST_STATE.md | 2 +- crates/lance-graph-cognitive/src/world/counterfactual.rs | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 278990de7..a5b83b026 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -3,7 +3,7 @@ **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 ``` diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 10f1d3e8e..6a6748b43 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -11,7 +11,7 @@ **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. It is the pre-PR baseline, kept only because it introduces no breaking API and no global ceiling while the correct model is designed. +**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. diff --git a/crates/lance-graph-cognitive/src/world/counterfactual.rs b/crates/lance-graph-cognitive/src/world/counterfactual.rs index ac2cc9ee9..3829e4b50 100644 --- a/crates/lance-graph-cognitive/src/world/counterfactual.rs +++ b/crates/lance-graph-cognitive/src/world/counterfactual.rs @@ -80,7 +80,10 @@ pub struct BindingSubstitution { /// ``` /// /// This is a substitution, NOT `do(X = x)` — see the module docs. -pub fn substitute_binding(world: &Fingerprint, substitution: &BindingSubstitution) -> SubstitutedWorld { +pub fn substitute_binding( + world: &Fingerprint, + substitution: &BindingSubstitution, +) -> SubstitutedWorld { // Unbind original, bind replacement let new_state = world .bind(&substitution.original) // Unbind: cancels original via XOR