diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index c2d50a2d3..5d4c077df 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -1,3 +1,9 @@ +## 2026-10-20 — D-TGB harvest and D-MPS-9 groundwork (Haiku) + +- Five runs: `substrate-mapper` over MORK `kernel/src/space.rs` (47 items, six verb bodies verbatim), over `mork-pathmap-probe/src` (267 items), over `lance-graph-quack/src` (201 rows); `harvest-scout` for the write-path symbols (GadamerRevision, pearl, MUL gates, `advance_on_gate`, `LanceCycleWriter`, `EpistemicState5`); `census-counter` (`nal_inventory.tsv` 115 rows / 115 ids, `KanbanColumn` 6, `RevisionKind` 9, `EvidentialEffect` 3, probe tests 42). +- Checks on the main thread: `nal_inventory.tsv` count and the probe test counts re-run (equal); `add_sexpr` 900, `query_multi` 1116, `lower` 1383, `lower_group_by` 1407, `Refusal` at boundary.rs:41 and rmap.rs:84 read at the source (equal); `advance_on_gate` recount 58 against 59 lines in the harvest file (the extra line is the pattern header). +- Misses: one MORK mapper run returned BRIEF-INCOMPLETE (asked for a caller space; resumed with "none"); the quack mapper left a stray scratch file (removed) and self-reported one typo in a mentions column. Retired: none. Written by the orchestrating main thread only. + ## 2026-10-20 — synergy map #1592–#1626 and board catch-up for #1621–#1626 (Haiku) - Round 1 (#1592–#1624): seven substrate-mapper runs over about 8 700 lines of plans, research docs and probe code; one harvest of board rows. Four claims re-read at the source, all held. A few off-by-one line numbers and one stray placeholder, self-reported. diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 77baf472f..5ab306fed 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -355,8 +355,8 @@ Plan: `.claude/plans/ce64-hypothesis-layers-v1.md`. Plateau: `.claude/knowledge/ | **D-TGA-4** | land through the revision gate into the next Lance version (D-HL-8) | Queued | landing a thought that is behind is refused | | **D-TGA-5** | squash_into (partial squash) and blame | Queued | blame returns original sources after slot reuse | | **D-TGA-6** | MergeQueue | Queued | independent thoughts == landed by hand in FIFO order; a thought reading another negated lands after it; a cycle through negation refused at enqueue | -| **D-TGB-0** | thought git basics: fixtures per write-intention outcome, squash-stack and anomaly fixtures, absent-not-zero fixture, from-scratch oracle (`thought-git-basics-v1.md`) | Queued (lab) | disables written before code | -| **D-TGB-1** | slot pool, autonomous: taken by `branch`, freed by `squash`/`discard`/`land` (the write is the Rubicon decision, 2026-10-20) | Queued (lab) | no slot held by a thought whose result was read; at 63 taken unremarkable thoughts squash first, else `branch` refused | +| **D-TGB-0** | thought git basics: fixtures per write-intention outcome, squash-stack and anomaly fixtures, absent-not-zero fixture, from-scratch oracle (`thought-git-basics-v1.md`) | In PR (lab probe `crates/thought-git-probe`, 19 tests; `PREREGISTRATION.md`) | 5 oracle disables verified red-then-green; every signal has a can-fire and a can-stay-silent case on real contract code; the pearl half on edges moves to D-TGB-3 | +| **D-TGB-1** | slot pool, autonomous: taken by `branch`, freed by `squash`/`discard`/`land` (the write is the Rubicon decision, 2026-10-20) | In PR (lab probe `crates/thought-git-probe/src/slots.rs`) | at 63 taken `branch` picks a FIFO or least-used candidate, squashes it along its dependency chain if it has one, else evicts it (DECISIONS 2026-10-20; was: squash unremarkable first, else refuse); stale handles refused; 6 tests | | **D-TGB-2** | `w_slot` lane labels (W = thought layer, not a pointer) qualified by base version / slot generation | Queued (lab) | no live cell carries a landed slot; old-generation edge refused | | **D-TGB-3** | `Commit` lands one sparse batch through `LanceCycleWriter`; `DurableRef` = dataset + `Ref::Version(branch, version)` | Queued (lab) | empty cycle writes nothing; re-submit reconciles; two branches at version 7 told apart | | **D-TGB-4** | receipt (base ref, program id, seeds, slot generation) instead of history | Queued (lab) | replay from receipt reproduces writes cell for cell | diff --git a/.claude/board/SUPERSESSION-INDEX.md b/.claude/board/SUPERSESSION-INDEX.md index 4fdaa0ab1..b21e9fbba 100644 --- a/.claude/board/SUPERSESSION-INDEX.md +++ b/.claude/board/SUPERSESSION-INDEX.md @@ -56,7 +56,7 @@ coverage instead. | `persist_cycle` | BLOCKED | — | 12 | 8 | 5 | | `CognitiveMarkers` | REPURPOSE | `Commit` | 1 | 0 | 0 | | `DominoCascade` | REPURPOSE | `Commit` | 7 | 1 | 0 | -| `GateDecision` | REPURPOSE | — | 25 | 30 | 26 | +| `GateDecision` | REPURPOSE | — | 26 | 30 | 26 | | `GateState` | REPURPOSE | — | 14 | 1 | 1 | | `MergeMode` | REPURPOSE | — | 8 | 14 | 13 | | `ResonanceDto` | REPURPOSE | `PerturbationDto` | 2 | 12 | 7 | diff --git a/.claude/board/entries/2026-10-20-d-tgb-0-preregistration.md b/.claude/board/entries/2026-10-20-d-tgb-0-preregistration.md new file mode 100644 index 000000000..6ca3e402f --- /dev/null +++ b/.claude/board/entries/2026-10-20-d-tgb-0-preregistration.md @@ -0,0 +1,21 @@ +# 2026-10-20 — D-TGB-0: thought-git pre-registration + +**Status:** MEASURED (lab probe `crates/thought-git-probe`, own workspace, excluded; depends on `lance-graph-contract` only). Operator approved lab code 2026-10-20 ("Lab-Code freigegeben, fang mit D-TGB-0 an"). + +## What landed + +- Fixtures (`src/fixtures.rs`): one write intention per Rubicon outcome (Commit, Plan, Prune, pre-Rubicon veto), an unremarkable stack, a contradiction against a given, a divergent pair, a regression scanpath, a cold/hot surprise pair, an unwritten lane. Twelve rows of four lanes; givens on lane 0 of rows 0..4. +- Oracle (`src/oracle.rs`): the next version from scratch. Only Commit lands; a given is never overwritten (agreement not stored, a different value recorded as a contradiction); a divergence inside one version stores nothing and is recorded; an unwritten cell reads absent. +- `PREREGISTRATION.md`: falsifier, disable and fixture for D-TGB-1..10. + +## Measured + +- 19 tests: 9 oracle, 10 signal. Clippy `-D warnings` and fmt clean. +- Five oracle disables, each red at the named test, restored green: land every outcome (`only_commit_lands`); store agreement (`agreement_with_a_given_is_not_stored`); overwrite the given (`a_different_value_for_a_given_is_refused_and_recorded`); last writer wins (divergence test and the squash test); read missing as 0 (absence test, plus two that read an absent cell). +- Signals on real contract code, each with a can-fire and a can-stay-silent case: `AlphaOverlay::regressions`, `AlphaMask` cold/hot xor, the kanban route per outcome, the MUL gate at `Evaluation`, and `GadamerRevision` (replayed material is `Echo` and routes to `Plan` even with a `Necessary` counterfactual; a second landing has no new root; a preserved contradiction suspends and keeps its tension). +- Naive squash is safe on the unremarkable stack and hides the divergence on the divergent pair, which is the reason an anomaly stops a squash. + +## OPEN + +- Which anomaly signals stop a squash and at what threshold; which revision kinds besides a contradiction count; what "satisfactory" means; kanban card granularity. +- The 59..63 falsifiers on edges (`pearl::hydrate` / `pearl::revise`) need `lance-graph-planner`; they run with D-TGB-3. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index bda625fbe..5df1eb7fa 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,7 +25,7 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -387 entries, 2026-08-06 .. 2026-10-20. +388 entries, 2026-08-06 .. 2026-10-20. | date | entry id | finding | file | |---|---|---|---| @@ -35,6 +35,7 @@ exactly one of them, never both. | 2026-10-20 | `recursive-perspective-rebinding` | | [2026-10-20-recursive-perspective-rebinding.md](2026-10-20-recursive-perspective-rebinding.md) | | 2026-10-20 | `prs-1601-1617-plan-links` | | [2026-10-20-prs-1601-1617-plan-links.md](2026-10-20-prs-1601-1617-plan-links.md) | | 2026-10-20 | `D-TGB` | thought git basics planned from the ideal state | [2026-10-20-d-tgb-thought-git-basics.md](2026-10-20-d-tgb-thought-git-basics.md) | +| 2026-10-20 | `D-TGB-0` | | [2026-10-20-d-tgb-0-preregistration.md](2026-10-20-d-tgb-0-preregistration.md) | | 2026-10-20 | `D-TGA-SLOT-AXIS` | thoughts are 64 slots, and the slot number is CE64's witness pointer | [2026-10-20-d-tga-slot-axis-witness-pointer.md](2026-10-20-d-tga-slot-axis-witness-pointer.md) | | 2026-10-20 | `D-RPR` | | [2026-10-20-d-rpr-consolidation-1624.md](2026-10-20-d-rpr-consolidation-1624.md) | | 2026-10-20 | `D-RPR-1` | | [2026-10-20-d-rpr-1-handoff-lab-demo.md](2026-10-20-d-rpr-1-handoff-lab-demo.md) | diff --git a/.claude/plans/thought-git-basics-v1.md b/.claude/plans/thought-git-basics-v1.md index 11c4e28dd..f3b450697 100644 --- a/.claude/plans/thought-git-basics-v1.md +++ b/.claude/plans/thought-git-basics-v1.md @@ -158,7 +158,7 @@ A petgraph API is an optional usability layer on top, not a storage format or an - re-reading inherited material (replaying the same receipt, another branch of the same sources) can never raise certification, because it carries no new root; - a landed `ContradictionPreserved` lands the contradiction as structure, with certification unchanged; only a revision with a new root may feed `pearl::revise`. - **Consequence for "satisfactory"** (correction item 7): the jc statistics compare strategy branches, but a branch is landable only if the MUL gate lets it through and its revision verdict is not `Echo` / `ClosedCycle`. Which verdicts may raise certification is fixed by `GadamerRevision`, not by jc. -- **Falsifiers (for D-TGB-0):** landing the same thought twice leaves 59..63 unchanged the second time (no new root); a thought whose only support is replayed inherited material lands with certification unchanged; a `Block` gate leaves the Lance version count unchanged; `pearl::hydrate` never changes certification and `pearl::revise` never changes topology on any fixture edge. Open: which revision kinds count as an anomaly that stops a squash, beside the contradiction record. +- **Falsifiers (registered in D-TGB-0; the Gadamer half runs there, the edge half with D-TGB-3):** landing the same thought twice leaves 59..63 unchanged the second time (no new root); a thought whose only support is replayed inherited material lands with certification unchanged; a `Block` gate leaves the Lance version count unchanged; `pearl::hydrate` never changes certification and `pearl::revise` never changes topology on any fixture edge. Open: which revision kinds count as an anomaly that stops a squash, beside the contradiction record. **Optimizing `datafrog` through Quack and the folding polyfill** (operator, 2026-10-20: "Schau auch ob du datafrog> quack oder folding RISC simd Optimierung kannst"). What the repo already measured, and what it allows: @@ -167,6 +167,11 @@ A petgraph API is an optional usability layer on top, not a storage format or an - **Consequence for thought git:** a `datafrog` rule over the metadata table is optimized by laying its relations out at a shared coordinate (D-MPS-8 already does: presence planes per side over a shared-variable domain) and running the rule as stages 1–4. `datafrog` stays the exact oracle; the folded answer must equal its bag of tuples. - **Not covered yet:** recursion. Stages 1–4 are non-recursive; a recursive rule needs relalg `μ` with the fixpoint outside mask-risc (D-MTP-3, not built). Until then a recursive rule runs on `datafrog` itself, under the polyfill rule only where it executes set operations. - **Later, as a possibility** (operator, 2026-10-20: "Später ist auch mork MeTTa relalg Datalog denkbar"): Datalog written in MeTTa, run over MORK and lowered through relalg. Not scheduled; it waits for the same MORK licence clearance (D-MPS-7: MIT intent stated, PR #148 open) and would need the same `datafrog` oracle. +- **Two more Rust Datalog engines beside `datafrog`** (operator, 2026-10-20: "Crepe= souffle / Ascent,= datafrog+"). Both are cloned read-only into the session; nothing depends on them yet. + - `crepe` (AdaWorldAPI fork, b26a580, v0.2.0, MIT OR Apache-2.0): Datalog compiled by a proc macro, "heavily inspired by Souffle" (its README). Semi-naive evaluation, stratified negation, automatic index generation. + - `ascent` (AdaWorldAPI fork, e52c84b, v0.8.1, MIT): `datafrog` plus a rule language. Macro Datalog with stratified negation and aggregation (`sum`, `min`, `max`, `count`, `mean`), user-defined lattices whose last column is joined on conflict, parallel code through `ascent_par!` (rayon), and BYODS (bring your own data structures, OOPSLA). + - Relevance here: `ascent`'s lattice relations are the shape a revision needs, where two facts for one key join instead of both being kept; stratified negation covers "branches with no anomaly". `datafrog` has neither, so rules that need them would otherwise be written by hand. + - **OPEN:** whether either becomes an oracle beside `datafrog`, a rule front end whose relations still execute on the polyfill, or neither. Under "Polyfill, never legacy" neither may become a second executor of set operations. ⊘ Struck: "an `ascent_par!` path is a scheduler of its own and would be one" (operator, 2026-10-20: "Ascent supports rayon"). Rayon is not the problem: it is already a dependency in this workspace (`holograph`, `thinking-engine`), and parallelism is not a second executor. The rule bites on who executes the joins: ascent's own relations and indices would be one, rayon or not. BYODS is the open route around that, since the relation behind a rule can be a data structure we provide. Whether a polyfill-backed BYODS relation fits ascent's interface is unchecked. - **Next step (D-TGB-10):** a pre-registered probe running the D-TGB-8 rules ("which branches are unremarkable", "which landed facts rest on source s") as H14d stages over the metadata table, against `datafrog`, answers identical as bags, costs reported, no threshold claimed before the run. **Kanban gets a planning vocabulary later** (DECISION, operator, 2026-10-20: "Kanban sollte später planungs Vokabular mit einer schönen schnellen effektiven API erhalten. Was lasse ich gerne offen. Es muß aber auch quack shaped folding polyfill methods verstehen"): @@ -211,10 +216,12 @@ Read in the crate sources (`lance-13.0.0/src/dataset.rs`, `dataset/refs.rs`, `da ## Work items (test-first; each lands with its falsifier first) -- [ ] **D-TGB-0 Pre-registration.** Fixtures: one write intention per Rubicon outcome (`Commit`, `Plan`, `Prune`, pre-Rubicon veto); a stack of unremarkable thoughts that squash and continue on a new branch; one thought per anomaly signal that must stop the squash; an unwritten lane that must read as absent, not zero. Oracle: a from-scratch run on main at the next version. Disables written before code. ⊘ Struck 2026-10-20: "three thoughts that reference each other through `w_slot`, one mutual pair" (W names a lane and points nowhere). +- [x] **D-TGB-0 Pre-registration.** Done in `crates/thought-git-probe` (fixtures, oracle, 19 tests, `PREREGISTRATION.md`); the 59..63 falsifiers on edges need `lance-graph-planner` (`pearl`) and move to D-TGB-3, their Gadamer half is pinned here. Fixtures: one write intention per Rubicon outcome (`Commit`, `Plan`, `Prune`, pre-Rubicon veto); a stack of unremarkable thoughts that squash and continue on a new branch; one thought per anomaly signal that must stop the squash; an unwritten lane that must read as absent, not zero. Oracle: a from-scratch run on main at the next version. Disables written before code. ⊘ Struck 2026-10-20: "three thoughts that reference each other through `w_slot`, one mutual pair" (W names a lane and points nowhere). - [ ] **D-TGB-1 Slot pool, autonomous.** A slot is taken by `branch` and freed by `squash`, `discard` or `land`, without a decision. - Falsifier: after a run, every slot is either live work or free; no slot is held by a thought whose result was already read. - - Edges: with 63 slots taken, unremarkable thoughts are squashed first; if none is, `branch` is refused, never given a 65th slot. + - ⊘ Struck 2026-10-20: "Edges: with 63 slots taken, unremarkable thoughts are squashed first; if none is, `branch` is refused". DECISION (operator, 2026-10-20: "Please focus on important stuff not on edge cases. The easiest way in the beginning is Fifo vs pruning the least used lanes"): at 63 slots `branch` evicts one live thought, by FIFO or by least use (ties to the oldest); the evicted thought's handle goes stale. Anomaly-aware squashing comes later, if the two simple policies prove insufficient. + - Before evicting, a candidate in a dependency chain is squashed (operator, 2026-10-20: "Squashing if the least used are in a dependency chain. That's probably covering already a lot of heuristics"): into its live parent, or its live child is squashed into it. Only a candidate with neither is evicted. The parent link lives in the pool's bookkeeping, never in W. The new thought's own parent is never the candidate. + - Built in `crates/thought-git-probe/src/slots.rs` (`SlotPool`, `Eviction::{Fifo, LeastUsed}`, `Pressure::{Squashed, Evicted}`), 6 tests. - Disables: never free on squash; squash a thought that carries an anomaly. - ⊘ Struck 2026-10-20: "slot pool on the Rubicon crossing … taken only at `Planning → CognitiveWork`" and its falsifier "no slot exists without a live card". - [ ] **D-TGB-2 Lane labels.** `w_slot` (CE64 bits 53..58) names the thought a state belongs to, qualified by the frame's base version (0) or the slot's generation (1..=63); it is not a pointer between thoughts. The relabel rules are #1628's. diff --git a/Cargo.toml b/Cargo.toml index a5be97e90..d23ff96a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -170,6 +170,10 @@ exclude = [ # on the AdaWorldAPI forks, results only (D-ASM-10). Run from its directory # so its `.cargo/config.toml` applies: `cd crates/mork-pathmap-probe && cargo test`. "crates/mork-pathmap-probe", + # D-TGB-0 thought-git pre-registration: fixtures, the from-scratch oracle, + # and signal checks over lance-graph-contract only. Lab probe; run from + # its directory: `cd crates/thought-git-probe && cargo test`. + "crates/thought-git-probe", # X-C2-1 injection harness (RP-SEAL Tier 0) — fault injection over a # synthetic cycle; anti-vacuity gate reproduces the known in-tree # false-accepts against the REAL live implementations. Verify via diff --git a/crates/thought-git-probe/Cargo.toml b/crates/thought-git-probe/Cargo.toml new file mode 100644 index 000000000..165685153 --- /dev/null +++ b/crates/thought-git-probe/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "thought-git-probe" +version = "0.0.0" +edition = "2024" +publish = false +description = "D-TGB-0 pre-registration: thought-git fixtures, the from-scratch oracle, and checks that the anomaly signals the plan relies on can fire and stay silent. Lab only; no Lance storage." + +# Its own workspace root, excluded from the lance-graph workspace (lab probe). +# Run from THIS directory: `cd crates/thought-git-probe && cargo test`. +[workspace] + +[dependencies] +lance-graph-contract = { path = "../lance-graph-contract" } diff --git a/crates/thought-git-probe/PREREGISTRATION.md b/crates/thought-git-probe/PREREGISTRATION.md new file mode 100644 index 000000000..80338a33a --- /dev/null +++ b/crates/thought-git-probe/PREREGISTRATION.md @@ -0,0 +1,58 @@ +# D-TGB pre-registration (D-TGB-0) + +Written before any thought-git code exists. Every later item is checked +against the oracle in `src/oracle.rs` on the fixtures in `src/fixtures.rs`. +Its falsifier and its disable are fixed here. A disable is a deliberate break +of the implementation; the falsifier must turn red under it. If an item +changes one of these, the change is recorded here with its reason, never made +silently. + +## What D-TGB-0 itself pins (tests in this crate) + +| rule | test | disable that turns it red (verified 2026-10-20) | +|---|---|---| +| only `Commit` lands | `only_commit_lands` | land every outcome | +| agreement with a given is not stored | `agreement_with_a_given_is_not_stored` | store agreeing writes | +| a different value for a given is refused and recorded | `a_different_value_for_a_given_is_refused_and_recorded` | let the offer overwrite the given | +| a divergence in one version stores nothing and is recorded | `a_divergence_in_one_version_stores_nothing_and_is_recorded` | last writer wins | +| an unwritten lane reads absent, not zero | `an_unwritten_lane_reads_absent_not_zero` | read a missing cell as 0 | +| squashing an unremarkable stack changes nothing | `squashing_an_unremarkable_stack_changes_nothing` | — (characterises the naive squash) | +| squashing a divergent pair hides the divergence | `squashing_a_divergent_pair_hides_the_divergence` | last writer wins (also red) | + +Signals the plan relies on, each with a can-fire and a can-stay-silent case +on the real `lance-graph-contract` code (`tests/signals.rs`): + +| signal | fires on | silent on | +|---|---|---| +| regression (`AlphaOverlay::regressions`, `visits > 1`) | `REGRESSION_SCANPATH` (row 3 returned to) | `SMOOTH_SCANPATH` | +| cold/hot surprise (`expected.xor(attended)`) | `SURPRISE_EXPECTED` vs `SURPRISE_ATTENDED` | expected equals a real overlay's attended mask | +| kanban route per outcome | Commit, Plan, Prune, pre-Rubicon veto, each a legal path through `advance` / `revise` / `veto` | — | +| MUL gate | `Block` at `Evaluation` → `Prune`; `Hold` → stays | `Flow` → `Commit` | +| Gadamer, replayed material | `Echo`, `NoIncrease`, routes to `Plan` even with a `Necessary` counterfactual | a new root plus `Necessary` reaches `Commit` | +| Gadamer, second landing | the same encounter against the first result has no new root | the first landing is `IncreaseEligible` | +| Gadamer, preserved contradiction | `ContradictionPreserved`, `Suspend`, the cycle holds, the tension is kept | — | + +## Later items: falsifier, disable, fixture + +| item | falsifier | disable | fixture | +|---|---|---|---| +| D-TGB-1 slot pool (built, `src/slots.rs`) | the 64th branch picks a candidate (FIFO oldest, LeastUsed idlest); a candidate with a live parent is squashed into it, one with a live child absorbs that child; only a chain-free candidate is evicted; the new thought's parent is never removed; stale handles are refused; a freed slot is reused without pressure | ignore the generation; LeastUsed keyed on age only; evict instead of squashing a chain; let the new thought's parent be the candidate | `tests/slots.rs` | +| D-TGB-2 lane labels | after `Commit` no live edge carries the landed slot; an edge from a recycled slot's old generation is refused | read W of a sealed row as meaningful (proposed here; the plan names none) | `rubicon_outcomes` (the Commit thought) | +| D-TGB-3 landing | equals `next_version` on every fixture; an empty cycle writes nothing; two thoughts in one cycle give one version; a re-submitted batch reconciles; a missing metadata row is detected at replay | one batch per thought (second `Commit` in a cycle must hit `HashConflict`) | all fixtures, landed together and one per version | +| D-TGB-3 write into 59..63 | landing the same thought twice leaves 59..63 unchanged; a thought supported only by replayed material lands with certification unchanged; `Block` leaves the version count unchanged; `pearl::hydrate` never changes certification, `pearl::revise` never changes topology | let `revise` lower a certification; let `hydrate` move certification | the Gadamer cases in `tests/signals.rs`, as edges (needs `lance-graph-planner`, added when D-TGB-3 starts) | +| D-TGB-4 receipt | replaying a landed or discarded thought from its receipt reproduces its writes cell for cell | drop the seeds from the receipt | `rubicon_outcomes` (Plan and Prune thoughts are the discarded ones) | +| D-TGB-5 resident folds | `checkout`, `log`, `diff`, `blame` equal the oracle; `diff` across two versions equals Lance's `delta()` | read Lance on the hot path | `consecutive_versions_replace_without_divergence` shape | +| D-TGB-6 MemWAL arm | same answers as D-TGB-3 behind the switch | enable below the measured crossover | as D-TGB-3 | +| D-TGB-7 API + driver | two drivers making the same decisions give byte-identical cells, receipts, metadata rows | let the implementation read a `KanbanColumn` to decide a squash | all fixtures | +| D-TGB-8 abstraction | rule answers equal a direct scan of the metadata table; recorded intents replayed as verbs reproduce the run | answer one rule with a loop that skips the source relation | metadata rows produced by D-TGB-3 on all fixtures | +| D-TGB-9 planning vocabulary | planning answers equal a hand-written Quack `Query`; no allocation proportional to the number of cards | — (written when D-TGB-9 starts) | — | +| D-TGB-10 folded rules | the folded answer equals datafrog's bag of tuples | drop one presence plane from the address mask | as D-TGB-8 | + +## Still OPEN (not decided here) + +- Which anomaly signals stop a squash, and at what threshold. The fixtures + make each signal fire; whether a single regression (`visits == 2`) is + enough is not decided. +- Which revision kinds besides a contradiction count as an anomaly. +- What "satisfactory" means for a write (the `jc` statistic and threshold). +- The kanban card granularity (mailbox card or one per move). diff --git a/crates/thought-git-probe/src/fixtures.rs b/crates/thought-git-probe/src/fixtures.rs new file mode 100644 index 000000000..ced713b2a --- /dev/null +++ b/crates/thought-git-probe/src/fixtures.rs @@ -0,0 +1,162 @@ +//! The D-TGB-0 fixtures. Pure data; nothing here decides anything. +//! +//! The address space is twelve rows of four lanes. Rows 0..4 carry givens on +//! lane 0; every other cell starts absent. Each fixture names the plan's +//! wording it stands for, so a later item can say which one it runs on. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::oracle::{Givens, Version}; + +/// Rows in the fixture address space. +pub const ROWS: u32 = 12; +/// Lanes per row. +pub const LANES: u8 = 4; + +/// One addressable value: a row and a lane within it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Cell { + pub row: u32, + pub lane: u8, +} + +/// Shorthand for a [`Cell`]. +#[must_use] +pub const fn cell(row: u32, lane: u8) -> Cell { + Cell { row, lane } +} + +/// How a thought's write intention ends on the Rubicon lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// `Planning → CognitiveWork → Evaluation → Commit`: the result lands. + Commit, + /// `… → Evaluation → Plan`: back to the in-memory work, nothing lands. + Plan, + /// `… → Evaluation → Prune`: the intention is dropped after evaluation. + Prune, + /// `Planning → Prune`: the pre-Rubicon veto, before any work. + Veto, +} + +/// A thought: its writes (one per cell) and how its write intention ends. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Thought { + pub id: u32, + pub writes: Vec<(Cell, u64)>, + pub outcome: Outcome, +} + +impl Thought { + /// A thought; panics if it writes one cell twice or leaves the address + /// space, because every fixture below relies on one write per cell. + #[must_use] + pub fn new(id: u32, writes: Vec<(Cell, u64)>, outcome: Outcome) -> Self { + let mut seen = BTreeSet::new(); + for &(c, _) in &writes { + assert!( + c.row < ROWS && c.lane < LANES, + "cell {c:?} outside the fixture space" + ); + assert!(seen.insert(c), "thought {id} writes {c:?} twice"); + } + Self { + id, + writes, + outcome, + } + } +} + +/// Layer 0: row r (r < 4) has value `100 + r` on lane 0. +#[must_use] +pub fn givens() -> Givens { + Givens((0..4).map(|r| (cell(r, 0), 100 + u64::from(r))).collect()) +} + +/// The empty previous version (main before any thought landed). +#[must_use] +pub fn main_version() -> Version { + Version::default() +} + +/// One write intention per Rubicon outcome, each writing one absent cell. +#[must_use] +pub fn rubicon_outcomes() -> [Thought; 4] { + [ + Thought::new(10, vec![(cell(4, 1), 41)], Outcome::Commit), + Thought::new(11, vec![(cell(5, 1), 51)], Outcome::Plan), + Thought::new(12, vec![(cell(6, 1), 61)], Outcome::Prune), + Thought::new(13, vec![(cell(7, 1), 71)], Outcome::Veto), + ] +} + +/// A stack of unremarkable thoughts: disjoint absent cells, one agreeing +/// write to a given (agreement is not an anomaly), no conflicts. +#[must_use] +pub fn unremarkable_stack() -> Vec { + vec![ + Thought::new( + 20, + vec![(cell(8, 1), 81), (cell(8, 2), 82)], + Outcome::Commit, + ), + Thought::new(21, vec![(cell(9, 1), 91)], Outcome::Commit), + Thought::new( + 22, + vec![(cell(10, 1), 101), (cell(0, 0), 100)], + Outcome::Commit, + ), + Thought::new(23, vec![(cell(11, 3), 113)], Outcome::Commit), + ] +} + +/// Contradiction: offers a value for a given that differs from it. +#[must_use] +pub fn contradiction_thought() -> Thought { + Thought::new( + 30, + vec![(cell(1, 0), 999), (cell(4, 2), 42)], + Outcome::Commit, + ) +} + +/// Divergence: two thoughts landing together disagree on an absent cell. +#[must_use] +pub fn divergent_pair() -> [Thought; 2] { + [ + Thought::new(31, vec![(cell(5, 2), 7), (cell(6, 2), 62)], Outcome::Commit), + Thought::new(32, vec![(cell(5, 2), 8)], Outcome::Commit), + ] +} + +/// Regression: the row sequence attention visits; row 3 is returned to. +pub const REGRESSION_SCANPATH: [u32; 5] = [1, 3, 2, 3, 4]; +/// The same shape without a return. +pub const SMOOTH_SCANPATH: [u32; 4] = [1, 2, 3, 4]; + +/// Cold/hot surprise: rows an independent route expected, and rows attended. +pub const SURPRISE_EXPECTED: [u32; 3] = [1, 2, 3]; +pub const SURPRISE_ATTENDED: [u32; 3] = [1, 2, 4]; + +/// The lane no fixture writes: every cell on lane 3 except (11, 3). +#[must_use] +pub fn unwritten_cells() -> Vec { + (0..ROWS).filter(|&r| r != 11).map(|r| cell(r, 3)).collect() +} + +/// Every write of every fixture thought, for the absence check. +#[must_use] +pub fn all_writes() -> BTreeMap> { + let mut m: BTreeMap> = BTreeMap::new(); + let mut add = |t: &Thought| { + for &(c, v) in &t.writes { + m.entry(c).or_default().push(v); + } + }; + rubicon_outcomes().iter().for_each(&mut add); + unremarkable_stack().iter().for_each(&mut add); + add(&contradiction_thought()); + divergent_pair().iter().for_each(&mut add); + m +} diff --git a/crates/thought-git-probe/src/lib.rs b/crates/thought-git-probe/src/lib.rs new file mode 100644 index 000000000..d52657c15 --- /dev/null +++ b/crates/thought-git-probe/src/lib.rs @@ -0,0 +1,24 @@ +//! D-TGB-0 — the pre-registration for thought git +//! (`.claude/plans/thought-git-basics-v1.md`, work item D-TGB-0). +//! +//! Three things live here, all written before any thought-git code exists: +//! +//! - [`fixtures`]: the thoughts every later D-TGB item is tested on, one per +//! Rubicon outcome, a stack of unremarkable thoughts, one thought per +//! anomaly signal, and a lane that is never written. +//! - [`oracle`]: the from-scratch reference. Given the givens (layer 0), the +//! previous version and the thoughts that land, it computes the next +//! version without any incremental state. Every later implementation is +//! compared against it. +//! - `tests/`: the oracle's rules, each with a disable that turns it red, and +//! checks that the existing contract signals the plan relies on (regression +//! counts, the cold/hot mask diff, the kanban routes, the Gadamer verdict) +//! fire on the anomaly fixtures and stay silent on the unremarkable ones. +//! +//! `PREREGISTRATION.md` lists the falsifier and the disable of every later +//! D-TGB item, with the fixture it runs on. What is still OPEN there (the +//! anomaly thresholds, what "satisfactory" means) is left open on purpose. + +pub mod fixtures; +pub mod oracle; +pub mod slots; diff --git a/crates/thought-git-probe/src/oracle.rs b/crates/thought-git-probe/src/oracle.rs new file mode 100644 index 000000000..2df70b099 --- /dev/null +++ b/crates/thought-git-probe/src/oracle.rs @@ -0,0 +1,122 @@ +//! The from-scratch oracle: the next version computed from the givens, the +//! previous version and the thoughts that land, with no incremental state. +//! +//! The rules are the ones `thought-git-basics-v1.md` reads out of the existing +//! code (correction item 5) and the capstone's givens row: +//! +//! 1. Only a thought whose outcome is `Commit` lands. `Plan`, `Prune` and the +//! pre-Rubicon veto leave the version unchanged. +//! 2. A cell with a given (layer 0) is never overwritten. An offered value +//! equal to the given is agreement and is not stored; a different value is +//! refused and recorded as a [`Contradiction`]. +//! 3. A cell without a given takes the landed value. If thoughts landing in +//! the same version offer different values for it, nothing is stored and a +//! [`Divergence`] is recorded; the cell keeps its previous state. +//! 4. A cell nobody wrote reads as absent (`None`), never as zero. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::fixtures::{Cell, Outcome, Thought}; + +/// Layer 0: hydrated values that no thought may overwrite. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Givens(pub BTreeMap); + +/// A refused write to a given. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Contradiction { + pub cell: Cell, + pub given: u64, + pub offered: u64, + pub thought: u32, +} + +/// Two or more thoughts landing in one version offered different values for +/// one cell without a given. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Divergence { + pub cell: Cell, + /// `(value, thought)`, sorted. + pub offers: Vec<(u64, u32)>, +} + +/// One version: the cells written above layer 0, plus the records of what was +/// refused while producing it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Version { + pub cells: BTreeMap, + pub contradictions: Vec, + pub divergences: Vec, +} + +impl Version { + /// What a reader sees at `cell`: the given if there is one, else the + /// landed value, else absent. + #[must_use] + pub fn read(&self, givens: &Givens, cell: Cell) -> Option { + givens + .0 + .get(&cell) + .copied() + .or_else(|| self.cells.get(&cell).copied()) + } +} + +/// The next version, computed from scratch. +#[must_use] +pub fn next_version(givens: &Givens, prior: &Version, landing: &[Thought]) -> Version { + let mut cells = prior.cells.clone(); + let mut contradictions = Vec::new(); + let mut offers: BTreeMap> = BTreeMap::new(); + + for t in landing.iter().filter(|t| t.outcome == Outcome::Commit) { + for &(cell, value) in &t.writes { + match givens.0.get(&cell) { + Some(&given) if given == value => {} + Some(&given) => contradictions.push(Contradiction { + cell, + given, + offered: value, + thought: t.id, + }), + None => { + offers.entry(cell).or_default().insert((value, t.id)); + } + } + } + } + + let mut divergences = Vec::new(); + for (cell, set) in offers { + let values: BTreeSet = set.iter().map(|&(v, _)| v).collect(); + if values.len() == 1 { + cells.insert(cell, *values.iter().next().expect("one value")); + } else { + divergences.push(Divergence { + cell, + offers: set.into_iter().collect(), + }); + } + } + + contradictions.sort(); + Version { + cells, + contradictions, + divergences, + } +} + +/// Squash a stack into one thought: the union of its writes, later writes +/// replacing earlier ones at the same cell. This is the naive squash; the +/// tests show why it is only safe on a stack without anomalies. +#[must_use] +pub fn squash(id: u32, stack: &[Thought]) -> Thought { + let mut merged: BTreeMap = BTreeMap::new(); + for t in stack { + for &(cell, value) in &t.writes { + merged.insert(cell, value); + } + } + Thought::new(id, merged.into_iter().collect(), Outcome::Commit) +} diff --git a/crates/thought-git-probe/src/slots.rs b/crates/thought-git-probe/src/slots.rs new file mode 100644 index 000000000..3e46d94ec --- /dev/null +++ b/crates/thought-git-probe/src/slots.rs @@ -0,0 +1,255 @@ +//! D-TGB-1: the slot pool. +//! +//! Slot 0 is the givens (main); slots 1..=63 hold live thoughts. `branch` +//! takes a free slot without any decision; `free` (squash, discard or land) +//! returns it. A thought may branch from another live thought; the pool keeps +//! that parent link in its own bookkeeping (never in the CE64 W bits, which +//! name a lane and point nowhere). +//! +//! When all 63 are taken, `branch` makes room for the new thought: +//! +//! 1. The policy names the candidate: the oldest ([`Eviction::Fifo`]) or the +//! least used ([`Eviction::LeastUsed`], ties to the oldest) (operator, +//! 2026-10-20: "The easiest way in the beginning is Fifo vs pruning the +//! least used lanes"). +//! 2. If the candidate is in a dependency chain, it is squashed instead of +//! lost: into its live parent, or, if it has none, its live child is +//! squashed into it (operator, same day: "Squashing if the least used are +//! in a dependency chain"). Nothing is discarded; the caller merges the +//! writes (`oracle::squash`). +//! 3. Only a candidate with no live parent and no live child is evicted. +//! +//! A [`Slot`] handle carries the generation it was issued under. After its +//! slot is freed, squashed away or evicted, the old handle is refused, so a +//! recycled slot is never read as the thought that used to live in it. + +/// Live slots: 1..=63 (slot 0 is the givens). +pub const LIVE_SLOTS: usize = 63; + +/// Which live thought is the candidate when the pool is full. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Eviction { + /// The thought branched longest ago. + Fifo, + /// The thought whose lane was used least; ties go to the oldest. + LeastUsed, +} + +/// A handle to a live slot, valid while its generation matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Slot { + /// 1..=63. + pub index: u8, + pub generation: u32, +} + +/// How a full pool made room for a new branch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pressure { + /// `from` was squashed into `into`; `from`'s handle is now stale. The + /// caller merges `from`'s writes over `into`'s. + Squashed { + thought: u32, + from: Slot, + into: Slot, + }, + /// The thought had no live parent or child and gave way. + Evicted { thought: u32, slot: Slot, uses: u64 }, +} + +/// A handle whose slot was freed, squashed away or evicted since it was issued. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Stale(pub Slot); + +#[derive(Debug, Clone, Copy)] +struct Live { + thought: u32, + generation: u32, + born: u64, + uses: u64, + /// Index of the live parent this thought branched from. + parent: Option, +} + +/// The 63 live slots and their eviction policy. +#[derive(Debug, Clone)] +pub struct SlotPool { + policy: Eviction, + live: [Option; LIVE_SLOTS], + generations: [u32; LIVE_SLOTS], + clock: u64, +} + +impl SlotPool { + #[must_use] + pub fn new(policy: Eviction) -> Self { + Self { + policy, + live: [None; LIVE_SLOTS], + generations: [0; LIVE_SLOTS], + clock: 0, + } + } + + /// Live thoughts right now. + #[must_use] + pub fn live(&self) -> usize { + self.live.iter().filter(|s| s.is_some()).count() + } + + /// Branch `thought`, from main (`None`) or from a live thought. + /// + /// # Errors + /// [`Stale`] when `from` is no longer live. + pub fn branch( + &mut self, + thought: u32, + from: Option, + ) -> Result<(Slot, Option), Stale> { + let mut parent = from.map(|s| self.live_index(s)).transpose()?; + self.clock += 1; + let (i, pressure) = match self.live.iter().position(Option::is_none) { + Some(i) => (i, None), + None => self.make_room(parent), + }; + // The new thought's parent may have just been squashed away; it + // then hangs off whatever absorbed it. + if let (Some(p), Some(Pressure::Squashed { from, into, .. })) = (parent, pressure) + && usize::from(from.index) - 1 == p + { + parent = Some(usize::from(into.index) - 1); + } + let generation = self.generations[i]; + self.live[i] = Some(Live { + thought, + generation, + born: self.clock, + uses: 0, + parent, + }); + Ok((self.handle(i, generation), pressure)) + } + + /// Record one use of the slot's lane (a write or a read). + /// + /// # Errors + /// [`Stale`] when the slot is no longer live under this handle. + pub fn touch(&mut self, slot: Slot) -> Result<(), Stale> { + let i = self.live_index(slot)?; + if let Some(l) = self.live[i].as_mut() { + l.uses += 1; + } + Ok(()) + } + + /// The thought living in `slot`. + /// + /// # Errors + /// [`Stale`] as for [`touch`](Self::touch). + pub fn thought(&self, slot: Slot) -> Result { + let i = self.live_index(slot)?; + Ok(self.live[i].expect("checked live").thought) + } + + /// Free the slot: the thought was squashed, discarded or landed. Its + /// children now hang off its parent. + /// + /// # Errors + /// [`Stale`] as for [`touch`](Self::touch). + pub fn free(&mut self, slot: Slot) -> Result<(), Stale> { + let i = self.live_index(slot)?; + let parent = self.live[i].expect("checked live").parent; + self.reparent(i, parent); + self.release(i); + Ok(()) + } + + /// `keep`: the new thought's parent, never the candidate (branching from + /// a thought must not be what removes it). + fn make_room(&mut self, keep: Option) -> (usize, Option) { + let c = self.candidate(keep); + let cand = self.live[c].expect("pool is full"); + let (from, into) = if let Some(p) = cand.parent { + (c, p) + } else if let Some(child) = self.oldest_child(c) { + (child, c) + } else { + let slot = self.handle(c, cand.generation); + self.release(c); + return ( + c, + Some(Pressure::Evicted { + thought: cand.thought, + slot, + uses: cand.uses, + }), + ); + }; + let f = self.live[from].expect("live"); + let t = self.live[into].expect("live"); + let pressure = Pressure::Squashed { + thought: f.thought, + from: self.handle(from, f.generation), + into: self.handle(into, t.generation), + }; + if let Some(target) = self.live[into].as_mut() { + target.uses += f.uses; + } + self.reparent(from, Some(into)); + self.release(from); + (from, Some(pressure)) + } + + fn candidate(&self, keep: Option) -> usize { + let key = |l: &Live| match self.policy { + Eviction::Fifo => (0, l.born), + Eviction::LeastUsed => (l.uses, l.born), + }; + self.live + .iter() + .enumerate() + .filter(|&(i, _)| Some(i) != keep) + .filter_map(|(i, s)| s.as_ref().map(|l| (i, key(l)))) + .min_by_key(|&(_, k)| k) + .map(|(i, _)| i) + .expect("pool is full") + } + + fn oldest_child(&self, parent: usize) -> Option { + self.live + .iter() + .enumerate() + .filter_map(|(i, s)| s.filter(|l| l.parent == Some(parent)).map(|l| (i, l.born))) + .min_by_key(|&(_, born)| born) + .map(|(i, _)| i) + } + + /// Hand `i`'s children to `to`. + fn reparent(&mut self, i: usize, to: Option) { + for l in self.live.iter_mut().flatten() { + if l.parent == Some(i) { + l.parent = to; + } + } + } + + fn release(&mut self, i: usize) { + self.live[i] = None; + self.generations[i] = self.generations[i].wrapping_add(1); + } + + fn live_index(&self, slot: Slot) -> Result { + let i = usize::from(slot.index).wrapping_sub(1); + match self.live.get(i).copied().flatten() { + Some(l) if l.generation == slot.generation => Ok(i), + _ => Err(Stale(slot)), + } + } + + fn handle(&self, i: usize, generation: u32) -> Slot { + Slot { + index: u8::try_from(i + 1).expect("63 slots fit in u8"), + generation, + } + } +} diff --git a/crates/thought-git-probe/tests/oracle.rs b/crates/thought-git-probe/tests/oracle.rs new file mode 100644 index 000000000..71829f079 --- /dev/null +++ b/crates/thought-git-probe/tests/oracle.rs @@ -0,0 +1,146 @@ +//! The oracle's four rules, each pinned on a fixture whose answer differs +//! under the rule's disable (named in each test). + +use thought_git_probe::fixtures::{ + self, Outcome, cell, contradiction_thought, divergent_pair, givens, main_version, + rubicon_outcomes, unremarkable_stack, unwritten_cells, +}; +use thought_git_probe::oracle::{Contradiction, next_version, squash}; + +/// Rule 1. Disable: land every outcome → the Plan, Prune and Veto cells appear. +#[test] +fn only_commit_lands() { + let g = givens(); + let v = next_version(&g, &main_version(), &rubicon_outcomes()); + assert_eq!(v.read(&g, cell(4, 1)), Some(41), "Commit lands"); + for c in [cell(5, 1), cell(6, 1), cell(7, 1)] { + assert_eq!(v.read(&g, c), None, "{c:?} must not land"); + } + assert_eq!(v.cells.len(), 1); +} + +/// Rule 2, agreement half. Disable: store agreeing writes → `cells` holds (0, 0). +#[test] +fn agreement_with_a_given_is_not_stored() { + let g = givens(); + let v = next_version(&g, &main_version(), &unremarkable_stack()); + assert!( + !v.cells.contains_key(&cell(0, 0)), + "agreement stored as a value" + ); + assert_eq!(v.read(&g, cell(0, 0)), Some(100)); + assert!( + v.contradictions.is_empty(), + "agreement is not a contradiction" + ); +} + +/// Rule 2, refusal half. Disable: let the offer overwrite the given → the read +/// returns 999 and no record exists. +#[test] +fn a_different_value_for_a_given_is_refused_and_recorded() { + let g = givens(); + let t = contradiction_thought(); + let v = next_version(&g, &main_version(), std::slice::from_ref(&t)); + assert_eq!(v.read(&g, cell(1, 0)), Some(101), "given overwritten"); + assert_eq!( + v.contradictions, + vec![Contradiction { + cell: cell(1, 0), + given: 101, + offered: 999, + thought: 30, + }] + ); + // The thought's other write still lands: a refusal is per cell. + assert_eq!(v.read(&g, cell(4, 2)), Some(42)); +} + +/// Rule 3. Disable: last writer wins → (5, 2) reads 8 and no divergence exists. +#[test] +fn a_divergence_in_one_version_stores_nothing_and_is_recorded() { + let g = givens(); + let v = next_version(&g, &main_version(), &divergent_pair()); + assert_eq!(v.read(&g, cell(5, 2)), None); + assert_eq!(v.divergences.len(), 1); + assert_eq!(v.divergences[0].offers, vec![(7, 31), (8, 32)]); + // The non-conflicting write of the same thought lands. + assert_eq!(v.read(&g, cell(6, 2)), Some(62)); +} + +/// The same two writes in consecutive versions are not a divergence: the +/// later version replaces a value above layer 0. Pins that rule 3 is about +/// one version, not about history. +#[test] +fn consecutive_versions_replace_without_divergence() { + let g = givens(); + let [a, b] = divergent_pair(); + let v1 = next_version(&g, &main_version(), std::slice::from_ref(&a)); + let v2 = next_version(&g, &v1, std::slice::from_ref(&b)); + assert_eq!(v2.read(&g, cell(5, 2)), Some(8)); + assert!(v2.divergences.is_empty()); +} + +/// Rule 4. Disable: read a missing cell as 0 → every read below is Some(0). +#[test] +fn an_unwritten_lane_reads_absent_not_zero() { + let g = givens(); + let mut landing: Vec<_> = rubicon_outcomes().into_iter().collect(); + landing.extend(unremarkable_stack()); + landing.push(contradiction_thought()); + let v = next_version(&g, &main_version(), &landing); + let unwritten = unwritten_cells(); + let written = fixtures::all_writes(); + assert!( + unwritten.len() >= 10, + "fixture must leave a real lane unwritten" + ); + for c in unwritten { + assert!(!written.contains_key(&c), "fixture writes {c:?}"); + assert_eq!(v.read(&g, c), None, "{c:?} read as a value"); + } + // Anti-vacuity: the same version does hold values elsewhere. + assert!(v.cells.len() >= 5); +} + +/// The squash rule the plan relies on: squashing an unremarkable stack and +/// landing it equals landing the stack as it is. +#[test] +fn squashing_an_unremarkable_stack_changes_nothing() { + let g = givens(); + let stack = unremarkable_stack(); + let separate = next_version(&g, &main_version(), &stack); + let squashed = next_version(&g, &main_version(), &[squash(99, &stack)]); + assert_eq!(separate.cells, squashed.cells); + assert_eq!(separate.contradictions, squashed.contradictions); + assert!(separate.cells.len() >= 5, "stack must write something"); +} + +/// ...and why an anomaly must stop the squash: squashing a divergent pair +/// hides the divergence and stores one side. Silence twin of the test above. +#[test] +fn squashing_a_divergent_pair_hides_the_divergence() { + let g = givens(); + let pair = divergent_pair(); + let separate = next_version(&g, &main_version(), &pair); + let squashed = next_version(&g, &main_version(), &[squash(99, &pair)]); + assert_eq!(separate.divergences.len(), 1); + assert!(squashed.divergences.is_empty()); + assert_eq!(squashed.read(&g, cell(5, 2)), Some(8)); + assert_ne!(separate.cells, squashed.cells); +} + +/// Fixture sanity: one thought per outcome, all four present. +#[test] +fn every_outcome_has_a_fixture() { + let o: Vec = rubicon_outcomes().iter().map(|t| t.outcome).collect(); + assert_eq!( + o, + vec![ + Outcome::Commit, + Outcome::Plan, + Outcome::Prune, + Outcome::Veto + ] + ); +} diff --git a/crates/thought-git-probe/tests/signals.rs b/crates/thought-git-probe/tests/signals.rs new file mode 100644 index 000000000..d0cc93b9c --- /dev/null +++ b/crates/thought-git-probe/tests/signals.rs @@ -0,0 +1,253 @@ +//! The existing contract signals the plan's squash rule and write path rely +//! on, run on the D-TGB-0 fixtures. Each signal gets a can-fire case on an +//! anomaly fixture and a can-stay-silent case on an unremarkable one, so a +//! signal that fires on everything fails as surely as one that never fires. + +use lance_graph_contract::alpha::{AlphaAllocation, AlphaMask, AlphaOverlay}; +use lance_graph_contract::canonical_node::{EdgeBlock, NodeGuid, NodeRow, TailVariant}; +use lance_graph_contract::kanban::KanbanColumn; +use lance_graph_contract::mul::{FlowState, GateDecision, TrustTexture}; +use lance_graph_contract::revision::{ + BasisView, CodebookId, CounterfactualVerdict, EncounterEvidence, EvidentialEffect, + GadamerRevision, GrammarId, HorizonId, InterpretiveHorizon, LanguageId, LensId, QuestionId, + RevisionKind, RevisionPolicy, RevisionVerdict, +}; +use thought_git_probe::fixtures::{ + Outcome, REGRESSION_SCANPATH, ROWS, SMOOTH_SCANPATH, SURPRISE_ATTENDED, SURPRISE_EXPECTED, + rubicon_outcomes, +}; + +fn spine() -> Vec { + (0..ROWS) + .map(|r| NodeRow { + key: NodeGuid::mint_for(TailVariant::V3, 0x0B0B_0000, 1, 2, 3, 4, 0x11, r + 1), + edges: EdgeBlock::default(), + value: [0u8; 480], + }) + .collect() +} + +fn overlay_regressions(path: &[u32]) -> Vec { + let base = spine(); + let alloc = AlphaAllocation::over(&base); + let mut o = AlphaOverlay::new(alloc, 1); + for &r in path { + o.claim(base[r as usize].key, 0).expect("allocated"); + } + o.regressions().into_iter().map(|(_, n)| n).collect() +} + +/// Regression signal (`visits > 1`) fires where attention returns. +#[test] +fn regression_fires_on_a_return() { + assert_eq!(overlay_regressions(®RESSION_SCANPATH), vec![2]); +} + +/// ...and stays silent on a path without one. +#[test] +fn regression_is_silent_without_a_return() { + assert!(overlay_regressions(&SMOOTH_SCANPATH).is_empty()); +} + +fn mask(base: &[NodeRow], rows: &[u32]) -> AlphaMask { + AlphaAllocation::over(base) + .mask_of(rows.iter().map(|&r| base[r as usize].key)) + .expect("allocated") +} + +/// Cold/hot surprise (`expected.xor(attended)`) fires when they differ... +#[test] +fn surprise_fires_when_expected_and_attended_differ() { + let base = spine(); + let s = mask(&base, &SURPRISE_EXPECTED).xor(&mask(&base, &SURPRISE_ATTENDED)); + assert_eq!(s.materialize_ordinals(), vec![3, 4]); +} + +/// ...and is empty when the attended set is what was expected. The attended +/// mask here comes from a real overlay, not from the expected list. +#[test] +fn surprise_is_silent_when_attended_matches_expected() { + let base = spine(); + let alloc = AlphaAllocation::over(&base); + let mut o = AlphaOverlay::new(alloc, 1); + for &r in &SURPRISE_EXPECTED { + o.claim(base[r as usize].key, 0).expect("allocated"); + } + assert!( + mask(&base, &SURPRISE_EXPECTED) + .xor(&o.attended_mask()) + .is_empty() + ); +} + +/// The named route each outcome takes from `Planning`. +fn route(outcome: Outcome) -> Vec { + use KanbanColumn::*; + let mut path = vec![Planning]; + let mut at = Planning; + let step = |c: KanbanColumn, f: fn(KanbanColumn) -> Option| f(c).expect("legal"); + if outcome == Outcome::Veto { + path.push(step(at, KanbanColumn::veto)); + return path; + } + at = step(at, KanbanColumn::advance); + path.push(at); + at = step(at, KanbanColumn::advance); + path.push(at); + let last = match outcome { + Outcome::Commit => step(at, KanbanColumn::advance), + Outcome::Plan => step(at, KanbanColumn::revise), + Outcome::Prune => step(at, KanbanColumn::veto), + Outcome::Veto => unreachable!(), + }; + path.push(last); + path +} + +/// Every fixture outcome is reached through the named kanban primitives, and +/// every step is a legal transition. +#[test] +fn every_outcome_is_a_legal_kanban_route() { + use KanbanColumn::*; + let ends: Vec = rubicon_outcomes() + .iter() + .map(|t| { + let p = route(t.outcome); + for w in p.windows(2) { + assert!(w[0].can_transition_to(w[1]), "{:?} -> {:?}", w[0], w[1]); + } + *p.last().expect("non-empty") + }) + .collect(); + assert_eq!(ends, vec![Commit, Plan, Prune, Prune]); +} + +/// The MUL gate: `Block` at `Evaluation` vetoes (nothing lands), `Hold` stays +/// put, `Flow` advances to `Commit`. +#[test] +fn the_mul_gate_decides_whether_the_write_crosses() { + use KanbanColumn::*; + let hold = GateDecision::Hold { + texture: TrustTexture::Uncertain, + flow: FlowState::Transition, + }; + let block = GateDecision::Block { + texture: TrustTexture::Overconfident, + flow: FlowState::Anxiety, + }; + assert_eq!( + Evaluation.advance_on_gate(&GateDecision::Flow), + Some(Commit) + ); + assert_eq!(Evaluation.advance_on_gate(&hold), None); + assert_eq!(Evaluation.advance_on_gate(&block), Some(Prune)); +} + +type Horizon = InterpretiveHorizon<(), u64>; + +fn horizon(claims: u64, roots: u64) -> Horizon { + InterpretiveHorizon { + id: HorizonId(1), + awareness: (), + question: QuestionId(1), + language: LanguageId(1), + grammar: GrammarId(1), + codebook: CodebookId(1), + lens: LensId(1), + projected_claims: claims, + independent_roots: roots, + inherited_roots: 0, + unresolved_tension: 0, + revision_index: 0, + } +} + +fn ancestry(h: &Horizon) -> BasisView { + BasisView { + ancestry_independent_roots: h.independent_roots, + ancestry_derived_roots: h.inherited_roots, + ancestor_claims: h.projected_claims, + closes_cycle: false, + } +} + +fn encounter(claims: u64, roots: u64, inherited: u64, contra: u64) -> EncounterEvidence { + EncounterEvidence { + proposed_claims: claims, + independent_roots: roots, + inherited_roots: inherited, + resistance: 0, + contradictions: contra, + affected_parts: 0, + } +} + +/// Replaying a thought's own material (no root outside its ancestry) earns +/// nothing and routes back to `Plan`, never to `Commit`. +#[test] +fn replayed_material_cannot_raise_evidence() { + let prior = horizon(0b0011, 0b0001); + let replay = encounter(0b0011, 0b0001, 0b0010, 0); + let d = GadamerRevision.revise(&prior, &replay, &ancestry(&prior)); + assert_eq!(d.kind, RevisionKind::Echo); + assert_eq!(d.evidential_effect, EvidentialEffect::NoIncrease); + let verdict = RevisionVerdict { + effect: d.evidential_effect, + counterfactual: CounterfactualVerdict::Necessary, + }; + assert_eq!( + KanbanColumn::Evaluation.advance_on_revision(verdict), + Some(KanbanColumn::Plan) + ); +} + +/// Can-fire twin: a genuinely new root that survived the counterfactual +/// reaches `Commit`. +#[test] +fn a_new_root_that_survived_attack_reaches_commit() { + let prior = horizon(0b0011, 0b0001); + let fresh = encounter(0b0111, 0b0101, 0, 0); + let d = GadamerRevision.revise(&prior, &fresh, &ancestry(&prior)); + assert_eq!(d.evidential_effect, EvidentialEffect::IncreaseEligible); + let verdict = RevisionVerdict { + effect: d.evidential_effect, + counterfactual: CounterfactualVerdict::Necessary, + }; + assert_eq!( + KanbanColumn::Evaluation.advance_on_revision(verdict), + Some(KanbanColumn::Commit) + ); +} + +/// Landing the same thought twice: the second revision, against the horizon +/// the first one produced, has no new root. +#[test] +fn the_second_landing_of_the_same_thought_earns_nothing() { + let prior = horizon(0b0011, 0b0001); + let e = encounter(0b0111, 0b0101, 0, 0); + let first = GadamerRevision.revise(&prior, &e, &ancestry(&prior)); + let second = GadamerRevision.revise(&first.resulting, &e, &ancestry(&first.resulting)); + assert_eq!(first.evidential_effect, EvidentialEffect::IncreaseEligible); + assert_ne!(second.evidential_effect, EvidentialEffect::IncreaseEligible); +} + +/// A contradiction without a new root is preserved and suspends: the cycle +/// holds instead of landing or pruning. +#[test] +fn a_preserved_contradiction_holds_the_cycle() { + let prior = horizon(0b0011, 0b0001); + let e = encounter(0b0111, 0b0001, 0, 0b1000); + let d = GadamerRevision.revise(&prior, &e, &ancestry(&prior)); + assert_eq!(d.kind, RevisionKind::ContradictionPreserved); + assert_eq!(d.evidential_effect, EvidentialEffect::Suspend); + assert_eq!( + KanbanColumn::Evaluation + .advance_on_revision(RevisionVerdict::unadjudicated(d.evidential_effect)), + None + ); + assert_eq!( + d.resulting.unresolved_tension & 0b1000, + 0b1000, + "tension kept" + ); +} diff --git a/crates/thought-git-probe/tests/slots.rs b/crates/thought-git-probe/tests/slots.rs new file mode 100644 index 000000000..02d724155 --- /dev/null +++ b/crates/thought-git-probe/tests/slots.rs @@ -0,0 +1,113 @@ +//! D-TGB-1: the slot pool under its two starting policies, with squashing +//! along dependency chains before anything is evicted. + +use thought_git_probe::slots::{Eviction, LIVE_SLOTS, Pressure, Slot, SlotPool, Stale}; + +/// Fill the pool with root thoughts (branched from main); `parents[t]` names +/// the earlier thought `t` branches from instead. +fn full(policy: Eviction, parents: &[(u32, u32)]) -> (SlotPool, Vec) { + let mut p = SlotPool::new(policy); + let mut h: Vec = Vec::new(); + for t in 0..LIVE_SLOTS as u32 { + let from = parents + .iter() + .find(|&&(c, _)| c == t) + .map(|&(_, parent)| h[parent as usize]); + let (s, e) = p.branch(t, from).expect("live parent"); + assert!(e.is_none(), "pressure before the pool was full"); + h.push(s); + } + (p, h) +} + +/// Without chains, FIFO evicts the oldest. +#[test] +fn fifo_evicts_the_oldest_root() { + let (mut p, h) = full(Eviction::Fifo, &[]); + for s in &h[..10] { + p.touch(*s).unwrap(); + } + let (_, e) = p.branch(100, None).unwrap(); + assert!(matches!(e, Some(Pressure::Evicted { thought: 0, .. }))); + assert_eq!(p.live(), LIVE_SLOTS); +} + +/// Without chains, LeastUsed evicts the idlest; on the same workload FIFO +/// picks a different victim. +#[test] +fn least_used_evicts_the_idlest_root() { + let (mut p, h) = full(Eviction::LeastUsed, &[]); + for s in &h[..10] { + p.touch(*s).unwrap(); + } + let (_, e) = p.branch(100, None).unwrap(); + assert!(matches!( + e, + Some(Pressure::Evicted { + thought: 10, + uses: 0, + .. + }) + )); +} + +/// The least-used thought has a live parent: it is squashed into it, not +/// evicted, and its handle goes stale. +#[test] +fn a_least_used_thought_in_a_chain_is_squashed_into_its_parent() { + let (mut p, h) = full(Eviction::LeastUsed, &[(20, 5)]); + for (t, s) in h.iter().enumerate() { + if t != 20 { + p.touch(*s).unwrap(); + } + } + let (_, e) = p.branch(100, None).unwrap(); + assert_eq!( + e, + Some(Pressure::Squashed { + thought: 20, + from: h[20], + into: h[5], + }) + ); + assert_eq!(p.thought(h[20]), Err(Stale(h[20]))); + assert_eq!(p.thought(h[5]), Ok(5)); +} + +/// The FIFO candidate has a live child but no parent: the child is squashed +/// into it, so the oldest thought survives and nothing is lost. +#[test] +fn a_candidate_with_a_child_absorbs_it() { + let (mut p, h) = full(Eviction::Fifo, &[(1, 0)]); + let (_, e) = p.branch(100, None).unwrap(); + assert_eq!( + e, + Some(Pressure::Squashed { + thought: 1, + from: h[1], + into: h[0], + }) + ); + assert_eq!(p.thought(h[0]), Ok(0)); +} + +/// Branching from the thought the policy would pick must not remove it. +#[test] +fn branching_from_the_candidate_keeps_it() { + let (mut p, h) = full(Eviction::Fifo, &[]); + let (s, e) = p.branch(100, Some(h[0])).unwrap(); + assert!(matches!(e, Some(Pressure::Evicted { thought: 1, .. }))); + assert_eq!(p.thought(h[0]), Ok(0)); + assert_eq!(p.thought(s), Ok(100)); +} + +/// Freeing makes room without pressure, and the freed handle is stale. +#[test] +fn a_freed_slot_is_reused_without_pressure() { + let (mut p, h) = full(Eviction::LeastUsed, &[]); + p.free(h[30]).unwrap(); + let (s, e) = p.branch(100, None).unwrap(); + assert!(e.is_none()); + assert_eq!(s.index, h[30].index); + assert_eq!(p.thought(h[30]), Err(Stale(h[30]))); +}