From ef142dc9bf0f7d9aa2f78282cd45a2c580b24b17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:04:35 +0000 Subject: [PATCH 1/6] D-TEH-2: harvest the ghost prior into the planner as nars/ghost_prior.rs; delete thinking-engine ghosts.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lingering-trace field (Friston prior over atoms: imprint / bias / prediction / free_energy) moves from the excluded lab crate into the planner as a per-thought GhostPrior over the contract's WisdomMarker and GhostEcho — no singleton, no new contract type, no new dependency. The floor the two sources disagreed on (source: drop below 0.001 and prune; contract: clamp at 0.1 forever) is carried as PriorFloor and decided by a pre-registered calibration gate on a recurrence fixture. The gate rejected the first-declared Trace floor: once the remembered pattern ages past its prune point the Trace prior cannot tell a recurrence from a shift (discrimination 0.0000) while the Marker prior keeps 0.0188. Default = Marker. Twelve tests, incl. two-sided free energy under both floors and the decay constant load-bearing both ways. Lab crate: ghosts.rs and its only consumer examples/think.rs deleted; persona / world_model / awareness_dto import GhostEcho from the contract (TD-GHOST-ECHO-DUP-1 resolved); Agent no longer owns a trace field — DTO builders take the thought's prior summary as arguments. cargo check --lib --examples clean; persona/world_model/awareness_dto tests 14/14. Planner clippy -D warnings + fmt clean. Board: EPIPHANIES E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1, LATEST_STATE inventory delta (+ the #1141 merge record), PR_ARC entry, STATUS_BOARD D-TEH-2 Shipped / D-HOUSE-4 unblocked, TECH_DEBT status line, plan §5 + §3 W2 result; SUPERSESSION-INDEX regenerated. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PFnYKqw6d7TTiB9cT8eFdK --- .claude/board/EPIPHANIES.md | 14 + .claude/board/LATEST_STATE.md | 8 + .claude/board/PR_ARC_INVENTORY.md | 10 + .claude/board/STATUS_BOARD.md | 4 +- .claude/board/SUPERSESSION-INDEX.md | 2 +- .claude/board/TECH_DEBT.md | 2 +- .../thinking-engine-harvest-closure-v1.md | 4 +- crates/lance-graph-contract/src/escalation.rs | 9 +- .../src/nars/ghost_prior.rs | 613 ++++++++++++++++++ crates/lance-graph-planner/src/nars/mod.rs | 2 + crates/thinking-engine/Cargo.lock | 35 - crates/thinking-engine/examples/think.rs | 273 -------- crates/thinking-engine/src/awareness_dto.rs | 8 +- crates/thinking-engine/src/domino.rs | 2 +- crates/thinking-engine/src/ghosts.rs | 339 ---------- crates/thinking-engine/src/lib.rs | 1 - crates/thinking-engine/src/persona.rs | 41 +- crates/thinking-engine/src/world_model.rs | 12 +- 18 files changed, 694 insertions(+), 685 deletions(-) create mode 100644 crates/lance-graph-planner/src/nars/ghost_prior.rs delete mode 100644 crates/thinking-engine/examples/think.rs delete mode 100644 crates/thinking-engine/src/ghosts.rs diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 6865afd49..44eada80b 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,17 @@ +## 2026-09-02 — E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1 — D-TEH-2: the ghost prior landed in the planner, and the pre-registered gate overruled the floor its author had declared + +**Status:** FINDING (measured, `crates/lance-graph-planner/src/nars/ghost_prior.rs`, test `calibration_gate_picks_the_default_floor`). **Confidence:** High on the fixture; the fixture is one recurrence shape (256 atoms, a 3-atom pattern, 0–30 stale patterns, ages 0–60). + +**What landed.** `thinking_engine::ghosts::GhostField` is harvested as planner `nars::ghost_prior::GhostPrior` — per-thought / per-mailbox, never a singleton — over the contract's `WisdomMarker` / `GhostEcho`. `ghosts.rs` and its only consumer, `examples/think.rs`, are deleted; the lab crate's `persona` / `world_model` / `awareness_dto` now import `GhostEcho` from the contract, which closes `TD-GHOST-ECHO-DUP-1` (one declaration of the eight echoes remains). The lab `Agent` no longer OWNS a trace field; DTO builders take the thought's prior summary as an argument — the V3 ownership rule applied to a process-wide field. + +**The finding.** The closure plan's §1c row said the port must declare which floor it adopts — the source's (drop below 0.001, prune) or the contract's (clamp at 0.1 forever) — and let a calibration gate decide: free-energy discrimination between a RECURRENCE and a SHIFT after the prior has aged. The first declaration was the source's `Trace` floor. The gate rejected it. Discrimination (`fe_shift − fe_recurrence`), Trace vs Marker: with no stale memory the two are identical (0.0188); with 30 stale patterns and age 0, 0.0002 vs 0.0026; with 30 stale patterns and age 20 or 60, **0.0000 vs 0.0188**. Under the Trace floor the remembered pattern is pruned once older than ~42 cycles (`0.8·0.85ᵏ < 0.001`) and the prior can no longer tell familiar from new at all. Under the Marker floor it survives at 0.1 and the discrimination holds — at the price of a noisier baseline (absolute free energy 0.35 vs 0.07 with 30 stale patterns). The anchoring alarm (D-HOUSE-4) reads the difference, not the level, so the default is `Marker`: the contract's `felt_parse:70` semantics ("never fully vanishes"), now measured rather than inherited. + +**Why it is worth an entry.** The author of the port had a preference (the bias-semantics floor) and wrote the gate before running it; the gate's assertion is on the DECLARED default, so the preference could not survive the fixture without the test going red. That is the falsifiability rule doing what it exists for — a knob that is set by a measurement it can lose, not by the name of whoever set it. The shape generalises: when two shipped sources disagree on a constant, encode the disagreement as a parameter and let a pre-registered fixture pick, with the assertion on the pick. + +**Fences carried forward.** Lingering trace ≠ counterfactual rung (`E-A-GHOST-TRACE-IS-NOT-THE-COUNTERFACTUAL-LANE-1`): nothing in `ghost_prior` reads or writes the −6 lane. The floor decision is scoped to this fixture; D-HOUSE-4 should re-run `calibration::discrimination` on its own recurrence shape before trusting the default there. + +Refs: `thinking-engine-harvest-closure-v1` §1c (ghost row), §3 W2 result, §5 D-TEH-2; `house-differential-style-v1` D-HOUSE-4; `contract::escalation::{GhostEcho, WisdomMarker}`; `TD-GHOST-ECHO-DUP-1` (resolved); `TD-GHOST-TIER-NAME-COLLISION-1` (still open, pays when `counterfactual.rs` is next touched). + ## 2026-09-02 — E-PILLAR-11-PUBLISHED-BOUND-NEEDS-ITS-OWN-NUMERIC-GUARD-1 **Status:** FINDING (measured) — closes the Hambly-Lyons edition-constant arc. diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 10a7ed0b1..5f5cfb70a 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,11 @@ +## 2026-09-02 — branch (D-TEH-2, after #1141 merged): ghost prior harvested into the planner — INVENTORY DELTA + +- MERGED #1141 (D-HOUSE-1, `3102776`): no contract type; planner example `house_differential.rs` + plan/board records (see PR_ARC). +- ADDED planner `lance_graph_planner::nars::ghost_prior` — `GhostPrior` (per-thought Friston prior over atoms: `imprint` / `bias` / `prediction` / `free_energy` / `active_count` / `prune` / `summary` / `tick` / `echo_for`), `PriorFloor { Marker, Trace }` with `DEFAULT = Marker` (decided by the calibration gate, see EPIPHANIES `E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1`), `Trace` (atom + `WisdomMarker` + birth cycle), `calibration::{recurrence_fixture, discrimination}` (library code so a consumer can re-run the gate on its own fixture). Re-exported as `nars::{GhostPrior, PriorFloor}`. 12 tests. Consumes contract `escalation::{GhostEcho, WisdomMarker}` only; no new dependency, no new contract type, no tenant, no layout change. +- REMOVED (lab crate, excluded from the workspace) `thinking_engine::ghosts` (`GhostType`, `Ghost`, `GhostField`) and `examples/think.rs` (its only consumer). `persona::Agent` lost its `ghosts` field; `Agent::to_dto(ghost_count)`, `Agent::self_model(.., ghost_count, dominant_ghost)` and `WorldModelDto::build(.., trace_count, dominant_trace)` take the thought's prior summary as arguments; `SelfModelDto::dominant_ghost_type`, `GestaltState::dominant_trace`, `QualiaDto::traces` are typed over `contract::escalation::GhostEcho`. Lab crate: `cargo check --lib --examples` clean (pre-existing warnings only), persona/world_model/awareness_dto tests 14/14. +- RESOLVED `TD-GHOST-ECHO-DUP-1` (one declaration of the eight echoes remains — the contract's). +- UNCHANGED: `contract::escalation` shapes (`GhostEcho`, `WisdomMarker::{FLOOR, DECAY, intensity_at}`) — only a doc comment; the ALU artery files (`dto.rs`, `engine_bridge.rs`, `cognitive_shader.rs`, `mailbox_soa.rs`) untouched. + ## 2026-09-02 — branch (D-TEH-1, first code wave after #1138): `bridge_gate` moved to the contract — CONTRACT INVENTORY DELTA - ADDED `lance_graph_contract::bridge_gate` — the cross-tenant authorization injection point, moved from `thinking_engine::bridge_gate` with all seven public items together and identical shapes: `CognitiveOpKind` (4 variants, `repr(u8)`), `CognitiveAuthResult { Allow, Deny, Escalate }` + `is_allowed/is_denied`, `CognitiveBridgeError { Denied, Escalation }`, `auth_to_result`, the `CognitiveBridgeGate: Send + Sync` trait (3 sync methods), `PassthroughGate`, `DenyAllGate`. Zero-dep (std only). Its 9 unit tests moved with it; the engine-side `pure_ops_dont_touch_gate` stays in thinking-engine next to the lens modules it calls. diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index 9eaade3d3..d3031b917 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -10,6 +10,16 @@ > census §8.3 trap 10: read the body FIRST, then open for write — never > inline both in one expression. +## 2026-09-02 — lance-graph branch `claude/medcare-rs-continue-6nhbxn` (D-TEH-2 PR, after #1141) — ghost prior harvested into the planner; `ghosts.rs` deleted + +- **Added:** `crates/lance-graph-planner/src/nars/ghost_prior.rs` — `GhostPrior`, `PriorFloor`, `Trace`, `calibration::{recurrence_fixture, discrimination}`, 12 tests (monotone decay to each floor with the inert cycle derived from the constant, decay-constant load-bearing both ways, two-sided free energy under both floors, the calibration gate on the declared default, anti-vacuity that the floors differ, prediction shape, imprint cap, echo rule, independence of two priors); `pub mod ghost_prior` + re-export in `nars/mod.rs`. EPIPHANIES `E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1`. +- **Removed:** `crates/thinking-engine/src/ghosts.rs`, `crates/thinking-engine/examples/think.rs`; `pub mod ghosts` from the lab `lib.rs`. +- **Changed (lab crate):** `persona.rs` (`Agent` without a trace field; `to_dto` / `self_model` take the prior summary; `SelfModelDto` over `GhostEcho`), `world_model.rs` (`build` takes `trace_count` + `dominant_trace`; `GhostEcho`), `awareness_dto.rs` (`GhostEcho`), `domino.rs` (doc). Contract `escalation.rs`: `GhostEcho` doc comment (retired-mirror note). TECH_DEBT `TD-GHOST-ECHO-DUP-1` → RESOLVED. Plan §5 D-TEH-2 → Shipped, §3 W2 result; STATUS_BOARD D-TEH-2 Shipped, D-HOUSE-4 unblocked. +- **Measured:** calibration gate rejected the first-declared `Trace` floor — discrimination 0.0000 vs `Marker` 0.0188 at 30 stale patterns / age 20 and 60 (equal at 0.0188 with no stale memory); absolute baseline 0.35 vs 0.07. Default = `Marker`. Planner clippy `-D warnings` + fmt clean; lab `cargo check --lib --examples` clean; lab tests 14/14. +- **Locked:** per-thought ownership (no singleton field); the lingering-trace / counterfactual-lane fence; the floor as a measured parameter, not an inherited constant. +- **Deferred:** D-HOUSE-4 (unblocked, not started — should re-run `calibration::discrimination` on its own fixture); D-TEH-3 (math → jc), D-TEH-4/5; `TD-GHOST-TIER-NAME-COLLISION-1` waits for the next touch of `counterfactual.rs`. +- **Confidence:** High. + ## 2026-09-02 — lance-graph branch `claude/medcare-rs-continue-6nhbxn` (D-HOUSE-1 PR, after #1139) — PROBE-HOUSE-DIFFERENTIAL-1 run and reported - **Added:** `crates/lance-graph-planner/examples/house_differential.rs` (the probe; no library surface): fixture, arms A0 / A1 / A1c / A2-S0 / A2-S3 / A2 (variant 1) / A2' (variant 2), size-preserving null with far-parent re-owning, guards G1–G4, per-variant verdict. Plan §4 RESULT; EPIPHANIES `E-THE-PERIPHERY-OF-A-STRATUM-IS-THE-OTHER-STRATA-1`. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 50a019b00..6a341e24b 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -9,7 +9,7 @@ | D-HOUSE-1d | aperture hypothesis (the "humor" label): incongruity → `k` across passes; a hypothesis until its operator is defined and measured (plan §4b) | Not scheduled — re-scoped 2026-09-02: the knob is the strata budgets, not `k` over an excluded set | | D-HOUSE-2 | discriminating-evidence readout (clone arena, observe hypothetical premise, report largest rank displacement) | Unblocked by the D-HOUSE-1 base-path PASS (2026-09-02); not started | | D-HOUSE-3 | elimination predicate over `truth`/`contradiction`/`stamp` + never-re-enter falsifier | Unblocked by the D-HOUSE-1 base-path PASS (2026-09-02); not started | -| D-HOUSE-4 | anchoring alarm (free energy between wisdom-marker prior and evidence) | Gated on `thinking-engine-harvest-closure-v1` D-TEH-2 | +| D-HOUSE-4 | anchoring alarm (free energy between wisdom-marker prior and evidence) | Unblocked 2026-09-02 (D-TEH-2 shipped: planner `GhostPrior::free_energy` is the alarm's input); not started | | D-HOUSE-5 | the style→recipe edge (O2) with House as its first program | D-HOUSE-1 half unblocked (2026-09-02, base-path PASS; the edge selects STRATA, CAS-down as the board's feeder); still gated on the D-TSC-2/3 mint | | D-HOUSE-6 | consumer pointer: `DiffRow` READ fields `eliminated` / `discriminator` (MedCare-rs) | pointer only, out of scope here | @@ -19,7 +19,7 @@ |---|---|---| | D-TEH-0 | census: live footprint measured (one required consumer via `bridge_gate`; one optional via `with-engine`), 51-file fate table, open-row reconciliation, four-wave closure, idea harvest, four rulings asked | **Shipped (plan-only, this PR)** | | D-TEH-1 | W1: `bridge_gate` (seven items) → `lance_graph_contract::bridge_gate`; callcenter re-imports and drops the path dep; thinking-engine keeps a re-export shim | **Shipped 2026-09-02** (before: required dep, 6 crossing sites, dep-drop alone fails 6 × E0433; after: zero thinking-engine deps in callcenter metadata, contract 1303/1303, callcenter 156/156, driver default + `with-engine` green, ALU artery files byte-identical). `with-engine` re-point deferred by stop condition: D-TTV-1 not landed | -| D-TEH-2 | W2: ghost prior → planner `nars/ghost_prior.rs` over `WisdomMarker`, per-thought, two-sided falsifiers; crate `ghosts.rs` deleted | Queued — consumer D-HOUSE-4 | +| D-TEH-2 | W2: ghost prior → planner `nars/ghost_prior.rs` over `WisdomMarker`, per-thought, two-sided falsifiers; crate `ghosts.rs` deleted | **Shipped 2026-09-02** — `GhostPrior`/`PriorFloor` in planner `nars/ghost_prior.rs`, 12 tests incl. the calibration gate (default floor = `Marker`, the gate reversed the first declaration); `ghosts.rs` + `think.rs` deleted; TD-GHOST-ECHO-DUP-1 resolved; consumer D-HOUSE-4 unblocked | | D-TEH-3 | W2: calibration MATH → `jc` (ruling 4, 2026-09-02: lift if correct, perfect in jc if not; crate copies die); glue stays in the lab crate; `semantic_chunker` / `spiral_segment` decided by their falsifiers | Queued | | D-TEH-4 | W3: ENTROPY M8 engine collapse with dtype parity suite; 5 cascade shapes + 3 lens modules collapse | Queued | | D-TEH-5 | W4: residue deleted, crate renamed `thinking-lab` with a `--manifest-path` CI line; §2 rows closed; `TD-THINKING-ENGINE-EXCLUDED-DEBT-1` paid | Queued — closes the chapter | diff --git a/.claude/board/SUPERSESSION-INDEX.md b/.claude/board/SUPERSESSION-INDEX.md index ebee8aa3e..061d11cae 100644 --- a/.claude/board/SUPERSESSION-INDEX.md +++ b/.claude/board/SUPERSESSION-INDEX.md @@ -55,7 +55,7 @@ coverage instead. | `dispatch_busdto` | BLOCKED | — | 3 | 6 | 5 | | `persist_cycle` | BLOCKED | — | 10 | 6 | 5 | | `CognitiveMarkers` | REPURPOSE | `Commit` | 2 | 0 | 0 | -| `DominoCascade` | REPURPOSE | `Commit` | 8 | 0 | 0 | +| `DominoCascade` | REPURPOSE | `Commit` | 7 | 0 | 0 | | `GateDecision` | REPURPOSE | — | 25 | 27 | 24 | | `GateState` | REPURPOSE | — | 14 | 1 | 1 | | `MergeMode` | REPURPOSE | — | 8 | 13 | 12 | diff --git a/.claude/board/TECH_DEBT.md b/.claude/board/TECH_DEBT.md index 91e87705a..476a12bc1 100644 --- a/.claude/board/TECH_DEBT.md +++ b/.claude/board/TECH_DEBT.md @@ -1569,7 +1569,7 @@ multi-antecedent path for real. - **Severity:** P3 (cosmetic type-dup; no runtime correctness risk — the two enums are not exchanged across a crate boundary today) - **Surfaced in:** D-PERSONA-1 (`rung-persona-orchestration-v1` §2), 2026-05-26, branch `claude/splat3d-cpu-simd-renderer-MAOO0` -- **Status:** Open +- **Status:** RESOLVED 2026-09-02 (D-TEH-2): `thinking_engine::ghosts::GhostType` deleted with `ghosts.rs`; the lab crate's `persona` / `world_model` / `awareness_dto` import `contract::escalation::GhostEcho` directly. One declaration remains. - **Description:** `lance_graph_contract::escalation::GhostEcho` (8 variants: Affinity / Epiphany / Somatic / Staunen / Wisdom / Thought / Grief / Boundary) is a second declaration of the same 8 named ghost echoes already in `thinking_engine::ghosts::GhostType` (`crates/thinking-engine/src/ghosts.rs`). The duplication is *intentional and currently unavoidable*: `lance-graph-contract` is ZERO-DEP and cannot import the excluded `thinking-engine` crate, and the contract is the canonical "single source of truth for types" home for the wisdom-marker substrate (≤32 named identities per I-VSA-IDENTITIES). The two are NOT interchanged across a boundary today, so there is no silent-corruption risk (cf. I-LEGACY-API-FEATURE-GATED), only a naming/maintenance dup. - **Resolution (when thinking-engine joins the workspace):** make `thinking_engine::ghosts::GhostType` a re-export of (or `From`/`Into` with) `contract::escalation::GhostEcho`, retiring the thinking-engine copy. Until then, keep the variant sets identical (same 8, same order) so a future `transmute`/`From` bridge is trivial. - **Cross-ref:** `crates/lance-graph-contract/src/escalation.rs` (`GhostEcho`, `WisdomMarker`); `crates/thinking-engine/src/ghosts.rs` (`GhostType`, `GhostField`); `docs/TYPE_DUPLICATION_MAP.md`; `.claude/plans/rung-persona-orchestration-v1.md` §2 + §8. diff --git a/.claude/plans/thinking-engine-harvest-closure-v1.md b/.claude/plans/thinking-engine-harvest-closure-v1.md index 411b99f3d..c30dfc27c 100644 --- a/.claude/plans/thinking-engine-harvest-closure-v1.md +++ b/.claude/plans/thinking-engine-harvest-closure-v1.md @@ -180,6 +180,8 @@ inter-mailbox handoff type never revive; the `E-MORTON-CASCADE-V3-1` legacy arm is not deleted while that probe is open; no port of a fixed axis vocabulary by assertion (AXES_48 goes falsifier-first or stays LAB). +**W2 result (2026-09-02, D-TEH-2).** The ghost prior landed as planner `nars/ghost_prior.rs` — per-thought, over `WisdomMarker` / `GhostEcho`, no singleton. The floor question the §1c row left open was decided by the pre-registered calibration gate, and it decided AGAINST the first declaration: `Trace` (source 0.001 + prune) loses all discrimination between a recurrence and a shift once the remembered pattern is older than ~42 cycles, `Marker` (contract 0.1, never pruned) keeps it (0.0188) at the cost of a higher absolute free-energy baseline (0.35 vs 0.07 with 30 stale patterns). Default = `Marker`. The remaining W2 items (math → jc, `semantic_chunker`, `spiral_segment`) are D-TEH-3/-5 and untouched here. + ## 4. The good ideas — what the chapter leaves behind even where the code dies Kept as doctrine with a named present or future home, so the closure is a @@ -206,7 +208,7 @@ harvest and not an amputation. |---|---|---|---| | D-TEH-0 | census + fate table + open-row reconciliation + idea harvest (this plan) | plan + board rows | Shipped (this PR) | | D-TEH-1 | W1: `bridge_gate` (seven items) → `lance_graph_contract::bridge_gate`; callcenter re-imports and drops the path dep; thinking-engine keeps a re-export shim | contract + callcenter | **Shipped 2026-09-02** — edge measured before (required dep, 6 crossing sites, dep-drop fails 6 × E0433) and after (zero thinking-engine deps in callcenter metadata; 1303 + 156 tests, driver default + `with-engine` green). The `with-engine` re-point is NOT part of this wave: D-TTV-1 is Queued and the engine hook still lives in thinking-engine, so there is nothing to re-point it at (stop condition honoured). thinking-engine is now a leaf for every REQUIRED edge; the one remaining edge is the ALU's optional engine hook | -| D-TEH-2 | W2: ghost prior harvested as planner `nars/ghost_prior.rs` over `WisdomMarker`, per-thought, with two-sided falsifiers; crate `ghosts.rs` deleted | planner | Queued — consumer = D-HOUSE-4 | +| D-TEH-2 | W2: ghost prior harvested as planner `nars/ghost_prior.rs` over `WisdomMarker`, per-thought, with two-sided falsifiers; crate `ghosts.rs` deleted | planner | **Shipped 2026-09-02** — planner `nars/ghost_prior.rs` (`GhostPrior`, `PriorFloor`, `Trace`, `calibration::{recurrence_fixture, discrimination}`; 12 tests); `ghosts.rs` + `examples/think.rs` deleted; lab `persona`/`world_model`/`awareness_dto` re-pointed to `contract::escalation::GhostEcho` (TD-GHOST-ECHO-DUP-1 resolved). Calibration gate REVERSED the first-declared floor: `Marker` (0.1, never pruned) discriminates ≥ `Trace` (0.001) on every fixture row and strictly once the remembered pattern ages past its prune point (disc 0.0188 vs 0.0000 at 30 stale / age 20 and 60); default = `Marker`. Consumer D-HOUSE-4 unblocked | | D-TEH-3 | W2: calibration MATH → jc (ruling 4: compare, then lift or perfect in jc; crate copies deleted); `semantic_chunker` / `spiral_segment` decided by their falsifiers | jc / deepnsm-v2 / codec home | Queued | | D-TEH-4 | W3: M8 engine collapse with parity suite; cascade shapes and lens modules collapse | thinking-engine → the one engine | Queued (owns ENTROPY M8) | | D-TEH-5 | W4: residue deleted, crate renamed `thinking-lab` with a CI line; §2 rows closed; TD paid | workspace | Queued — closes the chapter | diff --git a/crates/lance-graph-contract/src/escalation.rs b/crates/lance-graph-contract/src/escalation.rs index b412cbf39..250ac9846 100644 --- a/crates/lance-graph-contract/src/escalation.rs +++ b/crates/lance-graph-contract/src/escalation.rs @@ -281,10 +281,11 @@ impl EpiphanyDetector { /// residue that biases future perception. ≤ 32 named identities, content lives /// in the store (I-VSA-IDENTITIES). /// -/// Canonical zero-dep home. Mirrors `thinking_engine::ghosts::GhostType` -/// (an excluded crate that cannot be a contract dependency); the two are to be -/// reconciled when thinking-engine joins the workspace — see -/// `TECH_DEBT.md` TD-GHOST-ECHO-DUP-1. +/// Canonical zero-dep home — and since D-TEH-2 (2026-09-02) the ONLY home: +/// the duplicate `thinking_engine::ghosts::GhostType` was retired with that +/// crate's `ghosts.rs`; the lab crate now imports this enum (closes +/// `TECH_DEBT.md` TD-GHOST-ECHO-DUP-1). The decaying per-thought field over +/// these echoes lives in the planner, `nars::ghost_prior::GhostPrior`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GhostEcho { /// Lingering pull toward a concept / person / thing. diff --git a/crates/lance-graph-planner/src/nars/ghost_prior.rs b/crates/lance-graph-planner/src/nars/ghost_prior.rs new file mode 100644 index 000000000..7793d0a82 --- /dev/null +++ b/crates/lance-graph-planner/src/nars/ghost_prior.rs @@ -0,0 +1,613 @@ +//! **Ghost prior** — the lingering-trace family as a per-thought Friston prior +//! (D-TEH-2, harvested from thinking-engine's former `ghosts.rs`; that file is +//! deleted, this module is its only home). +//! +//! A ghost is a trace a completed thought leaves on an atom: it decays +//! asymptotically and pre-weights which atoms the next cascade visits first. +//! The prior's `prediction` is the autocomplete cache; the gap between that +//! prediction and what actually lit up is **free energy** — the surprise that +//! says "update the prior" (Friston) and, for D-HOUSE-4, the anchoring alarm: +//! a prior that says X while the evidence says Y raises free energy, and the +//! prior is what yields, never the evidence. +//! +//! # Family fence (`E-A-GHOST-TRACE-IS-NOT-THE-COUNTERFACTUAL-LANE-1`) +//! +//! This is the LINGERING-TRACE family — `GhostEcho` (Staunen = persistent +//! wonder, Wisdom = harvested knowing, …) carried by `WisdomMarker`. It is NOT +//! the non-authoritative counterfactual rung (the −6 lane, +//! `deposit_counterfactual` / `CounterfactualMailbox`), whose own docs call +//! themselves "ghost-tier" (`TD-GHOST-TIER-NAME-COLLISION-1`). The rung may +//! consume a trace as a starting prior; it never is one, and nothing here reads +//! or writes the −6 lane. +//! +//! # Ownership — per thought, never a singleton +//! +//! A [`GhostPrior`] is owned by the thought (mailbox) that runs the cascade. +//! There is no global field, no `static`, no shared mutable sink; two mailboxes +//! have two priors. That is the V3 ownership rule applied to the source's +//! process-wide `GhostField`. +//! +//! # The floor — an intentional semantic decision, calibrated, not inherited +//! +//! The source and the contract's `WisdomMarker` agree on the decay rate (0.85 +//! per cycle) but not on the FLOOR: the source dropped a trace below 0.001 and +//! `prune` deleted it; `WisdomMarker::intensity_at` clamps at 0.1 forever. +//! Porting the field over the marker would raise long-lived bias by up to two +//! orders of magnitude, so this module carries BOTH readings as +//! [`PriorFloor`] and a calibration gate ([`calibration::discrimination`], +//! exercised in the tests) decides the default from the free-energy response +//! on a recurrence fixture: which floor lets a context SHIFT and a RECURRENCE +//! be told apart after the prior has aged. The declared default is +//! [`PriorFloor::DEFAULT`]; the test `calibration_gate_picks_the_default_floor` +//! fails if the other floor discriminates better on the fixture, so the choice +//! is falsifiable rather than asserted. +//! +//! **Result (2026-09-02, first run).** The first declaration was `Trace` and +//! the gate REJECTED it. Discrimination (`fe_shift − fe_recurrence`, 256 +//! atoms), Trace vs Marker: no stale patterns, age 0 → 0.0188 vs 0.0188; +//! age 20 → 0.0188 vs 0.0188; 30 stale patterns, age 0 → 0.0002 vs 0.0026; +//! age 20 → **0.0000 vs 0.0188**; age 60 → **0.0000 vs 0.0188**. Under the +//! Trace floor the remembered pattern is pruned once it is older than ~42 +//! cycles (0.8·0.85ᵏ < 0.001) and the prior can no longer tell a recurrence +//! from a shift at all; under the Marker floor it survives at 0.1 and the +//! discrimination holds. The price is absolute level: with 30 stale patterns +//! the Marker prior's free energy sits at 0.35 where the Trace prior's sits at +//! 0.07 — a permanent memory is a noisier baseline. D-HOUSE-4's anchoring +//! alarm reads the DIFFERENCE, so the default is **`Marker`** — the contract's +//! semantics, now measured rather than inherited. + +use lance_graph_contract::escalation::{GhostEcho, WisdomMarker}; + +/// How a trace's intensity bottoms out as it ages. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PriorFloor { + /// The contract reading: `max(FLOOR, i · decay^age)` with + /// `WisdomMarker::FLOOR` (0.1) — a trace never vanishes and is never + /// pruned. Persistence-of-identity semantics. + Marker, + /// The source reading: a trace below [`GhostPrior::TRACE_FLOOR`] (0.001) + /// contributes nothing and `prune` removes it. Bias semantics. + Trace, +} + +impl PriorFloor { + /// The declared default — chosen by the calibration gate (see module doc + /// and `tests::calibration_gate_picks_the_default_floor`), not inherited + /// from either source. `Marker` won on every fixture row; `Trace` lost + /// all discrimination once the remembered pattern aged past its prune + /// point. + pub const DEFAULT: PriorFloor = PriorFloor::Marker; +} + +/// One lingering trace: an echo on an atom, born at a cycle. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Trace { + /// The atom the trace sits on. + pub atom: u16, + /// Echo kind + birth intensity (the contract's marker). + pub marker: WisdomMarker, + /// Thought cycle at which the trace was laid. + pub created_at: u32, +} + +/// A per-thought Friston prior over atoms — the harvested ghost field. +#[derive(Debug, Clone)] +pub struct GhostPrior { + traces: Vec, + decay: f32, + floor: PriorFloor, + cycle: u32, +} + +impl GhostPrior { + /// Per-cycle decay rate shared with `WisdomMarker::DECAY` (0.85). + pub const DEFAULT_DECAY: f32 = WisdomMarker::DECAY; + /// Below this a trace is inert under [`PriorFloor::Trace`] (source: 0.001). + pub const TRACE_FLOOR: f32 = 0.001; + /// At most this many atoms are imprinted per thought (source: 10) — + /// the I-VSA-IDENTITIES bundle-size discipline applied to the prior. + pub const IMPRINT_CAP: usize = 10; + /// `summary` reports traces at or above this intensity (source: 0.01). + pub const SUMMARY_FLOOR: f32 = 0.01; + + /// An empty prior at cycle 0 with the default decay and the given floor. + pub fn new(floor: PriorFloor) -> Self { + Self { + traces: Vec::new(), + decay: Self::DEFAULT_DECAY, + floor, + cycle: 0, + } + } + + /// Override the decay rate (a persona's mode may want 0.75 / 0.92). + /// Clamped to `[0, 1]`: a rate above 1 would be growth, not decay. + pub fn with_decay(mut self, decay: f32) -> Self { + self.decay = decay.clamp(0.0, 1.0); + self + } + + /// The floor this prior applies. + pub fn floor(&self) -> PriorFloor { + self.floor + } + + /// The decay rate this prior applies. + pub fn decay(&self) -> f32 { + self.decay + } + + /// The current thought cycle. + pub fn cycle(&self) -> u32 { + self.cycle + } + + /// Advance one thought cycle. `imprint` does this itself; call `tick` + /// for a thought that laid no trace. + pub fn tick(&mut self) { + self.cycle = self.cycle.saturating_add(1); + } + + /// Choose the echo a completed thought leaves, from its cognitive + /// markers (source rule, unchanged): Staunen above 0.5 wins, then Wisdom + /// above 0.3, then unresolved dissonance above 0.3 becomes Grief; + /// otherwise the caller's style-derived `fallback`. + pub fn echo_for(staunen: f32, wisdom: f32, dissonance: f32, fallback: GhostEcho) -> GhostEcho { + if staunen > 0.5 { + GhostEcho::Staunen + } else if wisdom > 0.3 { + GhostEcho::Wisdom + } else if dissonance > 0.3 { + GhostEcho::Grief + } else { + fallback + } + } + + /// Lay traces for a completed thought: the first [`Self::IMPRINT_CAP`] + /// `(atom, amplitude)` pairs become traces of `echo` at + /// `amplitude.clamp(0, 1)`. Advances the cycle first, so a trace laid now + /// has age 0 for the next `bias` / `prediction`. + pub fn imprint(&mut self, resonant_atoms: &[(u16, f32)], echo: GhostEcho) { + self.tick(); + for &(atom, amplitude) in resonant_atoms.iter().take(Self::IMPRINT_CAP) { + let mut marker = WisdomMarker::fresh(echo); + marker.intensity = amplitude.clamp(0.0, 1.0); + self.traces.push(Trace { + atom, + marker, + created_at: self.cycle, + }); + } + } + + /// A trace's live intensity under this prior's floor, or `None` when it + /// contributes nothing (`Trace` floor only — a `Marker` trace never does). + fn live_intensity(&self, t: &Trace) -> Option { + let age = self.cycle.saturating_sub(t.created_at); + let decayed = t.marker.intensity * self.decay.powi(age as i32); + match self.floor { + PriorFloor::Marker => Some(decayed.max(WisdomMarker::FLOOR)), + PriorFloor::Trace => (decayed >= Self::TRACE_FLOOR).then_some(decayed), + } + } + + /// The prior's pull on one atom: summed live intensity and the echo of + /// the strongest contributing trace. + pub fn bias(&self, atom: u16) -> (f32, Option) { + let mut total = 0.0f32; + let mut dominant = None; + let mut max_i = 0.0f32; + for t in self.traces.iter().filter(|t| t.atom == atom) { + let Some(i) = self.live_intensity(t) else { + continue; + }; + total += i; + if i > max_i { + max_i = i; + dominant = Some(t.marker.ghost); + } + } + (total, dominant) + } + + /// The prediction for the next cascade over `n_atoms` atoms — summed + /// live intensity per atom, normalised to `[0, 1]` by its maximum + /// (all-zero when nothing is live). + pub fn prediction(&self, n_atoms: usize) -> Vec { + let mut pred = vec![0.0f32; n_atoms]; + for t in &self.traces { + let idx = t.atom as usize; + if idx >= n_atoms { + continue; + } + if let Some(i) = self.live_intensity(t) { + pred[idx] += i; + } + } + let max = pred.iter().copied().fold(0.0f32, f32::max); + if max > 0.0 { + for p in &mut pred { + *p /= max; + } + } + pred + } + + /// Free energy between the prediction and the actual activation: the mean + /// absolute gap per atom (the source's L1 stand-in for a divergence). + /// Rises when the context shifts away from what the prior expected, falls + /// on a recurrence — the two-sided property the tests pin. + pub fn free_energy(&self, actual: &[f32]) -> f32 { + let pred = self.prediction(actual.len()); + let n = actual.len().max(1) as f32; + pred.iter() + .zip(actual) + .map(|(p, a)| (p - a).abs()) + .sum::() + / n + } + + /// Traces that currently contribute. + pub fn active_count(&self) -> usize { + self.traces + .iter() + .filter(|t| self.live_intensity(t).is_some()) + .count() + } + + /// Drop traces that no longer contribute. A no-op under + /// [`PriorFloor::Marker`] by definition (nothing ever falls below the + /// floor); returns the number removed. + pub fn prune(&mut self) -> usize { + let before = self.traces.len(); + let cycle = self.cycle; + let decay = self.decay; + let floor = self.floor; + self.traces.retain(|t| { + let age = cycle.saturating_sub(t.created_at); + let decayed = t.marker.intensity * decay.powi(age as i32); + match floor { + PriorFloor::Marker => true, + PriorFloor::Trace => decayed >= Self::TRACE_FLOOR, + } + }); + before - self.traces.len() + } + + /// Strongest live trace per atom at or above [`Self::SUMMARY_FLOOR`], + /// strongest first, one entry per atom. + pub fn summary(&self) -> Vec<(u16, GhostEcho, f32)> { + let mut v: Vec<(u16, GhostEcho, f32)> = self + .traces + .iter() + .filter_map(|t| { + let i = self.live_intensity(t)?; + (i >= Self::SUMMARY_FLOOR).then_some((t.atom, t.marker.ghost, i)) + }) + .collect(); + v.sort_by(|a, b| { + b.2.partial_cmp(&a.2) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.0.cmp(&b.0)) + }); + v.dedup_by_key(|e| e.0); + v + } +} + +/// The calibration gate that decides [`PriorFloor::DEFAULT`] — kept as +/// library code (not test-only) so a probe can re-run it on other fixtures. +pub mod calibration { + use super::{GhostPrior, PriorFloor}; + use lance_graph_contract::escalation::GhostEcho; + + /// The recurrence fixture: pattern A is imprinted once, then + /// `stale_patterns` OTHER disjoint patterns are imprinted (one per cycle, + /// the memory of a life lived since), then the prior is aged `age` more + /// cycles. Returns `(fe_recurrence, fe_shift)`: free energy when A recurs + /// vs when an unseen pattern B appears. `n_atoms` is the atom space. + pub fn recurrence_fixture( + floor: PriorFloor, + stale_patterns: usize, + age: u32, + n_atoms: usize, + ) -> (f32, f32) { + let a: [(u16, f32); 3] = [(10, 1.0), (20, 0.8), (30, 0.6)]; + let mut prior = GhostPrior::new(floor); + prior.imprint(&a, GhostEcho::Thought); + for k in 0..stale_patterns { + // Disjoint from A and from B: atoms 100.. in steps of 3. + let base = 100 + (k as u16) * 3; + prior.imprint( + &[(base, 0.9), (base + 1, 0.7), (base + 2, 0.5)], + GhostEcho::Affinity, + ); + } + for _ in 0..age { + prior.tick(); + } + let mut recur = vec![0.0f32; n_atoms]; + for &(atom, amp) in &a { + recur[atom as usize] = amp; + } + let mut shift = vec![0.0f32; n_atoms]; + shift[60] = 1.0; + shift[61] = 0.8; + shift[62] = 0.6; + (prior.free_energy(&recur), prior.free_energy(&shift)) + } + + /// Discrimination = `fe_shift − fe_recurrence` on the fixture: how far + /// apart the prior holds "this is familiar" from "this is new" after it + /// has aged. Higher is better; ≤ 0 means the prior can no longer tell. + pub fn discrimination( + floor: PriorFloor, + stale_patterns: usize, + age: u32, + n_atoms: usize, + ) -> f32 { + let (recur, shift) = recurrence_fixture(floor, stale_patterns, age, n_atoms); + shift - recur + } +} + +#[cfg(test)] +mod tests { + use super::calibration::{discrimination, recurrence_fixture}; + use super::*; + + const N: usize = 256; + + fn fresh(floor: PriorFloor) -> GhostPrior { + let mut p = GhostPrior::new(floor); + p.imprint(&[(42, 0.8)], GhostEcho::Affinity); + p + } + + // ── decay ──────────────────────────────────────────────────────────── + + #[test] + fn bias_decays_monotonically_to_the_trace_floor_and_then_goes_inert() { + let mut p = fresh(PriorFloor::Trace); + let mut last = p.bias(42).0; + assert!((last - 0.8).abs() < 1e-6, "age 0 = birth intensity"); + let mut went_inert_at = None; + for cycle in 1..200u32 { + p.tick(); + let b = p.bias(42).0; + assert!( + b <= last + 1e-7, + "non-increasing at cycle {cycle}: {b} > {last}" + ); + if b == 0.0 && went_inert_at.is_none() { + went_inert_at = Some(cycle); + } + last = b; + } + let inert = went_inert_at.expect("a Trace-floor trace must go inert within 200 cycles"); + // 0.8 · 0.85^k < 0.001 ⇔ k > ln(0.00125)/ln(0.85) ≈ 41.1 → inert from cycle 42. + assert_eq!( + inert, 42, + "inert cycle follows from the decay constant, not a tuned bound" + ); + assert_eq!(p.active_count(), 0); + assert_eq!(p.prune(), 1); + } + + #[test] + fn bias_decays_monotonically_to_the_marker_floor_and_never_below() { + let mut p = fresh(PriorFloor::Marker); + let mut last = p.bias(42).0; + for _ in 1..200u32 { + p.tick(); + let b = p.bias(42).0; + assert!(b <= last + 1e-7); + assert!( + b >= WisdomMarker::FLOOR - 1e-7, + "never below the marker floor" + ); + last = b; + } + assert!( + (last - WisdomMarker::FLOOR).abs() < 1e-6, + "settles exactly on the floor" + ); + assert_eq!(p.active_count(), 1, "a marker trace never goes inert"); + assert_eq!(p.prune(), 0, "prune is a no-op under the marker floor"); + } + + #[test] + fn decay_constant_is_load_bearing() { + // Disable-run in test form: with decay 1.0 nothing decays, so the + // monotone-to-floor property above would be vacuous. Prove the knob + // binds in both directions. + let mut slow = GhostPrior::new(PriorFloor::Trace).with_decay(1.0); + slow.imprint(&[(42, 0.8)], GhostEcho::Affinity); + let mut fast = GhostPrior::new(PriorFloor::Trace).with_decay(0.5); + fast.imprint(&[(42, 0.8)], GhostEcho::Affinity); + for _ in 0..10 { + slow.tick(); + fast.tick(); + } + assert!( + (slow.bias(42).0 - 0.8).abs() < 1e-6, + "decay 1.0 holds the birth intensity" + ); + assert!( + fast.bias(42).0 < 0.001, + "decay 0.5 is inert within 10 cycles" + ); + let mut default = fresh(PriorFloor::Trace); + for _ in 0..10 { + default.tick(); + } + let d = default.bias(42).0; + assert!( + d < 0.8 && d > 0.001, + "the default sits strictly between: {d}" + ); + } + + // ── free energy, two-sided ─────────────────────────────────────────── + + #[test] + fn free_energy_falls_on_recurrence_and_rises_on_shift_under_both_floors() { + for floor in [PriorFloor::Trace, PriorFloor::Marker] { + let mut p = GhostPrior::new(floor); + p.imprint(&[(10, 1.0), (20, 0.8), (30, 0.6)], GhostEcho::Thought); + let baseline = p.free_energy(&vec![0.0f32; N]); // nothing lit up at all + let mut recur = vec![0.0f32; N]; + recur[10] = 1.0; + recur[20] = 0.8; + recur[30] = 0.6; + let mut shift = vec![0.0f32; N]; + shift[60] = 1.0; + shift[61] = 0.8; + shift[62] = 0.6; + let fe_recur = p.free_energy(&recur); + let fe_shift = p.free_energy(&shift); + assert!( + fe_recur < baseline, + "{floor:?}: recurrence LOWERS surprise below the empty baseline" + ); + assert!( + fe_shift > baseline, + "{floor:?}: a shift RAISES surprise above the empty baseline" + ); + assert!(fe_recur < fe_shift); + assert!( + fe_recur.abs() < 1e-6, + "{floor:?}: an exact recurrence of a fresh prior is zero surprise" + ); + } + } + + #[test] + fn free_energy_of_an_empty_prior_is_the_mean_activation() { + let p = GhostPrior::new(PriorFloor::Trace); + let mut actual = vec![0.0f32; 4]; + actual[0] = 1.0; + assert!((p.free_energy(&actual) - 0.25).abs() < 1e-6); + assert_eq!( + p.free_energy(&[]), + 0.0, + "no atoms, no surprise, no division by zero" + ); + } + + // ── the calibration gate ───────────────────────────────────────────── + + #[test] + fn calibration_gate_picks_the_default_floor() { + // The fixture the module doc names: A imprinted, 30 stale patterns + // lived since, then aged 20 more cycles. Print the whole table so a + // reviewer can see the numbers, then assert the DECLARED default is + // the floor that discriminates better — anything else is a + // declaration the fixture contradicts. + let mut table = String::new(); + let mut default_wins_everywhere = true; + for &(stale, age) in &[(0usize, 0u32), (0, 20), (30, 0), (30, 20), (30, 60)] { + let d_trace = discrimination(PriorFloor::Trace, stale, age, N); + let d_marker = discrimination(PriorFloor::Marker, stale, age, N); + let (rt, st) = recurrence_fixture(PriorFloor::Trace, stale, age, N); + let (rm, sm) = recurrence_fixture(PriorFloor::Marker, stale, age, N); + table.push_str(&format!( + "stale={stale:>2} age={age:>2} | Trace: recur={rt:.4} shift={st:.4} disc={d_trace:.4} | Marker: recur={rm:.4} shift={sm:.4} disc={d_marker:.4}\n" + )); + let (d_default, d_other) = match PriorFloor::DEFAULT { + PriorFloor::Trace => (d_trace, d_marker), + PriorFloor::Marker => (d_marker, d_trace), + }; + if d_default + 1e-6 < d_other { + default_wins_everywhere = false; + } + } + println!("calibration gate (discrimination = fe_shift − fe_recurrence):\n{table}"); + assert!( + default_wins_everywhere, + "PriorFloor::DEFAULT = {:?} does not discriminate at least as well as the other floor on every \ + fixture row:\n{table}", + PriorFloor::DEFAULT + ); + } + + #[test] + fn the_two_floors_genuinely_differ_after_ageing() { + // Anti-vacuity for the gate: if both floors gave the same numbers the + // gate would pass trivially. After 60 cycles a Trace-floor trace is + // inert and a Marker-floor trace sits at 0.1 — the predictions differ. + let (rt, _) = recurrence_fixture(PriorFloor::Trace, 30, 60, N); + let (rm, _) = recurrence_fixture(PriorFloor::Marker, 30, 60, N); + assert!( + (rt - rm).abs() > 1e-3, + "floors must be distinguishable on the fixture: {rt} vs {rm}" + ); + } + + // ── shape ──────────────────────────────────────────────────────────── + + #[test] + fn prediction_orders_atoms_by_trace_strength_and_leaves_unseen_atoms_zero() { + let mut p = GhostPrior::new(PriorFloor::DEFAULT); + p.imprint(&[(10, 0.9), (20, 0.7), (30, 0.5)], GhostEcho::Epiphany); + let pred = p.prediction(N); + assert!((pred[10] - 1.0).abs() < 1e-6, "normalised to the maximum"); + assert!(pred[10] > pred[20] && pred[20] > pred[30]); + assert_eq!(pred[100], 0.0); + assert_eq!(p.prediction(0).len(), 0); + } + + #[test] + fn imprint_caps_at_ten_atoms_and_clamps_amplitude() { + let mut p = GhostPrior::new(PriorFloor::DEFAULT); + let atoms: Vec<(u16, f32)> = (0..15u16).map(|a| (a, 2.0)).collect(); + p.imprint(&atoms, GhostEcho::Somatic); + assert_eq!(p.active_count(), GhostPrior::IMPRINT_CAP); + assert!((p.bias(0).0 - 1.0).abs() < 1e-6, "amplitude clamped to 1.0"); + assert_eq!(p.bias(14).0, 0.0, "the 15th atom was not imprinted"); + } + + #[test] + fn echo_selection_follows_the_source_rule() { + assert_eq!( + GhostPrior::echo_for(0.6, 0.9, 0.9, GhostEcho::Thought), + GhostEcho::Staunen + ); + assert_eq!( + GhostPrior::echo_for(0.1, 0.4, 0.9, GhostEcho::Thought), + GhostEcho::Wisdom + ); + assert_eq!( + GhostPrior::echo_for(0.1, 0.1, 0.4, GhostEcho::Thought), + GhostEcho::Grief + ); + assert_eq!( + GhostPrior::echo_for(0.1, 0.1, 0.1, GhostEcho::Boundary), + GhostEcho::Boundary + ); + } + + #[test] + fn dominant_echo_is_the_strongest_live_trace_and_summary_is_one_per_atom() { + let mut p = GhostPrior::new(PriorFloor::DEFAULT); + p.imprint(&[(42, 0.3)], GhostEcho::Affinity); + p.imprint(&[(42, 0.9), (7, 0.2)], GhostEcho::Staunen); + assert_eq!(p.bias(42).1, Some(GhostEcho::Staunen)); + let s = p.summary(); + assert_eq!(s.len(), 2, "one entry per atom"); + assert_eq!(s[0].0, 42, "strongest first"); + assert_eq!(s[0].1, GhostEcho::Staunen); + } + + #[test] + fn two_priors_are_independent_state() { + // Ownership: no shared field. Imprinting one never touches the other. + let mut a = GhostPrior::new(PriorFloor::DEFAULT); + let b = GhostPrior::new(PriorFloor::DEFAULT); + a.imprint(&[(1, 1.0)], GhostEcho::Wisdom); + assert_eq!(a.active_count(), 1); + assert_eq!(b.active_count(), 0); + assert_eq!(b.cycle(), 0); + } +} diff --git a/crates/lance-graph-planner/src/nars/mod.rs b/crates/lance-graph-planner/src/nars/mod.rs index b232e0157..04054d538 100644 --- a/crates/lance-graph-planner/src/nars/mod.rs +++ b/crates/lance-graph-planner/src/nars/mod.rs @@ -11,6 +11,7 @@ pub mod dissolution; pub mod elevation; pub mod epiphany; pub mod facet_fold; +pub mod ghost_prior; pub mod inference; pub mod insight; pub mod insights; @@ -28,6 +29,7 @@ pub use dissolution::{detect_dissolution, should_elevate, staunen, wisdom, Disso pub use elevation::{elevate_field, Elevation}; pub use epiphany::{rank_epiphany_attractors, EpiphanyAttractor}; pub use facet_fold::{cstmt_from_spo_facet, to_spo_facet}; +pub use ghost_prior::{GhostPrior, PriorFloor}; pub use inference::NarsInference; pub use insight::{arena_graph_signals, detect, flow_state, InsightMush, Snapshot}; pub use insights::{extract_main_insights, InsightConfig, InsightKind, InsightReason, MainInsight}; diff --git a/crates/thinking-engine/Cargo.lock b/crates/thinking-engine/Cargo.lock index f74d036e5..43f9edec9 100644 --- a/crates/thinking-engine/Cargo.lock +++ b/crates/thinking-engine/Cargo.lock @@ -47,12 +47,6 @@ dependencies = [ "libc", ] -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - [[package]] name = "arrow-array" version = "58.4.0" @@ -168,19 +162,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -[[package]] -name = "blake3" -version = "1.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" -dependencies = [ - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures", -] - [[package]] name = "bumpalo" version = "3.20.2" @@ -377,12 +358,6 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - [[package]] name = "core-foundation" version = "0.9.4" @@ -409,15 +384,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -1565,7 +1531,6 @@ dependencies = [ name = "ndarray" version = "0.17.2" dependencies = [ - "blake3", "fractal", "matrixmultiply", "num-complex", diff --git a/crates/thinking-engine/examples/think.rs b/crates/thinking-engine/examples/think.rs deleted file mode 100644 index 043c140d6..000000000 --- a/crates/thinking-engine/examples/think.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! THINK: full Friston loop — ghost prediction → cascade → free energy → learn -//! -//! Processes multiple sentences sequentially. Each thought's ghosts -//! bias the NEXT thought's cascade. Free energy measures surprise. -//! The system learns across sentences within a session. -//! -//! cargo run --release --manifest-path crates/thinking-engine/Cargo.toml \ -//! --example think -- "sentence 1" "sentence 2" "sentence 3" - -use thinking_engine::bge_m3_lens; -use thinking_engine::centroid_labels::JINA_CENTROID_LABELS; -use thinking_engine::cognitive_trace::CognitiveTrace; -use thinking_engine::domino::DominoCascade; -use thinking_engine::engine::ThinkingEngine; -use thinking_engine::ghosts::{GhostField, GhostType}; -use thinking_engine::jina_lens; -use thinking_engine::qualia::Qualia17D; -use thinking_engine::superposition; - -fn label(c: u16) -> &'static str { - JINA_CENTROID_LABELS.get(c as usize).copied().unwrap_or("?") -} - -fn main() { - let args: Vec = std::env::args().collect(); - let sentences: Vec = if args.len() > 1 { - args[1..].iter().map(|s| s.clone()).collect() - } else { - vec![ - "The cat sat on the mat.".into(), - "I feel deeply sad about losing someone.".into(), - "The wound is where the light enters you.".into(), - "Set your life on fire. Seek those who fan your flames.".into(), - "What is the meaning of love?".into(), - ] - }; - - let tokenizer = match tokenizers::Tokenizer::from_file("/tmp/bge-m3-tokenizer.json") { - Ok(t) => t, - Err(_) => { - eprintln!("Need /tmp/bge-m3-tokenizer.json"); - return; - } - }; - - println!("╔══════════════════════════════════════════════════════════════╗"); - println!("║ FRISTON LOOP: Ghost Prediction → Cascade → Free Energy ║"); - println!("╚══════════════════════════════════════════════════════════════╝\n"); - - // Ghost field persists across all sentences - let mut ghost_field = GhostField::new(); - let kg_path = std::path::Path::new("/tmp/codebooks/knowledge_graph.tsv"); - - for (si, text) in sentences.iter().enumerate() { - println!("━━━ Thought {} of {} ━━━", si + 1, sentences.len()); - println!(" \"{}\"", text); - - let encoding = tokenizer.encode(text.as_str(), true).expect("tokenize"); - let token_ids = encoding.get_ids(); - let tokens: Vec = encoding - .get_tokens() - .iter() - .map(|s| s.to_string()) - .collect(); - - // ── Ghost prediction (before thinking) ── - let ghost_pred = ghost_field.prediction(256); - let ghost_count = ghost_field.active_count(); - if ghost_count > 0 { - let top_ghosts = ghost_field.summary(); - println!( - "\n 👻 Ghost field: {} active ghosts (prediction from past thoughts)", - ghost_count - ); - for (atom, gtype, intensity) in top_ghosts.iter().take(3) { - println!( - " atom {:>3} [{}] {} intensity={:.2}", - atom, - label(*atom), - gtype, - intensity - ); - } - } - - // ── Multi-lens cascade with ghost bias ── - let jina_cents = jina_lens::jina_lookup_many(token_ids); - let bge_cents = bge_m3_lens::bge_m3_lookup_many(token_ids); - - let jina_engine = jina_lens::jina_engine(); - let bge_engine = bge_m3_lens::bge_m3_engine(); - - // Jina cascade WITH ghost bias - let jina_cascade = - DominoCascade::new(&jina_engine, &vec![1u32; 256]).with_ghost_bias(ghost_pred.clone()); - let (jina_dom, jina_stages, jina_dis) = jina_cascade.think(&jina_cents); - let jina_chain: Vec = jina_stages - .iter() - .filter_map(|s| s.focus.first().map(|a| a.index)) - .collect(); - - // BGE cascade WITH ghost bias - let bge_cascade = - DominoCascade::new(&bge_engine, &vec![1u32; 256]).with_ghost_bias(ghost_pred.clone()); - let (bge_dom, bge_stages, bge_dis) = bge_cascade.think(&bge_cents); - let bge_chain: Vec = bge_stages - .iter() - .filter_map(|s| s.focus.first().map(|a| a.index)) - .collect(); - - println!( - "\n Jina: {} → {} BGE: {} → {}", - label(jina_chain.first().copied().unwrap_or(0)), - label(jina_dom), - label(bge_chain.first().copied().unwrap_or(0)), - label(bge_dom) - ); - - // ── Superposition ── - let dis_avg = (jina_dis.total_dissonance + bge_dis.total_dissonance) / 2.0; - let (field, style, gated) = superposition::superposition_cascade( - &[&jina_stages, &bge_stages], - 256, - dis_avg, - &superposition::StyleThresholds::default(), - ); - let dom_agree = jina_dom == bge_dom; - let confidence = if dom_agree { - 0.85 - } else if !gated.is_empty() { - 0.5 - } else { - 0.3 - }; - - // ── Qualia from superposition ── - let qualia = Qualia17D::from_superposition(&field, &style, dis_avg, confidence); - let (_, _, blend_name, _) = qualia.emotional_blend(); - - // ── Free energy: was ghost prediction accurate? ── - let actual_energy: Vec = field.amplitudes.clone(); - let free_energy = ghost_field.free_energy(&actual_energy); - - // ── Markers ── - let staunen_max = jina_stages - .iter() - .chain(bge_stages.iter()) - .map(|s| s.markers.staunen) - .fold(0.0f32, f32::max); - let wisdom_max = jina_stages - .iter() - .chain(bge_stages.iter()) - .map(|s| s.markers.wisdom) - .fold(0.0f32, f32::max); - - println!(" Style: {} Blend: {}", style, blend_name); - println!( - " Resonant: {}/256 Gated: {} Confidence: {:.0}%", - field.n_resonant, - gated.len(), - confidence * 100.0 - ); - if ghost_count > 0 { - println!( - " Free energy: {:.4} {}", - free_energy, - if free_energy < 0.01 { - "(LOW — ghosts predicted well → autocomplete)" - } else if free_energy < 0.05 { - "(moderate — partial match)" - } else { - "(HIGH — surprise! ghosts wrong → learning)" - } - ); - } - - // ── Markers ── - let mut markers = Vec::new(); - if staunen_max > 0.3 { - markers.push(format!("✨ wonder {:.1}", staunen_max)); - } - if wisdom_max > 0.1 { - markers.push(format!("🦉 wisdom {:.1}", wisdom_max)); - } - if dis_avg > 0.2 { - markers.push(format!("⚡ tension {:.2}", dis_avg)); - } - if dis_avg < 0.05 { - markers.push("🕊 calm".into()); - } - if !markers.is_empty() { - println!(" {}", markers.join(" ")); - } - - // ── SPO triples ── - let spo = CognitiveTrace::extract_spo( - &gated, - |a, b| jina_lens::jina_distance(a, b), - |a, b| bge_m3_lens::bge_m3_distance(a, b), - 0.6, - ); - if !spo.is_empty() { - println!( - " SPO: {} triples (top: ({}) —[{}]→ ({}) c={:.2})", - spo.len(), - label(spo[0].subject), - spo[0].predicate, - label(spo[0].object), - spo[0].confidence - ); - } - - // ── Imprint ghosts for NEXT thought ── - ghost_field.imprint( - &field.resonant_atoms, - &style, - staunen_max, - wisdom_max, - dis_avg, - text, - ); - - // ── Knowledge graph append ── - let trace = CognitiveTrace { - input: text.clone(), - token_ids: token_ids.to_vec(), - tokens: tokens.clone(), - lens_results: vec![], - superposition: field, - style: style.clone(), - gated_atoms: gated, - qualia: qualia.clone(), - blend: blend_name.clone(), - primary_family: String::new(), - overlay_family: String::new(), - spo_triples: spo, - confidence, - dissonance: dis_avg, - staunen_max, - wisdom_max, - }; - trace.append_to_knowledge_graph(kg_path).ok(); - - println!(); - } - - // ── Session summary ── - println!("╔══════════════════════════════════════════════════════════════╗"); - println!("║ SESSION COMPLETE ║"); - println!("╚══════════════════════════════════════════════════════════════╝\n"); - - println!(" {} thoughts processed", sentences.len()); - println!(" {} ghosts accumulated", ghost_field.active_count()); - ghost_field.prune(); - println!(" {} ghosts after prune", ghost_field.active_count()); - - let kg_lines = std::fs::read_to_string(kg_path) - .map(|s| s.lines().count()) - .unwrap_or(0); - println!(" {} SPO triples in knowledge graph", kg_lines); - - println!("\n Ghost field summary:"); - for (atom, gtype, intensity) in ghost_field.summary().iter().take(10) { - println!( - " atom {:>3} [{}] {} = {:.3}", - atom, - label(*atom), - gtype, - intensity - ); - } - println!(); -} diff --git a/crates/thinking-engine/src/awareness_dto.rs b/crates/thinking-engine/src/awareness_dto.rs index b6c8c3b58..503fda4d6 100644 --- a/crates/thinking-engine/src/awareness_dto.rs +++ b/crates/thinking-engine/src/awareness_dto.rs @@ -7,7 +7,7 @@ //! ``` use crate::cognitive_stack::{GateState, RungLevel, StyleFamily}; -use crate::ghosts::GhostType; +use lance_graph_contract::escalation::GhostEcho; use crate::meaning_axes::{Archetype, AxisActivation, HdrResonance, Viscosity}; // ═══════════════════════════════════════════════════════════════════════════ @@ -165,7 +165,7 @@ pub struct QualiaDto { // ── Persistent traces ── /// Active persistent trace types and their intensities. - pub traces: Vec<(GhostType, f32)>, + pub traces: Vec<(GhostEcho, f32)>, /// Unresolved conflict detected. pub is_dissonant: bool, } @@ -174,7 +174,7 @@ impl QualiaDto { /// Build from qualia 17D + superposition. pub fn from_qualia( qualia: &crate::qualia::Qualia17D, - ghost_summary: &[(u16, GhostType, f32)], + ghost_summary: &[(u16, GhostEcho, f32)], ) -> Self { let (primary, p_dist) = qualia.nearest_family(); let p_intensity = (1.0 - p_dist / 2.0).clamp(0.0, 1.0); @@ -199,7 +199,7 @@ impl QualiaDto { .and_then(|s| s.split(" = ").next()) .unwrap_or("neutral"); - let traces: Vec<(GhostType, f32)> = ghost_summary + let traces: Vec<(GhostEcho, f32)> = ghost_summary .iter() .map(|(_, gt, intensity)| (*gt, *intensity)) .collect(); diff --git a/crates/thinking-engine/src/domino.rs b/crates/thinking-engine/src/domino.rs index f46ba044e..4964071ac 100644 --- a/crates/thinking-engine/src/domino.rs +++ b/crates/thinking-engine/src/domino.rs @@ -294,7 +294,7 @@ impl<'a> DominoCascade<'a> { } } - /// Set ghost bias from a GhostField prediction. + /// Set ghost bias from a ghost-prior prediction (`lance_graph_planner::nars::ghost_prior::GhostPrior::prediction`). /// Atoms with ghost presence get pre-weighted in the cascade. pub fn with_ghost_bias(mut self, bias: Vec) -> Self { self.ghost_bias = Some(bias); diff --git a/crates/thinking-engine/src/ghosts.rs b/crates/thinking-engine/src/ghosts.rs deleted file mode 100644 index 9dd4400d2..000000000 --- a/crates/thinking-engine/src/ghosts.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Ghosts: Friston's Free Energy as persistent cognitive priors. -//! -//! Ghosts are lingering emotional/cognitive traces from past thoughts -//! that decay asymptotically but never fully vanish. They bias -//! future perception — not by changing the topology, but by -//! pre-weighting which atoms the cascade visits first. -//! -//! ```text -//! Friston: The brain minimizes surprise by maintaining predictions. -//! Ghost: A prediction from a past thought that persists as a prior. -//! The cascade visits ghost-weighted atoms FIRST, reducing -//! surprise when familiar patterns recur. -//! -//! Autocomplete: Ghost field IS the prediction cache. -//! Past: "love" activated atoms [29, 85, 42] with stormbreak qualia. -//! Now: "love" appears again → ghosts bias toward [29, 85, 42]. -//! Result: Faster convergence. Less surprise. Autocomplete. -//! But if the context is DIFFERENT, ghosts create prediction ERROR. -//! Error = surprise = FREE ENERGY = signal to update the ghost. -//! ``` -//! -//! The 8 ghost types from ladybug-rs/src/qualia/felt_parse.rs: -//! ```text -//! Affinity — lingering pull toward a concept/person/thing -//! Epiphany — residual clarity from a past insight -//! Somatic — body-felt echo (tension, warmth, chill) -//! Staunen — persistent wonder/awe -//! Wisdom — deep knowing that colors all future perception -//! Thought — a thought that won't let go (rumination or focus) -//! Grief — loss that reshapes the topology -//! Boundary — a limit discovered, still felt -//! ``` - -/// Ghost types — lingering cognitive traces with asymptotic decay. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum GhostType { - Affinity, // pull toward connection - Epiphany, // residual clarity - Somatic, // body-felt echo - Staunen, // persistent wonder - Wisdom, // deep knowing - Thought, // won't let go - Grief, // loss reshapes topology - Boundary, // discovered limit -} - -impl std::fmt::Display for GhostType { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::Affinity => write!(f, "affinity"), - Self::Epiphany => write!(f, "epiphany"), - Self::Somatic => write!(f, "somatic"), - Self::Staunen => write!(f, "staunen"), - Self::Wisdom => write!(f, "wisdom"), - Self::Thought => write!(f, "thought"), - Self::Grief => write!(f, "grief"), - Self::Boundary => write!(f, "boundary"), - } - } -} - -/// A single ghost: a lingering trace at a specific atom. -#[derive(Clone, Debug)] -pub struct Ghost { - pub atom: u16, - pub ghost_type: GhostType, - pub intensity: f32, // 0.0 = fully decayed, 1.0 = just created - pub created_at: u64, // thought cycle when created - pub source_text: String, // what created this ghost (for debug) -} - -/// The ghost field: all active ghosts across the atom space. -/// This IS Friston's prediction / the autocomplete cache. -#[derive(Clone, Debug)] -pub struct GhostField { - ghosts: Vec, - /// Decay rate per thought cycle. 0.9 = slow decay, 0.5 = fast. - pub decay_rate: f32, - /// Current thought cycle counter. - pub cycle: u64, -} - -impl GhostField { - pub fn new() -> Self { - Self { - ghosts: Vec::new(), - decay_rate: 0.85, // slow decay — ghosts linger - cycle: 0, - } - } - - /// Create ghosts from a completed thought. - /// The cascade's resonant atoms + cognitive markers → ghost types. - pub fn imprint( - &mut self, - resonant_atoms: &[(u16, f32)], - style: &crate::superposition::DetectedStyle, - staunen: f32, - wisdom: f32, - dissonance: f32, - source: &str, - ) { - self.cycle += 1; - - for &(atom, amplitude) in resonant_atoms.iter().take(10) { - // Determine ghost type from cognitive context - let ghost_type = if staunen > 0.5 { - GhostType::Staunen - } else if wisdom > 0.3 { - GhostType::Wisdom - } else if dissonance > 0.3 { - GhostType::Grief // unresolved tension becomes grief-ghost - } else { - match style { - crate::superposition::DetectedStyle::Emotional => GhostType::Somatic, - crate::superposition::DetectedStyle::Intuitive => GhostType::Affinity, - crate::superposition::DetectedStyle::Analytical => GhostType::Thought, - crate::superposition::DetectedStyle::Creative => GhostType::Epiphany, - crate::superposition::DetectedStyle::Diffuse => GhostType::Boundary, - } - }; - - self.ghosts.push(Ghost { - atom, - ghost_type, - intensity: amplitude.min(1.0), - created_at: self.cycle, - source_text: source.chars().take(50).collect(), - }); - } - } - - /// Get the ghost bias for an atom: how much past experience - /// pre-weights this atom for the next cascade. - /// Returns (total_bias, dominant_ghost_type). - pub fn bias(&self, atom: u16) -> (f32, Option) { - let mut total = 0.0f32; - let mut dominant_type = None; - let mut max_intensity = 0.0f32; - - for ghost in &self.ghosts { - if ghost.atom != atom { - continue; - } - - // Asymptotic decay: intensity * decay_rate^(cycles_since_creation) - let age = (self.cycle - ghost.created_at) as f32; - let decayed = ghost.intensity * self.decay_rate.powf(age); - - if decayed < 0.001 { - continue; - } // effectively dead - - total += decayed; - if decayed > max_intensity { - max_intensity = decayed; - dominant_type = Some(ghost.ghost_type); - } - } - - (total, dominant_type) - } - - /// Get ghost-weighted energy vector: pre-bias for the next cascade. - /// This IS the autocomplete prediction. - pub fn prediction(&self, n_atoms: usize) -> Vec { - let mut pred = vec![0.0f32; n_atoms]; - for ghost in &self.ghosts { - if (ghost.atom as usize) >= n_atoms { - continue; - } - let age = (self.cycle - ghost.created_at) as f32; - let decayed = ghost.intensity * self.decay_rate.powf(age); - if decayed > 0.001 { - pred[ghost.atom as usize] += decayed; - } - } - // Normalize to [0, 1] range - let max_pred = pred.iter().cloned().fold(0.0f32, f32::max); - if max_pred > 0.001 { - for p in &mut pred { - *p /= max_pred; - } - } - pred - } - - /// Free energy: the surprise between prediction and actual activation. - /// High free energy = ghosts predicted wrong = UPDATE needed. - /// Low free energy = ghosts predicted right = AUTOCOMPLETE works. - pub fn free_energy(&self, actual_energy: &[f32]) -> f32 { - let pred = self.prediction(actual_energy.len()); - // KL divergence approximation: sum of |pred - actual| - let mut surprise = 0.0f32; - for (p, a) in pred.iter().zip(actual_energy) { - let diff = (p - a).abs(); - surprise += diff; - } - surprise / actual_energy.len().max(1) as f32 - } - - /// Prune dead ghosts (intensity below threshold after decay). - pub fn prune(&mut self) { - self.ghosts.retain(|g| { - let age = (self.cycle - g.created_at) as f32; - let decayed = g.intensity * self.decay_rate.powf(age); - decayed > 0.001 - }); - } - - /// Number of active ghosts. - pub fn active_count(&self) -> usize { - self.ghosts - .iter() - .filter(|g| { - let age = (self.cycle - g.created_at) as f32; - g.intensity * self.decay_rate.powf(age) > 0.001 - }) - .count() - } - - /// Ghost summary for display. - pub fn summary(&self) -> Vec<(u16, GhostType, f32)> { - let mut active: Vec<(u16, GhostType, f32)> = self - .ghosts - .iter() - .filter_map(|g| { - let age = (self.cycle - g.created_at) as f32; - let decayed = g.intensity * self.decay_rate.powf(age); - if decayed > 0.01 { - Some((g.atom, g.ghost_type, decayed)) - } else { - None - } - }) - .collect(); - active.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap()); - active.dedup_by_key(|a| a.0); - active - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ghost_decays() { - let mut field = GhostField::new(); - field.imprint( - &[(42, 0.8)], - &crate::superposition::DetectedStyle::Intuitive, - 0.0, - 0.0, - 0.0, - "test", - ); - let (bias_0, _) = field.bias(42); - assert!(bias_0 > 0.7); - - // Simulate 10 cycles - field.cycle += 10; - let (bias_10, _) = field.bias(42); - assert!(bias_10 < bias_0); // decayed - assert!(bias_10 > 0.1); // but not dead - } - - #[test] - fn ghost_prediction_is_autocomplete() { - let mut field = GhostField::new(); - field.imprint( - &[(10, 0.9), (20, 0.7), (30, 0.5)], - &crate::superposition::DetectedStyle::Creative, - 0.0, - 0.0, - 0.0, - "previous thought", - ); - let pred = field.prediction(256); - assert!(pred[10] > pred[20]); // 10 was strongest - assert!(pred[20] > pred[30]); // 20 was next - assert_eq!(pred[100], 0.0); // 100 was never activated - } - - #[test] - fn free_energy_low_when_matching() { - let mut field = GhostField::new(); - field.imprint( - &[(10, 1.0)], - &crate::superposition::DetectedStyle::Analytical, - 0.0, - 0.0, - 0.0, - "test", - ); - // Actual matches prediction - let mut actual = vec![0.0f32; 64]; - actual[10] = 1.0; - let fe_match = field.free_energy(&actual); - - // Actual doesn't match - let mut actual_diff = vec![0.0f32; 64]; - actual_diff[50] = 1.0; - let fe_diff = field.free_energy(&actual_diff); - - assert!(fe_match < fe_diff); // matching = less surprise - } - - #[test] - fn staunen_creates_staunen_ghost() { - let mut field = GhostField::new(); - field.imprint( - &[(42, 0.8)], - &crate::superposition::DetectedStyle::Diffuse, - 0.8, - 0.0, - 0.0, - "wonder", - ); - let (_, ghost_type) = field.bias(42); - assert_eq!(ghost_type, Some(GhostType::Staunen)); - } - - #[test] - fn prune_removes_dead_ghosts() { - let mut field = GhostField::new(); - field.imprint( - &[(42, 0.01)], // very weak - &crate::superposition::DetectedStyle::Diffuse, - 0.0, - 0.0, - 0.0, - "weak", - ); - field.cycle += 100; // age a lot - field.prune(); - assert_eq!(field.active_count(), 0); - } -} diff --git a/crates/thinking-engine/src/lib.rs b/crates/thinking-engine/src/lib.rs index 322643f7c..bc9b8bd17 100644 --- a/crates/thinking-engine/src/lib.rs +++ b/crates/thinking-engine/src/lib.rs @@ -33,7 +33,6 @@ pub mod dto; pub mod dual_engine; pub mod engine; pub mod f32_engine; -pub mod ghosts; pub mod ground_truth; pub mod jina_lens; pub mod l4; diff --git a/crates/thinking-engine/src/persona.rs b/crates/thinking-engine/src/persona.rs index ecb35eec5..f4fb28d5a 100644 --- a/crates/thinking-engine/src/persona.rs +++ b/crates/thinking-engine/src/persona.rs @@ -20,7 +20,7 @@ use crate::cognitive_stack::EngineStyleExt; use crate::cognitive_stack::{GateState, RungLevel, StyleFamily}; use crate::contract_bridge::{CascadeConfig, FastBusDto}; -use crate::ghosts::GhostField; +use lance_graph_contract::escalation::GhostEcho; use crate::meaning_axes::{Archetype, CouncilWeights, Viscosity}; // ═══════════════════════════════════════════════════════════════════════════ @@ -198,11 +198,15 @@ impl PersonaProfile { // AGENT (the thinking entity) // ═══════════════════════════════════════════════════════════════════════════ -/// A thinking agent. Owns its persona, ghost field, council, and current state. +/// A thinking agent. Owns its persona, council, and current state. +/// +/// The lingering-trace prior is NOT owned here any more: it is per-thought / +/// per-mailbox and lives in the planner (`lance_graph_planner::nars::ghost_prior::GhostPrior`, +/// harvested from this crate's former `ghosts.rs` — D-TEH-2). The owner of the +/// thought passes its summary in where a DTO needs it. pub struct Agent { pub id: String, pub persona: PersonaProfile, - pub ghosts: GhostField, pub council: CouncilWeights, pub current_style: StyleFamily, pub thought_count: u64, @@ -211,8 +215,6 @@ pub struct Agent { impl Agent { pub fn new(id: impl Into, persona: PersonaProfile) -> Self { let style = persona.default_style; - let mut ghosts = GhostField::new(); - ghosts.decay_rate = persona.ghost_decay_rate(); let mut council = CouncilWeights::default(); match persona.mode { PersonaMode::Work => council.shift_toward(Archetype::Guardian, 0.2), @@ -222,15 +224,15 @@ impl Agent { Self { id: id.into(), persona, - ghosts, council, current_style: style, thought_count: 0, } } - /// Snapshot the agent's identity for A2A messages. - pub fn to_dto(&self) -> AgentDto { + /// Snapshot the agent's identity for A2A messages. `ghost_count` is the + /// active-trace count of the thought's `GhostPrior` (the caller owns it). + pub fn to_dto(&self, ghost_count: u16) -> AgentDto { AgentDto { id: self.id.clone(), mode: self.persona.mode, @@ -239,7 +241,7 @@ impl Agent { warmth: self.persona.priors.warmth, depth: self.persona.priors.depth, presence: self.persona.priors.presence, - ghost_count: self.ghosts.active_count() as u16, + ghost_count, thought_count: self.thought_count, collapse_bias: self.persona.collapse_bias, } @@ -342,7 +344,7 @@ pub struct A2AMessage { impl A2AMessage { pub fn thought(from: &Agent, to: &str, bus: FastBusDto, weight: f32) -> Self { Self { - from: from.to_dto(), + from: from.to_dto(0), to: to.into(), payload: A2APayload::Thought(bus), resonance_weight: weight, @@ -357,7 +359,7 @@ impl A2AMessage { triples: Vec, ) -> Self { Self { - from: from.to_dto(), + from: from.to_dto(0), to: to.into(), payload: A2APayload::Knowledge(triples), resonance_weight: 1.0, @@ -368,9 +370,9 @@ impl A2AMessage { pub fn persona_exchange(from: &Agent, to: &str) -> Self { Self { - from: from.to_dto(), + from: from.to_dto(0), to: to.into(), - payload: A2APayload::PersonaExchange(from.to_dto()), + payload: A2APayload::PersonaExchange(from.to_dto(0)), resonance_weight: 1.0, style_hint: None, timestamp: 0, @@ -405,7 +407,7 @@ pub struct SelfModelDto { /// Ghost field summary. pub ghost_count: u16, - pub dominant_ghost_type: Option, + pub dominant_ghost_type: Option, /// How surprised was the agent by the last thought? pub last_free_energy: f32, @@ -431,6 +433,9 @@ pub struct SelfModelDto { impl Agent { /// Build the self-model DTO — the agent's meta-cognitive snapshot. + /// `ghost_count` / `dominant_ghost` come from the thought's `GhostPrior` + /// (`active_count()` and the first entry of `summary()`). + #[allow(clippy::too_many_arguments)] pub fn self_model( &self, calibration_error: f32, @@ -438,9 +443,9 @@ impl Agent { qualia_family: &str, dissonance: f32, spo_count: u64, + ghost_count: u16, + dominant_ghost: Option, ) -> SelfModelDto { - let ghost_summary = self.ghosts.summary(); - let dominant_ghost = ghost_summary.first().map(|g| g.1); let gate = GateState::from_sd(dissonance + self.persona.collapse_bias); let viscosity = match gate { @@ -457,7 +462,7 @@ impl Agent { gate, calibration_error, should_admit_ignorance: calibration_error > 0.2 && dissonance > 0.3, - ghost_count: self.ghosts.active_count() as u16, + ghost_count, dominant_ghost_type: dominant_ghost, last_free_energy, guardian_weight: self.council.guardian, @@ -506,7 +511,7 @@ mod tests { #[test] fn agent_dto_snapshot() { let agent = Agent::new("a1", PersonaProfile::personal()); - let dto = agent.to_dto(); + let dto = agent.to_dto(0); assert_eq!(dto.id, "a1"); assert_eq!(dto.mode, PersonaMode::Personal); assert!(dto.warmth > 0.5); // personal mode has elevated warmth diff --git a/crates/thinking-engine/src/world_model.rs b/crates/thinking-engine/src/world_model.rs index 5073861dc..263511662 100644 --- a/crates/thinking-engine/src/world_model.rs +++ b/crates/thinking-engine/src/world_model.rs @@ -12,7 +12,7 @@ //! ``` use crate::cognitive_stack::{GateState, StyleFamily}; -use crate::ghosts::GhostType; +use lance_graph_contract::escalation::GhostEcho; use crate::meaning_axes::{Archetype, HdrResonance, Viscosity}; // ═══════════════════════════════════════════════════════════════════════════ @@ -124,7 +124,7 @@ pub struct ContextState { /// Focus level (0.0–1.0). pub clarity: f32, /// Dominant persistent trace type (if any). - pub dominant_trace: Option, + pub dominant_trace: Option, /// SPO triples extracted this cycle. pub spo_count: u16, /// Unresolved conflict detected. @@ -160,9 +160,11 @@ impl WorldModelDto { lens_agreement: f32, spo_count: u16, calibration_error: f32, + // The thought's ghost-prior summary (planner `GhostPrior`): active count + // and the dominant echo. The agent no longer owns the field (D-TEH-2). + trace_count: u16, + dominant_trace: Option, ) -> Self { - let ghost_summary = agent.ghosts.summary(); - let dominant_trace = ghost_summary.first().map(|g| g.1); let hdr = HdrResonance::new( lens_agreement, @@ -204,7 +206,7 @@ impl WorldModelDto { confidence: lens_agreement, calibration_error, should_acknowledge_limits: calibration_error > 0.2 && lens_agreement < 0.4, - trace_count: agent.ghosts.active_count() as u16, + trace_count, free_energy, thought_count: agent.thought_count, }, From 3cd6385a82d22d2333dc0c03f25249bbf65ea475 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:05:21 +0000 Subject: [PATCH 2/6] thinking-engine: restore Cargo.lock (standalone-build resolver churn, unrelated to D-TEH-2) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PFnYKqw6d7TTiB9cT8eFdK --- crates/thinking-engine/Cargo.lock | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/thinking-engine/Cargo.lock b/crates/thinking-engine/Cargo.lock index 43f9edec9..f74d036e5 100644 --- a/crates/thinking-engine/Cargo.lock +++ b/crates/thinking-engine/Cargo.lock @@ -47,6 +47,12 @@ dependencies = [ "libc", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "arrow-array" version = "58.4.0" @@ -162,6 +168,19 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -358,6 +377,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation" version = "0.9.4" @@ -384,6 +409,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1531,6 +1565,7 @@ dependencies = [ name = "ndarray" version = "0.17.2" dependencies = [ + "blake3", "fractal", "matrixmultiply", "num-complex", From 5285da43cf97de23e1983d03854a9750e7c02f91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:08:04 +0000 Subject: [PATCH 3/6] =?UTF-8?q?ci(weather-poc):=20check=20out=20the=20ndar?= =?UTF-8?q?ray=20sibling=20=E2=80=94=20jc=20path-deps=20it=20since=20#1140?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Weather POC workflow fires on contract-path changes and runs cargo test on crates/weather-poc, which path-deps jc, which since #1140 path-deps ../../../ndarray unconditionally. The workflow never checked out that sibling, so it failed at dependency resolution on the first contract-touching PR after #1140 (#1142: a doc comment). Mirrors the lance-graph/ + ndarray/ layout rust-test.yml already uses. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PFnYKqw6d7TTiB9cT8eFdK --- .github/workflows/weather-poc.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/weather-poc.yml b/.github/workflows/weather-poc.yml index 15d139dca..f3d8fe63a 100644 --- a/.github/workflows/weather-poc.yml +++ b/.github/workflows/weather-poc.yml @@ -27,9 +27,26 @@ jobs: test: runs-on: ubuntu-24.04 timeout-minutes: 10 + defaults: + run: + working-directory: lance-graph steps: - uses: actions/checkout@v4 with: + path: lance-graph + persist-credentials: false + # weather-poc path-deps `jc`, and since #1140 `jc` path-deps the + # `ndarray` sibling at `../../../ndarray` unconditionally (operator + # ruling: ndarray is mandatory everywhere in lance-graph). Without this + # checkout `cargo test` fails at dependency resolution before a single + # test runs — first seen on #1142, whose only contract change was a doc + # comment. Same layout as rust-test.yml: repo in `lance-graph/`, sibling + # in `ndarray/`, so the relative path resolves. + - name: Checkout AdaWorldAPI/ndarray (sibling dependency of jc) + uses: actions/checkout@v4 + with: + repository: AdaWorldAPI/ndarray + path: ndarray persist-credentials: false - name: Setup Rust run: | From c0953c0955f0e49b37ba6ca6b644cf647df554b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:12:13 +0000 Subject: [PATCH 4/6] D-TEH-2: per-atom max before the summary sort; fixture refuses undersized atom spaces; A2A constructors take the sender snapshot (Codex on #1142) - summary(): take the strongest live trace per atom FIRST, then sort; the source order (sort, then adjacent dedup) let one atom appear twice whenever another atom sorted between its traces. Regression test with the reviewer repro. - calibration::{recurrence_fixture, discrimination} return Option and refuse n_atoms < FIXTURE_MIN_ATOMS (63) instead of indexing past the vector. Test for both sides of the bound. - A2AMessage::{thought, knowledge, persona_exchange} take the sender's AgentDto (built by the owner of the thought via to_dto(ghost_count)) instead of hardcoding ghost_count = 0; test asserts the count arrives. Planner 14/14, clippy -D warnings + fmt clean; lab crate 14/14. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PFnYKqw6d7TTiB9cT8eFdK --- .claude/board/LATEST_STATE.md | 4 +- .claude/board/PR_ARC_INVENTORY.md | 4 +- .claude/board/STATUS_BOARD.md | 2 +- .../thinking-engine-harvest-closure-v1.md | 2 +- .../src/nars/ghost_prior.rs | 96 ++++++++++++++----- crates/thinking-engine/src/persona.rs | 25 +++-- 6 files changed, 95 insertions(+), 38 deletions(-) diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 5f5cfb70a..0424a6447 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,8 +1,8 @@ ## 2026-09-02 — branch (D-TEH-2, after #1141 merged): ghost prior harvested into the planner — INVENTORY DELTA - MERGED #1141 (D-HOUSE-1, `3102776`): no contract type; planner example `house_differential.rs` + plan/board records (see PR_ARC). -- ADDED planner `lance_graph_planner::nars::ghost_prior` — `GhostPrior` (per-thought Friston prior over atoms: `imprint` / `bias` / `prediction` / `free_energy` / `active_count` / `prune` / `summary` / `tick` / `echo_for`), `PriorFloor { Marker, Trace }` with `DEFAULT = Marker` (decided by the calibration gate, see EPIPHANIES `E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1`), `Trace` (atom + `WisdomMarker` + birth cycle), `calibration::{recurrence_fixture, discrimination}` (library code so a consumer can re-run the gate on its own fixture). Re-exported as `nars::{GhostPrior, PriorFloor}`. 12 tests. Consumes contract `escalation::{GhostEcho, WisdomMarker}` only; no new dependency, no new contract type, no tenant, no layout change. -- REMOVED (lab crate, excluded from the workspace) `thinking_engine::ghosts` (`GhostType`, `Ghost`, `GhostField`) and `examples/think.rs` (its only consumer). `persona::Agent` lost its `ghosts` field; `Agent::to_dto(ghost_count)`, `Agent::self_model(.., ghost_count, dominant_ghost)` and `WorldModelDto::build(.., trace_count, dominant_trace)` take the thought's prior summary as arguments; `SelfModelDto::dominant_ghost_type`, `GestaltState::dominant_trace`, `QualiaDto::traces` are typed over `contract::escalation::GhostEcho`. Lab crate: `cargo check --lib --examples` clean (pre-existing warnings only), persona/world_model/awareness_dto tests 14/14. +- ADDED planner `lance_graph_planner::nars::ghost_prior` — `GhostPrior` (per-thought Friston prior over atoms: `imprint` / `bias` / `prediction` / `free_energy` / `active_count` / `prune` / `summary` / `tick` / `echo_for`), `PriorFloor { Marker, Trace }` with `DEFAULT = Marker` (decided by the calibration gate, see EPIPHANIES `E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1`), `Trace` (atom + `WisdomMarker` + birth cycle), `calibration::{recurrence_fixture, discrimination}` (library code so a consumer can re-run the gate on its own fixture). Re-exported as `nars::{GhostPrior, PriorFloor}`. 14 tests (incl. the Codex-review pair: summary per-atom-max-before-sort, fixture refuses an atom space below `calibration::FIXTURE_MIN_ATOMS` = 63 — `recurrence_fixture` / `discrimination` return `Option`). Consumes contract `escalation::{GhostEcho, WisdomMarker}` only; no new dependency, no new contract type, no tenant, no layout change. +- REMOVED (lab crate, excluded from the workspace) `thinking_engine::ghosts` (`GhostType`, `Ghost`, `GhostField`) and `examples/think.rs` (its only consumer). `persona::Agent` lost its `ghosts` field; `Agent::to_dto(ghost_count)`, `Agent::self_model(.., ghost_count, dominant_ghost)` and `WorldModelDto::build(.., trace_count, dominant_trace)` take the thought's prior summary as arguments; the `A2AMessage::{thought, knowledge, persona_exchange}` constructors take the sender's `AgentDto` snapshot (built by the caller via `to_dto(ghost_count)`) instead of hardcoding a zero count; `SelfModelDto::dominant_ghost_type`, `GestaltState::dominant_trace`, `QualiaDto::traces` are typed over `contract::escalation::GhostEcho`. Lab crate: `cargo check --lib --examples` clean (pre-existing warnings only), persona/world_model/awareness_dto tests 14/14. - RESOLVED `TD-GHOST-ECHO-DUP-1` (one declaration of the eight echoes remains — the contract's). - UNCHANGED: `contract::escalation` shapes (`GhostEcho`, `WisdomMarker::{FLOOR, DECAY, intensity_at}`) — only a doc comment; the ALU artery files (`dto.rs`, `engine_bridge.rs`, `cognitive_shader.rs`, `mailbox_soa.rs`) untouched. diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index d3031b917..58fb710e5 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -12,9 +12,9 @@ ## 2026-09-02 — lance-graph branch `claude/medcare-rs-continue-6nhbxn` (D-TEH-2 PR, after #1141) — ghost prior harvested into the planner; `ghosts.rs` deleted -- **Added:** `crates/lance-graph-planner/src/nars/ghost_prior.rs` — `GhostPrior`, `PriorFloor`, `Trace`, `calibration::{recurrence_fixture, discrimination}`, 12 tests (monotone decay to each floor with the inert cycle derived from the constant, decay-constant load-bearing both ways, two-sided free energy under both floors, the calibration gate on the declared default, anti-vacuity that the floors differ, prediction shape, imprint cap, echo rule, independence of two priors); `pub mod ghost_prior` + re-export in `nars/mod.rs`. EPIPHANIES `E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1`. +- **Added:** `crates/lance-graph-planner/src/nars/ghost_prior.rs` — `GhostPrior`, `PriorFloor`, `Trace`, `calibration::{recurrence_fixture, discrimination}` (both `Option` below `FIXTURE_MIN_ATOMS`), 14 tests (monotone decay to each floor with the inert cycle derived from the constant, decay-constant load-bearing both ways, two-sided free energy under both floors, the calibration gate on the declared default, anti-vacuity that the floors differ, prediction shape, imprint cap, echo rule, independence of two priors); `pub mod ghost_prior` + re-export in `nars/mod.rs`. EPIPHANIES `E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1`. - **Removed:** `crates/thinking-engine/src/ghosts.rs`, `crates/thinking-engine/examples/think.rs`; `pub mod ghosts` from the lab `lib.rs`. -- **Changed (lab crate):** `persona.rs` (`Agent` without a trace field; `to_dto` / `self_model` take the prior summary; `SelfModelDto` over `GhostEcho`), `world_model.rs` (`build` takes `trace_count` + `dominant_trace`; `GhostEcho`), `awareness_dto.rs` (`GhostEcho`), `domino.rs` (doc). Contract `escalation.rs`: `GhostEcho` doc comment (retired-mirror note). TECH_DEBT `TD-GHOST-ECHO-DUP-1` → RESOLVED. Plan §5 D-TEH-2 → Shipped, §3 W2 result; STATUS_BOARD D-TEH-2 Shipped, D-HOUSE-4 unblocked. +- **Changed (lab crate):** `persona.rs` (`Agent` without a trace field; `to_dto` / `self_model` take the prior summary; `A2AMessage` constructors take the sender's `AgentDto`; `SelfModelDto` over `GhostEcho`), `world_model.rs` (`build` takes `trace_count` + `dominant_trace`; `GhostEcho`), `awareness_dto.rs` (`GhostEcho`), `domino.rs` (doc). Contract `escalation.rs`: `GhostEcho` doc comment (retired-mirror note). TECH_DEBT `TD-GHOST-ECHO-DUP-1` → RESOLVED. Plan §5 D-TEH-2 → Shipped, §3 W2 result; STATUS_BOARD D-TEH-2 Shipped, D-HOUSE-4 unblocked. - **Measured:** calibration gate rejected the first-declared `Trace` floor — discrimination 0.0000 vs `Marker` 0.0188 at 30 stale patterns / age 20 and 60 (equal at 0.0188 with no stale memory); absolute baseline 0.35 vs 0.07. Default = `Marker`. Planner clippy `-D warnings` + fmt clean; lab `cargo check --lib --examples` clean; lab tests 14/14. - **Locked:** per-thought ownership (no singleton field); the lingering-trace / counterfactual-lane fence; the floor as a measured parameter, not an inherited constant. - **Deferred:** D-HOUSE-4 (unblocked, not started — should re-run `calibration::discrimination` on its own fixture); D-TEH-3 (math → jc), D-TEH-4/5; `TD-GHOST-TIER-NAME-COLLISION-1` waits for the next touch of `counterfactual.rs`. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 6a341e24b..05fcb4113 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -19,7 +19,7 @@ |---|---|---| | D-TEH-0 | census: live footprint measured (one required consumer via `bridge_gate`; one optional via `with-engine`), 51-file fate table, open-row reconciliation, four-wave closure, idea harvest, four rulings asked | **Shipped (plan-only, this PR)** | | D-TEH-1 | W1: `bridge_gate` (seven items) → `lance_graph_contract::bridge_gate`; callcenter re-imports and drops the path dep; thinking-engine keeps a re-export shim | **Shipped 2026-09-02** (before: required dep, 6 crossing sites, dep-drop alone fails 6 × E0433; after: zero thinking-engine deps in callcenter metadata, contract 1303/1303, callcenter 156/156, driver default + `with-engine` green, ALU artery files byte-identical). `with-engine` re-point deferred by stop condition: D-TTV-1 not landed | -| D-TEH-2 | W2: ghost prior → planner `nars/ghost_prior.rs` over `WisdomMarker`, per-thought, two-sided falsifiers; crate `ghosts.rs` deleted | **Shipped 2026-09-02** — `GhostPrior`/`PriorFloor` in planner `nars/ghost_prior.rs`, 12 tests incl. the calibration gate (default floor = `Marker`, the gate reversed the first declaration); `ghosts.rs` + `think.rs` deleted; TD-GHOST-ECHO-DUP-1 resolved; consumer D-HOUSE-4 unblocked | +| D-TEH-2 | W2: ghost prior → planner `nars/ghost_prior.rs` over `WisdomMarker`, per-thought, two-sided falsifiers; crate `ghosts.rs` deleted | **Shipped 2026-09-02** — `GhostPrior`/`PriorFloor` in planner `nars/ghost_prior.rs`, 14 tests incl. the calibration gate (default floor = `Marker`, the gate reversed the first declaration); `ghosts.rs` + `think.rs` deleted; TD-GHOST-ECHO-DUP-1 resolved; consumer D-HOUSE-4 unblocked | | D-TEH-3 | W2: calibration MATH → `jc` (ruling 4, 2026-09-02: lift if correct, perfect in jc if not; crate copies die); glue stays in the lab crate; `semantic_chunker` / `spiral_segment` decided by their falsifiers | Queued | | D-TEH-4 | W3: ENTROPY M8 engine collapse with dtype parity suite; 5 cascade shapes + 3 lens modules collapse | Queued | | D-TEH-5 | W4: residue deleted, crate renamed `thinking-lab` with a `--manifest-path` CI line; §2 rows closed; `TD-THINKING-ENGINE-EXCLUDED-DEBT-1` paid | Queued — closes the chapter | diff --git a/.claude/plans/thinking-engine-harvest-closure-v1.md b/.claude/plans/thinking-engine-harvest-closure-v1.md index c30dfc27c..80f94a452 100644 --- a/.claude/plans/thinking-engine-harvest-closure-v1.md +++ b/.claude/plans/thinking-engine-harvest-closure-v1.md @@ -208,7 +208,7 @@ harvest and not an amputation. |---|---|---|---| | D-TEH-0 | census + fate table + open-row reconciliation + idea harvest (this plan) | plan + board rows | Shipped (this PR) | | D-TEH-1 | W1: `bridge_gate` (seven items) → `lance_graph_contract::bridge_gate`; callcenter re-imports and drops the path dep; thinking-engine keeps a re-export shim | contract + callcenter | **Shipped 2026-09-02** — edge measured before (required dep, 6 crossing sites, dep-drop fails 6 × E0433) and after (zero thinking-engine deps in callcenter metadata; 1303 + 156 tests, driver default + `with-engine` green). The `with-engine` re-point is NOT part of this wave: D-TTV-1 is Queued and the engine hook still lives in thinking-engine, so there is nothing to re-point it at (stop condition honoured). thinking-engine is now a leaf for every REQUIRED edge; the one remaining edge is the ALU's optional engine hook | -| D-TEH-2 | W2: ghost prior harvested as planner `nars/ghost_prior.rs` over `WisdomMarker`, per-thought, with two-sided falsifiers; crate `ghosts.rs` deleted | planner | **Shipped 2026-09-02** — planner `nars/ghost_prior.rs` (`GhostPrior`, `PriorFloor`, `Trace`, `calibration::{recurrence_fixture, discrimination}`; 12 tests); `ghosts.rs` + `examples/think.rs` deleted; lab `persona`/`world_model`/`awareness_dto` re-pointed to `contract::escalation::GhostEcho` (TD-GHOST-ECHO-DUP-1 resolved). Calibration gate REVERSED the first-declared floor: `Marker` (0.1, never pruned) discriminates ≥ `Trace` (0.001) on every fixture row and strictly once the remembered pattern ages past its prune point (disc 0.0188 vs 0.0000 at 30 stale / age 20 and 60); default = `Marker`. Consumer D-HOUSE-4 unblocked | +| D-TEH-2 | W2: ghost prior harvested as planner `nars/ghost_prior.rs` over `WisdomMarker`, per-thought, with two-sided falsifiers; crate `ghosts.rs` deleted | planner | **Shipped 2026-09-02** — planner `nars/ghost_prior.rs` (`GhostPrior`, `PriorFloor`, `Trace`, `calibration::{recurrence_fixture, discrimination}`; 14 tests); `ghosts.rs` + `examples/think.rs` deleted; lab `persona`/`world_model`/`awareness_dto` re-pointed to `contract::escalation::GhostEcho` (TD-GHOST-ECHO-DUP-1 resolved). Calibration gate REVERSED the first-declared floor: `Marker` (0.1, never pruned) discriminates ≥ `Trace` (0.001) on every fixture row and strictly once the remembered pattern ages past its prune point (disc 0.0188 vs 0.0000 at 30 stale / age 20 and 60); default = `Marker`. Consumer D-HOUSE-4 unblocked | | D-TEH-3 | W2: calibration MATH → jc (ruling 4: compare, then lift or perfect in jc; crate copies deleted); `semantic_chunker` / `spiral_segment` decided by their falsifiers | jc / deepnsm-v2 / codec home | Queued | | D-TEH-4 | W3: M8 engine collapse with parity suite; cascade shapes and lens modules collapse | thinking-engine → the one engine | Queued (owns ENTROPY M8) | | D-TEH-5 | W4: residue deleted, crate renamed `thinking-lab` with a CI line; §2 rows closed; TD paid | workspace | Queued — closes the chapter | diff --git a/crates/lance-graph-planner/src/nars/ghost_prior.rs b/crates/lance-graph-planner/src/nars/ghost_prior.rs index 7793d0a82..056ad3317 100644 --- a/crates/lance-graph-planner/src/nars/ghost_prior.rs +++ b/crates/lance-graph-planner/src/nars/ghost_prior.rs @@ -276,22 +276,35 @@ impl GhostPrior { } /// Strongest live trace per atom at or above [`Self::SUMMARY_FLOOR`], - /// strongest first, one entry per atom. + /// strongest first, one entry per atom. The per-atom maximum is taken + /// BEFORE the strength sort (a sort-then-adjacent-dedup, as the source + /// did it, lets one atom appear twice whenever another atom's trace sorts + /// between its two — Codex on #1142). pub fn summary(&self) -> Vec<(u16, GhostEcho, f32)> { - let mut v: Vec<(u16, GhostEcho, f32)> = self - .traces - .iter() - .filter_map(|t| { - let i = self.live_intensity(t)?; - (i >= Self::SUMMARY_FLOOR).then_some((t.atom, t.marker.ghost, i)) - }) - .collect(); + let mut best: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for t in &self.traces { + let Some(i) = self.live_intensity(t) else { + continue; + }; + if i < Self::SUMMARY_FLOOR { + continue; + } + best.entry(t.atom) + .and_modify(|cur| { + if i > cur.1 { + *cur = (t.marker.ghost, i); + } + }) + .or_insert((t.marker.ghost, i)); + } + let mut v: Vec<(u16, GhostEcho, f32)> = + best.into_iter().map(|(a, (g, i))| (a, g, i)).collect(); v.sort_by(|a, b| { b.2.partial_cmp(&a.2) .unwrap_or(std::cmp::Ordering::Equal) .then(a.0.cmp(&b.0)) }); - v.dedup_by_key(|e| e.0); v } } @@ -302,17 +315,28 @@ pub mod calibration { use super::{GhostPrior, PriorFloor}; use lance_graph_contract::escalation::GhostEcho; + /// The smallest atom space the fixture can address: pattern A sits on + /// atoms 10/20/30 and the shift pattern B on 60/61/62. Stale patterns + /// beyond the space are simply not predicted over (they still consume a + /// cycle each), so they impose no bound. + pub const FIXTURE_MIN_ATOMS: usize = 63; + /// The recurrence fixture: pattern A is imprinted once, then /// `stale_patterns` OTHER disjoint patterns are imprinted (one per cycle, /// the memory of a life lived since), then the prior is aged `age` more /// cycles. Returns `(fe_recurrence, fe_shift)`: free energy when A recurs - /// vs when an unseen pattern B appears. `n_atoms` is the atom space. + /// vs when an unseen pattern B appears. `n_atoms` is the atom space; + /// `None` when it is smaller than [`FIXTURE_MIN_ATOMS`] (the fixture's + /// atoms would fall outside it — Codex on #1142). pub fn recurrence_fixture( floor: PriorFloor, stale_patterns: usize, age: u32, n_atoms: usize, - ) -> (f32, f32) { + ) -> Option<(f32, f32)> { + if n_atoms < FIXTURE_MIN_ATOMS { + return None; + } let a: [(u16, f32); 3] = [(10, 1.0), (20, 0.8), (30, 0.6)]; let mut prior = GhostPrior::new(floor); prior.imprint(&a, GhostEcho::Thought); @@ -335,26 +359,27 @@ pub mod calibration { shift[60] = 1.0; shift[61] = 0.8; shift[62] = 0.6; - (prior.free_energy(&recur), prior.free_energy(&shift)) + Some((prior.free_energy(&recur), prior.free_energy(&shift))) } /// Discrimination = `fe_shift − fe_recurrence` on the fixture: how far /// apart the prior holds "this is familiar" from "this is new" after it /// has aged. Higher is better; ≤ 0 means the prior can no longer tell. + /// `None` under the same condition as [`recurrence_fixture`]. pub fn discrimination( floor: PriorFloor, stale_patterns: usize, age: u32, n_atoms: usize, - ) -> f32 { - let (recur, shift) = recurrence_fixture(floor, stale_patterns, age, n_atoms); - shift - recur + ) -> Option { + let (recur, shift) = recurrence_fixture(floor, stale_patterns, age, n_atoms)?; + Some(shift - recur) } } #[cfg(test)] mod tests { - use super::calibration::{discrimination, recurrence_fixture}; + use super::calibration::{discrimination, recurrence_fixture, FIXTURE_MIN_ATOMS}; use super::*; const N: usize = 256; @@ -508,10 +533,14 @@ mod tests { let mut table = String::new(); let mut default_wins_everywhere = true; for &(stale, age) in &[(0usize, 0u32), (0, 20), (30, 0), (30, 20), (30, 60)] { - let d_trace = discrimination(PriorFloor::Trace, stale, age, N); - let d_marker = discrimination(PriorFloor::Marker, stale, age, N); - let (rt, st) = recurrence_fixture(PriorFloor::Trace, stale, age, N); - let (rm, sm) = recurrence_fixture(PriorFloor::Marker, stale, age, N); + let d_trace = + discrimination(PriorFloor::Trace, stale, age, N).expect("N ≥ FIXTURE_MIN_ATOMS"); + let d_marker = + discrimination(PriorFloor::Marker, stale, age, N).expect("N ≥ FIXTURE_MIN_ATOMS"); + let (rt, st) = recurrence_fixture(PriorFloor::Trace, stale, age, N) + .expect("N ≥ FIXTURE_MIN_ATOMS"); + let (rm, sm) = recurrence_fixture(PriorFloor::Marker, stale, age, N) + .expect("N ≥ FIXTURE_MIN_ATOMS"); table.push_str(&format!( "stale={stale:>2} age={age:>2} | Trace: recur={rt:.4} shift={st:.4} disc={d_trace:.4} | Marker: recur={rm:.4} shift={sm:.4} disc={d_marker:.4}\n" )); @@ -537,8 +566,10 @@ mod tests { // Anti-vacuity for the gate: if both floors gave the same numbers the // gate would pass trivially. After 60 cycles a Trace-floor trace is // inert and a Marker-floor trace sits at 0.1 — the predictions differ. - let (rt, _) = recurrence_fixture(PriorFloor::Trace, 30, 60, N); - let (rm, _) = recurrence_fixture(PriorFloor::Marker, 30, 60, N); + let (rt, _) = + recurrence_fixture(PriorFloor::Trace, 30, 60, N).expect("N ≥ FIXTURE_MIN_ATOMS"); + let (rm, _) = + recurrence_fixture(PriorFloor::Marker, 30, 60, N).expect("N ≥ FIXTURE_MIN_ATOMS"); assert!( (rt - rm).abs() > 1e-3, "floors must be distinguishable on the fixture: {rt} vs {rm}" @@ -600,6 +631,25 @@ mod tests { assert_eq!(s[0].1, GhostEcho::Staunen); } + #[test] + fn summary_is_one_entry_per_atom_even_when_another_atom_sorts_between() { + // Codex's repro on #1142: atom 1 at 0.9 and 0.1 with atom 2 at 0.5 + // between them — a sort-then-adjacent-dedup returns atom 1 twice. + let mut p = GhostPrior::new(PriorFloor::DEFAULT); + p.imprint(&[(1, 0.9), (2, 0.5), (1, 0.1)], GhostEcho::Thought); + let s = p.summary(); + assert_eq!(s.len(), 2, "one entry per atom: {s:?}"); + assert_eq!(s[0], (1, GhostEcho::Thought, 0.9)); + assert_eq!(s[1].0, 2); + } + + #[test] + fn calibration_fixture_refuses_an_atom_space_it_cannot_address() { + assert!(recurrence_fixture(PriorFloor::DEFAULT, 0, 0, FIXTURE_MIN_ATOMS - 1).is_none()); + assert!(discrimination(PriorFloor::DEFAULT, 0, 0, 0).is_none()); + assert!(recurrence_fixture(PriorFloor::DEFAULT, 30, 60, FIXTURE_MIN_ATOMS).is_some()); + } + #[test] fn two_priors_are_independent_state() { // Ownership: no shared field. Imprinting one never touches the other. diff --git a/crates/thinking-engine/src/persona.rs b/crates/thinking-engine/src/persona.rs index f4fb28d5a..87dff41e3 100644 --- a/crates/thinking-engine/src/persona.rs +++ b/crates/thinking-engine/src/persona.rs @@ -342,9 +342,12 @@ pub struct A2AMessage { } impl A2AMessage { - pub fn thought(from: &Agent, to: &str, bus: FastBusDto, weight: f32) -> Self { + /// `from` is the sender's snapshot — `Agent::to_dto(ghost_count)` built + /// by the caller that owns the thought's `GhostPrior`, so the receiver + /// sees the real active-trace count rather than a hardcoded zero. + pub fn thought(from: AgentDto, to: &str, bus: FastBusDto, weight: f32) -> Self { Self { - from: from.to_dto(0), + from, to: to.into(), payload: A2APayload::Thought(bus), resonance_weight: weight, @@ -353,13 +356,14 @@ impl A2AMessage { } } + /// `from`: the sender's snapshot, see [`Self::thought`]. pub fn knowledge( - from: &Agent, + from: AgentDto, to: &str, triples: Vec, ) -> Self { Self { - from: from.to_dto(0), + from, to: to.into(), payload: A2APayload::Knowledge(triples), resonance_weight: 1.0, @@ -368,11 +372,13 @@ impl A2AMessage { } } - pub fn persona_exchange(from: &Agent, to: &str) -> Self { + /// `from`: the sender's snapshot, see [`Self::thought`]; it is carried + /// both as the envelope sender and as the exchanged payload. + pub fn persona_exchange(from: AgentDto, to: &str) -> Self { Self { - from: from.to_dto(0), + from: from.clone(), to: to.into(), - payload: A2APayload::PersonaExchange(from.to_dto(0)), + payload: A2APayload::PersonaExchange(from), resonance_weight: 1.0, style_hint: None, timestamp: 0, @@ -531,7 +537,8 @@ mod tests { 0.7, 5, ); - let msg = A2AMessage::thought(&sender, "receiver", bus, 0.9); + let msg = A2AMessage::thought(sender.to_dto(3), "receiver", bus, 0.9); + assert_eq!(msg.from.ghost_count, 3, "the caller-owned count reaches the receiver"); assert_eq!(msg.to, "receiver"); assert_eq!(msg.resonance_weight, 0.9); assert_eq!(msg.from.mode, PersonaMode::Work); @@ -540,7 +547,7 @@ mod tests { #[test] fn a2a_persona_exchange() { let agent = Agent::new("a1", PersonaProfile::personal()); - let msg = A2AMessage::persona_exchange(&agent, "a2"); + let msg = A2AMessage::persona_exchange(agent.to_dto(0), "a2"); match msg.payload { A2APayload::PersonaExchange(dto) => assert_eq!(dto.mode, PersonaMode::Personal), _ => panic!("wrong payload"), From 5204d6e6bb1c04593a75b87f7d8dc12e805bc29a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:19:03 +0000 Subject: [PATCH 5/6] D-TEH-2: calibration fixture refuses a stale-pattern count whose atom id would wrap u16 (CodeRabbit on #1142) FIXTURE_MAX_STALE_PATTERNS = 21812 (100 + 3*21811 + 2 = u16::MAX); recurrence_fixture / discrimination return None above it and the atom id is built with checked arithmetic. Test pins both sides of the bound. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PFnYKqw6d7TTiB9cT8eFdK --- .../src/nars/ghost_prior.rs | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/lance-graph-planner/src/nars/ghost_prior.rs b/crates/lance-graph-planner/src/nars/ghost_prior.rs index 056ad3317..6c1ea6db2 100644 --- a/crates/lance-graph-planner/src/nars/ghost_prior.rs +++ b/crates/lance-graph-planner/src/nars/ghost_prior.rs @@ -321,28 +321,36 @@ pub mod calibration { /// cycle each), so they impose no bound. pub const FIXTURE_MIN_ATOMS: usize = 63; + /// The most stale patterns the fixture can lay: pattern `k` sits on atoms + /// `100 + 3k .. 100 + 3k + 2`, and the last of those must fit a `u16` + /// (`100 + 3·21811 + 2 = 65535`). One more would wrap the atom id back + /// onto a live pattern (CodeRabbit on #1142). + pub const FIXTURE_MAX_STALE_PATTERNS: usize = 21_812; + /// The recurrence fixture: pattern A is imprinted once, then /// `stale_patterns` OTHER disjoint patterns are imprinted (one per cycle, /// the memory of a life lived since), then the prior is aged `age` more /// cycles. Returns `(fe_recurrence, fe_shift)`: free energy when A recurs /// vs when an unseen pattern B appears. `n_atoms` is the atom space; /// `None` when it is smaller than [`FIXTURE_MIN_ATOMS`] (the fixture's - /// atoms would fall outside it — Codex on #1142). + /// atoms would fall outside it — Codex on #1142) or when `stale_patterns` + /// exceeds [`FIXTURE_MAX_STALE_PATTERNS`] (a stale atom id would wrap). pub fn recurrence_fixture( floor: PriorFloor, stale_patterns: usize, age: u32, n_atoms: usize, ) -> Option<(f32, f32)> { - if n_atoms < FIXTURE_MIN_ATOMS { + if n_atoms < FIXTURE_MIN_ATOMS || stale_patterns > FIXTURE_MAX_STALE_PATTERNS { return None; } let a: [(u16, f32); 3] = [(10, 1.0), (20, 0.8), (30, 0.6)]; let mut prior = GhostPrior::new(floor); prior.imprint(&a, GhostEcho::Thought); for k in 0..stale_patterns { - // Disjoint from A and from B: atoms 100.. in steps of 3. - let base = 100 + (k as u16) * 3; + // Disjoint from A and from B: atoms 100.. in steps of 3. Checked: + // the bound above guarantees it, and a wrap would alias a live atom. + let base = u16::try_from(100 + 3 * k).ok()?; prior.imprint( &[(base, 0.9), (base + 1, 0.7), (base + 2, 0.5)], GhostEcho::Affinity, @@ -365,7 +373,7 @@ pub mod calibration { /// Discrimination = `fe_shift − fe_recurrence` on the fixture: how far /// apart the prior holds "this is familiar" from "this is new" after it /// has aged. Higher is better; ≤ 0 means the prior can no longer tell. - /// `None` under the same condition as [`recurrence_fixture`]. + /// `None` under the same conditions as [`recurrence_fixture`]. pub fn discrimination( floor: PriorFloor, stale_patterns: usize, @@ -379,7 +387,9 @@ pub mod calibration { #[cfg(test)] mod tests { - use super::calibration::{discrimination, recurrence_fixture, FIXTURE_MIN_ATOMS}; + use super::calibration::{ + discrimination, recurrence_fixture, FIXTURE_MAX_STALE_PATTERNS, FIXTURE_MIN_ATOMS, + }; use super::*; const N: usize = 256; @@ -650,6 +660,25 @@ mod tests { assert!(recurrence_fixture(PriorFloor::DEFAULT, 30, 60, FIXTURE_MIN_ATOMS).is_some()); } + #[test] + fn calibration_fixture_refuses_a_stale_count_whose_atom_id_would_wrap() { + // 100 + 3·21811 + 2 = 65535 = u16::MAX exactly: the last admissible + // pattern; one more (index 21812) starts at 65536 and wraps. + assert_eq!( + 100 + 3 * (FIXTURE_MAX_STALE_PATTERNS - 1) + 2, + usize::from(u16::MAX) + ); + assert!( + recurrence_fixture(PriorFloor::DEFAULT, FIXTURE_MAX_STALE_PATTERNS + 1, 0, N).is_none() + ); + assert!( + discrimination(PriorFloor::DEFAULT, FIXTURE_MAX_STALE_PATTERNS + 1, 0, N).is_none() + ); + assert!( + recurrence_fixture(PriorFloor::DEFAULT, FIXTURE_MAX_STALE_PATTERNS, 0, N).is_some() + ); + } + #[test] fn two_priors_are_independent_state() { // Ownership: no shared field. Imprinting one never touches the other. From 8b64aac71eab53f886429918f967b156e7c4f6f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 21:21:37 +0000 Subject: [PATCH 6/6] D-TEH-2: board test count 14 -> 15 after the u16-wrap boundary test Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PFnYKqw6d7TTiB9cT8eFdK --- .claude/board/LATEST_STATE.md | 2 +- .claude/board/PR_ARC_INVENTORY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 0424a6447..e265591d0 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,7 +1,7 @@ ## 2026-09-02 — branch (D-TEH-2, after #1141 merged): ghost prior harvested into the planner — INVENTORY DELTA - MERGED #1141 (D-HOUSE-1, `3102776`): no contract type; planner example `house_differential.rs` + plan/board records (see PR_ARC). -- ADDED planner `lance_graph_planner::nars::ghost_prior` — `GhostPrior` (per-thought Friston prior over atoms: `imprint` / `bias` / `prediction` / `free_energy` / `active_count` / `prune` / `summary` / `tick` / `echo_for`), `PriorFloor { Marker, Trace }` with `DEFAULT = Marker` (decided by the calibration gate, see EPIPHANIES `E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1`), `Trace` (atom + `WisdomMarker` + birth cycle), `calibration::{recurrence_fixture, discrimination}` (library code so a consumer can re-run the gate on its own fixture). Re-exported as `nars::{GhostPrior, PriorFloor}`. 14 tests (incl. the Codex-review pair: summary per-atom-max-before-sort, fixture refuses an atom space below `calibration::FIXTURE_MIN_ATOMS` = 63 — `recurrence_fixture` / `discrimination` return `Option`). Consumes contract `escalation::{GhostEcho, WisdomMarker}` only; no new dependency, no new contract type, no tenant, no layout change. +- ADDED planner `lance_graph_planner::nars::ghost_prior` — `GhostPrior` (per-thought Friston prior over atoms: `imprint` / `bias` / `prediction` / `free_energy` / `active_count` / `prune` / `summary` / `tick` / `echo_for`), `PriorFloor { Marker, Trace }` with `DEFAULT = Marker` (decided by the calibration gate, see EPIPHANIES `E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1`), `Trace` (atom + `WisdomMarker` + birth cycle), `calibration::{recurrence_fixture, discrimination}` (library code so a consumer can re-run the gate on its own fixture). Re-exported as `nars::{GhostPrior, PriorFloor}`. 15 tests (incl. the Codex-review pair: summary per-atom-max-before-sort, fixture refuses an atom space below `calibration::FIXTURE_MIN_ATOMS` = 63 — `recurrence_fixture` / `discrimination` return `Option`; and the CodeRabbit boundary: `calibration::FIXTURE_MAX_STALE_PATTERNS` = 21_812 refuses a stale-pattern count whose atom id would wrap `u16`). Consumes contract `escalation::{GhostEcho, WisdomMarker}` only; no new dependency, no new contract type, no tenant, no layout change. - REMOVED (lab crate, excluded from the workspace) `thinking_engine::ghosts` (`GhostType`, `Ghost`, `GhostField`) and `examples/think.rs` (its only consumer). `persona::Agent` lost its `ghosts` field; `Agent::to_dto(ghost_count)`, `Agent::self_model(.., ghost_count, dominant_ghost)` and `WorldModelDto::build(.., trace_count, dominant_trace)` take the thought's prior summary as arguments; the `A2AMessage::{thought, knowledge, persona_exchange}` constructors take the sender's `AgentDto` snapshot (built by the caller via `to_dto(ghost_count)`) instead of hardcoding a zero count; `SelfModelDto::dominant_ghost_type`, `GestaltState::dominant_trace`, `QualiaDto::traces` are typed over `contract::escalation::GhostEcho`. Lab crate: `cargo check --lib --examples` clean (pre-existing warnings only), persona/world_model/awareness_dto tests 14/14. - RESOLVED `TD-GHOST-ECHO-DUP-1` (one declaration of the eight echoes remains — the contract's). - UNCHANGED: `contract::escalation` shapes (`GhostEcho`, `WisdomMarker::{FLOOR, DECAY, intensity_at}`) — only a doc comment; the ALU artery files (`dto.rs`, `engine_bridge.rs`, `cognitive_shader.rs`, `mailbox_soa.rs`) untouched. diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index 58fb710e5..5c0816da4 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -12,7 +12,7 @@ ## 2026-09-02 — lance-graph branch `claude/medcare-rs-continue-6nhbxn` (D-TEH-2 PR, after #1141) — ghost prior harvested into the planner; `ghosts.rs` deleted -- **Added:** `crates/lance-graph-planner/src/nars/ghost_prior.rs` — `GhostPrior`, `PriorFloor`, `Trace`, `calibration::{recurrence_fixture, discrimination}` (both `Option` below `FIXTURE_MIN_ATOMS`), 14 tests (monotone decay to each floor with the inert cycle derived from the constant, decay-constant load-bearing both ways, two-sided free energy under both floors, the calibration gate on the declared default, anti-vacuity that the floors differ, prediction shape, imprint cap, echo rule, independence of two priors); `pub mod ghost_prior` + re-export in `nars/mod.rs`. EPIPHANIES `E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1`. +- **Added:** `crates/lance-graph-planner/src/nars/ghost_prior.rs` — `GhostPrior`, `PriorFloor`, `Trace`, `calibration::{recurrence_fixture, discrimination}` (both `Option` below `FIXTURE_MIN_ATOMS` and above `FIXTURE_MAX_STALE_PATTERNS`), 15 tests (monotone decay to each floor with the inert cycle derived from the constant, decay-constant load-bearing both ways, two-sided free energy under both floors, the calibration gate on the declared default, anti-vacuity that the floors differ, prediction shape, imprint cap, echo rule, independence of two priors); `pub mod ghost_prior` + re-export in `nars/mod.rs`. EPIPHANIES `E-THE-CALIBRATION-GATE-REVERSED-THE-DECLARED-FLOOR-1`. - **Removed:** `crates/thinking-engine/src/ghosts.rs`, `crates/thinking-engine/examples/think.rs`; `pub mod ghosts` from the lab `lib.rs`. - **Changed (lab crate):** `persona.rs` (`Agent` without a trace field; `to_dto` / `self_model` take the prior summary; `A2AMessage` constructors take the sender's `AgentDto`; `SelfModelDto` over `GhostEcho`), `world_model.rs` (`build` takes `trace_count` + `dominant_trace`; `GhostEcho`), `awareness_dto.rs` (`GhostEcho`), `domino.rs` (doc). Contract `escalation.rs`: `GhostEcho` doc comment (retired-mirror note). TECH_DEBT `TD-GHOST-ECHO-DUP-1` → RESOLVED. Plan §5 D-TEH-2 → Shipped, §3 W2 result; STATUS_BOARD D-TEH-2 Shipped, D-HOUSE-4 unblocked. - **Measured:** calibration gate rejected the first-declared `Trace` floor — discrimination 0.0000 vs `Marker` 0.0188 at 30 stale patterns / age 20 and 60 (equal at 0.0188 with no stale memory); absolute baseline 0.35 vs 0.07. Default = `Marker`. Planner clippy `-D warnings` + fmt clean; lab `cargo check --lib --examples` clean; lab tests 14/14.