diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 067bebd21..39b1ce932 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -385,7 +385,8 @@ Plan: `.claude/plans/ce64-hypothesis-layers-v1.md`. Plateau: `.claude/knowledge/ | **D-TGA-2** | Read plane R and status | Queued | a read outside R is never reported stale | | **D-TGA-3** | rebase (four masks, no data moved) | Queued | rebase-then-squash == from-scratch re-run on v+1, including a deletion in main (retract then re-derive); disables: no stale re-run, no conflict check, no retraction | | **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 | In PR (lab precursor: `crates/thought-git-probe/tests/squash_gate.rs`, `entries/2026-10-23-d-tga-5-squash-gate-preregistration.md`; most-updated target, contradiction/divergence gate, blame over lanes, 5 disables red) | blame returns original sources after slot reuse (slot reuse not yet covered) | +| **D-TGA-5** | squash_into (partial squash) and blame | Shipped (lab, #1736: `crates/thought-git-probe/src/squash.rs` + `tests/squash_gate.rs`, `entries/2026-10-23-d-tga-5-squash-gate-preregistration.md`; most-updated target, contradiction/divergence gate, blame over lanes, P8 Commit-only, 6 disables red) | blame returns original sources after slot reuse (slot reuse not yet covered) | +| **D-TGA-5D** | Probe D: protected readers of a squash (Final / Intermediate / Contradictions / Divergences / Multiplicity) | In PR (lab: `tests/protected_readers.rs`, `entries/2026-10-23-d-tga-5d-protected-readers-preregistration.md`; 7 tests, 5 disables red) | a protected reader the retained data cannot answer refuses the squash; intermediate reads are never retainable | | **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`) | Shipped (lab, #1630; 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) | Shipped (lab, #1630/#1631; `crates/thought-git-probe/src/slots.rs`). Slot pool only: no production land, rebase or persistence (D-TGB-2..5 Queued) | 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 | diff --git a/.claude/board/entries/2026-10-23-d-tga-5d-protected-readers-preregistration.md b/.claude/board/entries/2026-10-23-d-tga-5d-protected-readers-preregistration.md new file mode 100644 index 000000000..a7da97659 --- /dev/null +++ b/.claude/board/entries/2026-10-23-d-tga-5d-protected-readers-preregistration.md @@ -0,0 +1,139 @@ +# D-TGA-5D / Probe D — protected readers of a squash (pre-registration) + +> Renamed 2026-10-23 from "D-TGA-6", which is MergeQueue on the STATUS_BOARD. The pre-registration commit 8aedc267 carries the old name; the content is unchanged. + +**Status:** PRE-REGISTERED 2026-10-23, before any code. Owner: CC1 +(session_01VncKoyG6JkpsoqhqTGTv3f), claimed on EXO-50 `/036`; ownership sync +`GPT:gpt-ada-01/20261023-cc1-cc2-ownership-sync-13`. +**Crate:** `crates/thought-git-probe` (lab only, its own workspace). +**Builds on:** D-TGA-5 (`tests/squash_gate.rs`, merged in #1736: strict R1/R2, P8 +Commit-only). + +## Question + +A squash replaces a family of lane chains with one delta. Its readers are +everything that may later ask what happened. Which readers does the D-TGA-5 +`Squashed` value (cells, blame, order, seal) answer exactly the way the full +history does? For each reader it cannot answer, what must be retained as a +witness, or must the squash be refused? + +Refusal is a valid result. The question is never "can we compact": it is +"which readers survive compaction, at what retained cost". + +## Model + +- **History.** Lanes land interleaved. Step `i` lands every lane's `i`-th + thought together in one version, through `oracle::next_version`, starting from + a prior version `P`. `P` may be non-empty. The history is `v_1 .. v_T`. +- **Squash.** `squash_gate` as merged. If accepted, it is landed once on `P`. + +## Readers + +| reader | history answer | retained answer | +|---|---|---| +| `Final` | `v_T.read(c)` for every cell | the squash landed on `P`, read at `c` | +| `Intermediate` | `v_k.read(c)` for `1 ≤ k < T` | none: the squash keeps no per-step state | +| `Contradictions` | union of every `v_k.contradictions` | a witness log | +| `Divergences` | union of every `v_k.divergences` | a witness log | +| `Multiplicity` | committed writes per cell, from the thought log | a witness count. Without one, the best the squash has is blame's popcount, which counts lanes and not writes | + +Blame and order are already pinned (P6 and the seal test). They are not +re-tested here. + +## Fixtures + +The (a)–(d) fixtures come from the falsifier co-architect of the 2026-10-23 +coresearch. + +- (a) Prune, Veto and Plan writes between commits. +- (b) A committed write that contradicts a given, then a write agreeing with it. +- (c) Two lanes diverge at step 1 and agree at step 2. +- (d) The same value written 3× by one lane, against 1× by another lane. +- (e) The clean lanes of D-TGA-5, landed on a NON-EMPTY prior version. + +## Predictions (each is a test) + +- **D1.** `Final` is preserved on (a)–(e), and the witness is empty. + Kill: any differing final read. +- **D2 (loss is real).** Without a witness the squash loses `Contradictions` + on (b), `Divergences` on (c) and `Multiplicity` on (d). Anti-vacuity: each + history answer is non-empty or greater than 1 where claimed. +- **D3.** When `Contradictions`, `Divergences` and `Multiplicity` are protected, + the gate retains a witness, and the retained answers equal the history answers + on (a)–(e). Disables: drop each witness field in turn. Each disable must turn + its fixture red. +- **D4.** When `Intermediate` is protected and some intermediate read differs + from the final read, the gate refuses with `Unretainable(Intermediate)`. + Silent twins: a one-step history, and a multi-step history whose intermediate + reads all equal the final, are accepted. Disable: remove the intermediate check. +- **D5 (bytes).** Transient bytes grow with total writes. Retained bytes (delta, + blame, order, seal, witness) are measured separately, with the witness bytes + given on their own. This is reported, not gated: the witness grows with the + number of anomalies and the order list grows with the number of thoughts. A + proof object is not assumed to be constant size. + +## Kill conditions + +- A protected reader changes silently. +- R1 or R2 relaxes. +- P8 breaks. +- The witness claims a reader it cannot answer. + +## Not in scope + +- `ProofRef` and the shared reference type (Option B, still being agreed with CC2). +- F5 (a synthesized head). +- F6 (correlated derivations). +- Any change to `oracle.rs`. + +## Results (2026-10-23, measured) + +`crates/thought-git-probe/tests/protected_readers.rs`: 7/7 pass. The rest of +the crate is unchanged and green, `squash_gate` 10/10 included. fmt and +`clippy -D warnings` are clean. The gate moved to `src/squash.rs` with no +semantic change (commit a6c96473); the probe is commit 666081d5. + +- **D1 holds.** Final reads match the history on (a)–(e), including the + non-empty prior in (e), and the witness is empty. +- **D2 holds.** The loss is real. A bare squash drops (b)'s one + contradiction and (c)'s one divergence. For (d) it reports multiplicity 2 + (lanes) where the history has 4 (writes). +- **D3 holds.** With the logs protected, the witness keeps every log, and each + retained answer equals the history's on (a)–(e). +- **D4 holds.** `Unretainable(Intermediate)` is returned exactly when an + intermediate read differs from the final read. This is computed + independently of the gate. 3 of 5 fixtures refuse: (a), (c) and (e). (b) and + (d) have no differing intermediate read and are accepted. Silent twins (a + one-step history; a steady 3-step history) are accepted. +- **D5 (reported).** One lane with 8 cells and k thoughts: + `k, transient B, squash B, witness B` = + `(1, 104, 221, 72), (4, 416, 236, 72), (16, 1664, 296, 72), (64, 6656, 536, 72)`. + At k = 1 the squash is LARGER than the history, because blame and order are + overhead. It falls below at k ≥ 4. The squash grows by 5 B per thought (the + order list). The witness here is the multiplicity table, a constant 72 B. + +Disables, each run after the commit with its anchor asserted: + +| disable | red | +|---|---| +| land the squash on an empty version instead of `prior` | d1, d3 | +| do not fill `contradictions` | d3 | +| do not fill `divergences` | d3 | +| do not fill `multiplicity` | d3, d5 | +| remove the intermediate check | d4 | + +**What this establishes:** a squash may drop the folds only for the readers +it can answer. The final reads come for free. The three logs need a witness, +and its cost grows with the anomalies, not with the history. Intermediate +reads cannot be retained at all: a protected intermediate reader refuses the +squash, which is the outcome predicted for that case, not a failure. + +**Honest limit:** for the three logs, the witness is built from the same +history it is checked against. That check only catches wiring bugs. What the +probe measures is which readers a bare squash loses, the cost of keeping +them, and the refusal. The check is not independent. + +**Next (CC1):** +- F5 (a synthesized head over complementary children); +- F6 (correlated derivations without proof authority); +- the passive `ProofRef` once the type is agreed with CC2. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index 8b74c1381..9cdb7763f 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,13 +25,14 @@ 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. -448 entries, 2026-08-06 .. 2026-10-23. +449 entries, 2026-08-06 .. 2026-10-23. | date | entry id | finding | file | |---|---|---|---| | 2026-10-23 | `ps2-compose-chain-preregistration` | | [2026-10-23-ps2-compose-chain-preregistration.md](2026-10-23-ps2-compose-chain-preregistration.md) | | 2026-10-23 | `ledger-revision-1635-1726` | | [2026-10-23-ledger-revision-1635-1726.md](2026-10-23-ledger-revision-1635-1726.md) | | 2026-10-23 | `exo129-learned-lut-preregistration` | | [2026-10-23-exo129-learned-lut-preregistration.md](2026-10-23-exo129-learned-lut-preregistration.md) | +| 2026-10-23 | `D-TGA-5D` | | [2026-10-23-d-tga-5d-protected-readers-preregistration.md](2026-10-23-d-tga-5d-protected-readers-preregistration.md) | | 2026-10-23 | `D-TGA-5` | | [2026-10-23-d-tga-5-squash-gate-preregistration.md](2026-10-23-d-tga-5-squash-gate-preregistration.md) | | 2026-10-23 | `coresearch-thought-git-markov` | | [2026-10-23-coresearch-thought-git-markov.md](2026-10-23-coresearch-thought-git-markov.md) | | 2026-10-23 | `coresearch-thought-git-markov-cc2` | | [2026-10-23-coresearch-thought-git-markov-cc2.md](2026-10-23-coresearch-thought-git-markov-cc2.md) | diff --git a/crates/thought-git-probe/src/lib.rs b/crates/thought-git-probe/src/lib.rs index e5133e5c1..b377dad1d 100644 --- a/crates/thought-git-probe/src/lib.rs +++ b/crates/thought-git-probe/src/lib.rs @@ -27,3 +27,4 @@ pub mod fixtures; pub mod oracle; pub mod receipt; pub mod slots; +pub mod squash; diff --git a/crates/thought-git-probe/src/squash.rs b/crates/thought-git-probe/src/squash.rs new file mode 100644 index 000000000..1a589f02d --- /dev/null +++ b/crates/thought-git-probe/src/squash.rs @@ -0,0 +1,382 @@ +//! D-TGA-5: the squash gate, moved here from `tests/squash_gate.rs` so that +//! D-TGA-5D (`tests/protected_readers.rs`) can use it. The semantics are those +//! pre-registered in +//! `.claude/board/entries/2026-10-23-d-tga-5-squash-gate-preregistration.md`: +//! strict R1/R2 over landed final states, Commit-only (P8), blame as a lane +//! bitset, the ordered history and its seal. Lab only. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::fixtures::{Cell, Outcome, Thought}; +use crate::oracle::Givens; + +/// A thought lane: its id (1..=63) and its chain of origin, oldest first. +pub struct Lane { + pub id: u8, + pub chain: Vec, +} + +impl Lane { + #[must_use] + pub fn new(id: u8, chain: Vec) -> Self { + assert!((1..=63).contains(&id), "lane {id} outside 1..=63"); + Self { id, chain } + } + + /// The thoughts whose writes land: only `Outcome::Commit`, as in + /// `next_version`. Plan, Prune and Veto stay in the history only. + pub fn landed(&self) -> impl Iterator { + self.chain.iter().filter(|t| t.outcome == Outcome::Commit) + } + + /// Total landed writes in the chain. + #[must_use] + pub fn ops(&self) -> usize { + self.landed().map(|t| t.writes.len()).sum() + } + + /// The landed chain folded in order: later writes replace earlier ones. + #[must_use] + pub fn final_state(&self) -> BTreeMap { + let mut f = BTreeMap::new(); + for t in self.landed() { + for &(c, v) in &t.writes { + f.insert(c, v); + } + } + f + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Refusal { + Contradiction { + cell: Cell, + given: u64, + offered: u64, + lane: u8, + }, + Divergence { + cell: Cell, + offers: Vec<(u64, u8)>, + }, +} + +#[derive(Debug)] +pub struct Squashed { + pub target: u8, + pub cells: BTreeMap, + pub order: Vec<(u8, u32)>, + pub blame: BTreeMap, + pub seal: u64, +} + +impl Squashed { + /// The squash as one committed thought, for the oracle. + #[must_use] + pub fn as_thought(&self, id: u32) -> Thought { + Thought::new( + id, + self.cells.iter().map(|(&c, &v)| (c, v)).collect(), + Outcome::Commit, + ) + } +} + +fn fnv1a(seal: &mut u64, x: u64) { + for b in x.to_le_bytes() { + *seal ^= u64::from(b); + *seal = seal.wrapping_mul(0x0000_0100_0000_01b3); + } +} + +/// The gate. Lanes are taken in ascending id order whatever order they are +/// passed in. +/// # Errors +/// +/// [`Refusal::Contradiction`] when a lane's final state overwrites a given; +/// [`Refusal::Divergence`] when two lanes' final states disagree on a cell. +pub fn squash_gate(g: &Givens, lanes: &[Lane]) -> Result { + let mut lanes: Vec<&Lane> = lanes.iter().collect(); + lanes.sort_by_key(|l| l.id); + + // Target: most ops; on a tie the lowest id (max_by_key keeps the last + // maximum, so compare on (ops, Reverse(id))). + let target = lanes + .iter() + .max_by_key(|l| (l.ops(), std::cmp::Reverse(l.id))) + .map_or(0, |l| l.id); + + // R1, then R2, over final states only. + let mut offers: BTreeMap> = BTreeMap::new(); + for l in &lanes { + for (c, v) in l.final_state() { + if let Some(&given) = g.0.get(&c) { + if given != v { + return Err(Refusal::Contradiction { + cell: c, + given, + offered: v, + lane: l.id, + }); + } + continue; // agreement with a given is not stored + } + offers.entry(c).or_default().insert((v, l.id)); + } + } + let mut cells = BTreeMap::new(); + for (c, set) in offers { + let values: BTreeSet = set.iter().map(|&(v, _)| v).collect(); + if values.len() > 1 { + return Err(Refusal::Divergence { + cell: c, + offers: set.into_iter().collect(), + }); + } + cells.insert(c, *values.iter().next().expect("one value")); + } + + let mut order = Vec::new(); + let mut blame: BTreeMap = BTreeMap::new(); + let mut seal = 0xcbf2_9ce4_8422_2325_u64; + for l in &lanes { + for t in &l.chain { + order.push((l.id, t.id)); + fnv1a(&mut seal, u64::from(l.id)); + fnv1a(&mut seal, u64::from(t.id)); + for &(c, v) in &t.writes { + if t.outcome == Outcome::Commit { + *blame.entry(c).or_default() |= 1u64 << l.id; + } + fnv1a(&mut seal, u64::from(c.row)); + fnv1a(&mut seal, u64::from(c.lane)); + fnv1a(&mut seal, v); + } + } + } + + Ok(Squashed { + target, + cells, + order, + blame, + seal, + }) +} + +// ── D-TGA-5D: protected readers ───────────────────────────────────────────── +// +// Pre-registered in +// `.claude/board/entries/2026-10-23-d-tga-5d-protected-readers-preregistration.md`. +// A squash replaces a history with one delta. A reader is anything that may +// later ask what happened. The gate below answers every protected reader from +// the RETAINED data only (the squash, its witness, the prior version and the +// givens), compares that with the answer the full history gives, and refuses +// when a protected reader cannot be answered. Refusal is a valid result. + +use crate::oracle::{Contradiction, Divergence, Version, next_version}; + +/// What may later be asked of a squashed family. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Reader { + /// The read at every cell after the last step. + Final, + /// The read at every cell after each step `1 <= k < T`. + Intermediate, + /// Every contradiction any step recorded. + Contradictions, + /// Every divergence any step recorded. + Divergences, + /// Committed writes per cell, counted from the thought log. + Multiplicity, +} + +/// What a protected squash retains beyond the [`Squashed`] value. Only the +/// fields a protected reader needs are filled. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Witness { + pub contradictions: Vec, + pub divergences: Vec, + pub multiplicity: BTreeMap, +} + +/// Why a protected squash was refused. +#[derive(Debug, PartialEq, Eq)] +pub enum ProtectedRefusal { + /// R1 or R2, unchanged from [`squash_gate`]. + Gate(Refusal), + /// The retained data cannot answer this protected reader the way the + /// history does. + Unretainable(Reader), +} + +/// The history: step `i` lands every lane's `i`-th thought together in one +/// version, starting from `prior`. Returns `v_1 .. v_T`. +#[must_use] +pub fn land_interleaved(g: &Givens, prior: &Version, lanes: &[Lane]) -> Vec { + let steps = lanes.iter().map(|l| l.chain.len()).max().unwrap_or(0); + let mut out = Vec::with_capacity(steps); + let mut v = prior.clone(); + for i in 0..steps { + let landing: Vec = lanes + .iter() + .filter_map(|l| l.chain.get(i).cloned()) + .collect(); + v = next_version(g, &v, &landing); + out.push(v.clone()); + } + out +} + +/// Every cell a reader compares: the givens, the prior's cells and every cell +/// any thought offered. +fn touched(g: &Givens, prior: &Version, lanes: &[Lane]) -> BTreeSet { + let mut s: BTreeSet = g.0.keys().copied().collect(); + s.extend(prior.cells.keys().copied()); + for l in lanes { + for t in &l.chain { + s.extend(t.writes.iter().map(|&(c, _)| c)); + } + } + s +} + +/// The reads of one version over `cells`. +fn reads(g: &Givens, v: &Version, cells: &BTreeSet) -> Vec> { + cells.iter().map(|&c| v.read(g, c)).collect() +} + +/// History answer: every contradiction, in step order. +#[must_use] +pub fn history_contradictions(history: &[Version]) -> Vec { + history + .iter() + .flat_map(|v| v.contradictions.iter().copied()) + .collect() +} + +/// History answer: every divergence, in step order. +#[must_use] +pub fn history_divergences(history: &[Version]) -> Vec { + history + .iter() + .flat_map(|v| v.divergences.iter().cloned()) + .collect() +} + +/// History answer: committed writes per cell, from the thought log. +#[must_use] +pub fn history_multiplicity(lanes: &[Lane]) -> BTreeMap { + let mut m = BTreeMap::new(); + for l in lanes { + for t in l.landed() { + for &(c, _) in &t.writes { + *m.entry(c).or_default() += 1; + } + } + } + m +} + +/// The best multiplicity a bare [`Squashed`] can give: blame's popcount, +/// which counts lanes, not writes. +#[must_use] +pub fn squashed_multiplicity(s: &Squashed) -> BTreeMap { + s.blame.iter().map(|(&c, &b)| (c, b.count_ones())).collect() +} + +/// Squash `lanes` on top of `prior`, keeping every reader in `protect`. +/// +/// # Errors +/// +/// [`ProtectedRefusal::Gate`] when R1 or R2 refuses; +/// [`ProtectedRefusal::Unretainable`] when a protected reader's retained +/// answer would differ from the history's. +pub fn protected_squash( + g: &Givens, + prior: &Version, + lanes: &[Lane], + protect: &[Reader], +) -> Result<(Squashed, Witness), ProtectedRefusal> { + let s = squash_gate(g, lanes).map_err(ProtectedRefusal::Gate)?; + let history = land_interleaved(g, prior, lanes); + let wants = |r: Reader| protect.contains(&r); + + let mut w = Witness::default(); + if wants(Reader::Contradictions) { + w.contradictions = history_contradictions(&history); + } + if wants(Reader::Divergences) { + w.divergences = history_divergences(&history); + } + if wants(Reader::Multiplicity) { + w.multiplicity = history_multiplicity(lanes); + } + + // Verify every protected reader from the retained data alone. + let cells = touched(g, prior, lanes); + let landed = next_version(g, prior, &[s.as_thought(u32::MAX)]); + let last = history.last().unwrap_or(prior); + if wants(Reader::Final) && reads(g, &landed, &cells) != reads(g, last, &cells) { + return Err(ProtectedRefusal::Unretainable(Reader::Final)); + } + if wants(Reader::Intermediate) { + // The retained data keeps no per-step state: the only intermediate + // answer it can give is the final one. + let fin = reads(g, &landed, &cells); + if history[..history.len().saturating_sub(1)] + .iter() + .any(|v| reads(g, v, &cells) != fin) + { + return Err(ProtectedRefusal::Unretainable(Reader::Intermediate)); + } + } + if wants(Reader::Contradictions) && w.contradictions != history_contradictions(&history) { + return Err(ProtectedRefusal::Unretainable(Reader::Contradictions)); + } + if wants(Reader::Divergences) && w.divergences != history_divergences(&history) { + return Err(ProtectedRefusal::Unretainable(Reader::Divergences)); + } + if wants(Reader::Multiplicity) && w.multiplicity != history_multiplicity(lanes) { + return Err(ProtectedRefusal::Unretainable(Reader::Multiplicity)); + } + Ok((s, w)) +} + +/// Bytes, by a fixed encoding: a cell is 5 bytes (row u32 + lane u8), a value +/// 8, a lane id 1, a thought id 4, a count 4. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bytes { + /// Every write of every thought, landed or not: `(cell, value)`. + pub transient: usize, + /// delta + blame + order + seal. + pub squash: usize, + /// The witness alone. + pub witness: usize, +} + +const CELL: usize = 5; + +/// The byte accounting of one protected squash. +#[must_use] +pub fn bytes(lanes: &[Lane], s: &Squashed, w: &Witness) -> Bytes { + let transient = lanes + .iter() + .flat_map(|l| l.chain.iter()) + .map(|t| t.writes.len() * (CELL + 8)) + .sum(); + let squash = + s.cells.len() * (CELL + 8) + s.blame.len() * (CELL + 8) + s.order.len() * (1 + 4) + 8; + let witness = w.contradictions.len() * (CELL + 8 + 8 + 4) + + w.divergences + .iter() + .map(|d| CELL + d.offers.len() * (8 + 4)) + .sum::() + + w.multiplicity.len() * (CELL + 4); + Bytes { + transient, + squash, + witness, + } +} diff --git a/crates/thought-git-probe/tests/protected_readers.rs b/crates/thought-git-probe/tests/protected_readers.rs new file mode 100644 index 000000000..432f5bb28 --- /dev/null +++ b/crates/thought-git-probe/tests/protected_readers.rs @@ -0,0 +1,318 @@ +//! D-TGA-5D / Probe D: protected readers of a squash. +//! +//! Pre-registered in +//! `.claude/board/entries/2026-10-23-d-tga-5d-protected-readers-preregistration.md` +//! before this file was written. Predictions D1..D5 are the tests below; each +//! disable is named on the test it must turn red. + +use thought_git_probe::fixtures::{Cell, Outcome, Thought, cell, givens, main_version}; +use thought_git_probe::oracle::{Givens, Version, next_version}; +use thought_git_probe::squash::{ + Lane, ProtectedRefusal, Reader, bytes, history_contradictions, history_divergences, + history_multiplicity, land_interleaved, protected_squash, squashed_multiplicity, +}; + +fn commit(id: u32, writes: Vec<(Cell, u64)>) -> Thought { + Thought::new(id, writes, Outcome::Commit) +} + +/// (a) Prune, Veto and Plan writes between commits. +fn fixture_a() -> Vec { + vec![Lane::new( + 3, + vec![ + commit(1, vec![(cell(4, 1), 41)]), + Thought::new(2, vec![(cell(5, 1), 51)], Outcome::Prune), + Thought::new(3, vec![(cell(4, 1), 99)], Outcome::Veto), + Thought::new(4, vec![(cell(6, 1), 61)], Outcome::Plan), + commit(5, vec![(cell(7, 2), 72)]), + ], + )] +} + +/// (b) A committed write contradicting the given (1, 0) = 101, then a write +/// agreeing with it. +fn fixture_b() -> Vec { + vec![Lane::new( + 5, + vec![ + commit(1, vec![(cell(1, 0), 999), (cell(4, 2), 42)]), + commit(2, vec![(cell(1, 0), 101)]), + ], + )] +} + +/// (c) Two lanes diverge on (5, 2) at step 1 and agree at step 2. +fn fixture_c() -> Vec { + vec![ + Lane::new( + 4, + vec![ + commit(1, vec![(cell(5, 2), 7)]), + commit(3, vec![(cell(5, 2), 9)]), + ], + ), + Lane::new( + 9, + vec![ + commit(2, vec![(cell(5, 2), 8)]), + commit(4, vec![(cell(5, 2), 9)]), + ], + ), + ] +} + +/// (d) Lane 2 writes (6, 3) = 63 three times; lane 8 writes it once. +fn fixture_d() -> Vec { + vec![ + Lane::new( + 2, + vec![ + commit(1, vec![(cell(6, 3), 63)]), + commit(2, vec![(cell(6, 3), 63)]), + commit(3, vec![(cell(6, 3), 63)]), + ], + ), + Lane::new(8, vec![commit(4, vec![(cell(6, 3), 63)])]), + ] +} + +/// (e) Clean lanes over a non-empty prior version. +fn fixture_e() -> (Version, Vec) { + let prior = next_version( + &givens(), + &main_version(), + &[commit(90, vec![(cell(11, 2), 112), (cell(4, 1), 1)])], + ); + let lanes = vec![ + Lane::new( + 3, + vec![ + commit(1, vec![(cell(4, 1), 41)]), + commit(2, vec![(cell(6, 2), 62)]), + ], + ), + Lane::new( + 7, + vec![ + commit(3, vec![(cell(5, 1), 50)]), + commit(4, vec![(cell(5, 1), 52), (cell(6, 2), 62)]), + ], + ), + ]; + (prior, lanes) +} + +fn all_fixtures() -> Vec<(&'static str, Version, Vec)> { + let (pe, le) = fixture_e(); + vec![ + ("a", main_version(), fixture_a()), + ("b", main_version(), fixture_b()), + ("c", main_version(), fixture_c()), + ("d", main_version(), fixture_d()), + ("e", pe, le), + ] +} + +const LOGS: [Reader; 3] = [ + Reader::Contradictions, + Reader::Divergences, + Reader::Multiplicity, +]; + +fn g() -> Givens { + givens() +} + +/// D1. Final reads survive on every fixture, with an empty witness. +/// Disable: land the squash on `main_version()` instead of `prior` in +/// `protected_squash` -> (e) refuses with `Unretainable(Final)`. +#[test] +fn d1_final_reads_survive_with_an_empty_witness() { + for (name, prior, lanes) in all_fixtures() { + let (s, w) = protected_squash(&g(), &prior, &lanes, &[Reader::Final]) + .unwrap_or_else(|e| panic!("fixture {name}: {e:?}")); + assert_eq!(w, Default::default(), "fixture {name}: witness not empty"); + // Independent of the gate's own check: land and compare here. + let history = land_interleaved(&g(), &prior, &lanes); + let last = history.last().expect("non-empty history"); + let landed = next_version(&g(), &prior, &[s.as_thought(7777)]); + for r in 0..12 { + for l in 0..4 { + let c = cell(r, l); + assert_eq!(landed.read(&g(), c), last.read(&g(), c), "{name} {c:?}"); + } + } + } + // Anti-vacuity: the prior's untouched cell survives in (e). + let (prior, lanes) = fixture_e(); + let (s, _) = protected_squash(&g(), &prior, &lanes, &[Reader::Final]).expect("e"); + assert_eq!( + next_version(&g(), &prior, &[s.as_thought(1)]).read(&g(), cell(11, 2)), + Some(112) + ); +} + +/// D2. Without a witness the squash loses the logs. The loss is real: the +/// history has a contradiction in (b), a divergence in (c) and a 4-write +/// cell in (d), and the bare squash has none of them. +#[test] +fn d2_a_bare_squash_loses_contradictions_divergences_and_multiplicity() { + let (s, w) = protected_squash(&g(), &main_version(), &fixture_b(), &[Reader::Final]) + .expect("b squashes"); + let h = history_contradictions(&land_interleaved(&g(), &main_version(), &fixture_b())); + assert_eq!(h.len(), 1, "fixture b must record a contradiction"); + assert!(w.contradictions.is_empty()); + assert_ne!(w.contradictions, h, "the loss of b's contradiction"); + let _ = s; + + let (_, w) = protected_squash(&g(), &main_version(), &fixture_c(), &[Reader::Final]) + .expect("c squashes"); + let h = history_divergences(&land_interleaved(&g(), &main_version(), &fixture_c())); + assert_eq!(h.len(), 1, "fixture c must record a divergence"); + assert_ne!(w.divergences, h, "the loss of c's divergence"); + + let (s, _) = protected_squash(&g(), &main_version(), &fixture_d(), &[Reader::Final]) + .expect("d squashes"); + let h = history_multiplicity(&fixture_d()); + assert_eq!(h[&cell(6, 3)], 4, "fixture d writes (6, 3) four times"); + assert_eq!( + squashed_multiplicity(&s)[&cell(6, 3)], + 2, + "blame counts two lanes" + ); +} + +/// D3. With the logs protected the witness keeps them and every retained +/// answer equals the history's, on every fixture. Disables, each turning one +/// fixture red (the gate refuses with `Unretainable`): skip filling +/// `contradictions` (b), `divergences` (c), `multiplicity` (d). +#[test] +fn d3_a_protected_squash_keeps_every_log_it_promises() { + for (name, prior, lanes) in all_fixtures() { + let mut protect = vec![Reader::Final]; + protect.extend(LOGS); + let (_, w) = protected_squash(&g(), &prior, &lanes, &protect) + .unwrap_or_else(|e| panic!("fixture {name}: {e:?}")); + let history = land_interleaved(&g(), &prior, &lanes); + assert_eq!(w.contradictions, history_contradictions(&history), "{name}"); + assert_eq!(w.divergences, history_divergences(&history), "{name}"); + assert_eq!(w.multiplicity, history_multiplicity(&lanes), "{name}"); + } + // Anti-vacuity: the witness is non-empty exactly where the history is. + let protect = [Reader::Contradictions, Reader::Divergences]; + let (_, wb) = protected_squash(&g(), &main_version(), &fixture_b(), &protect).expect("b"); + let (_, wc) = protected_squash(&g(), &main_version(), &fixture_c(), &protect).expect("c"); + assert_eq!((wb.contradictions.len(), wb.divergences.len()), (1, 0)); + assert_eq!((wc.contradictions.len(), wc.divergences.len()), (0, 1)); +} + +/// D4. Intermediate reads cannot be retained: when one differs from the +/// final read and `Intermediate` is protected, the gate refuses, and only +/// then. The expectation is computed here, independently of the gate. +/// Disable: remove the intermediate check -> this test is red. +#[test] +fn d4_a_protected_intermediate_read_that_differs_refuses_the_squash() { + let mut refused = 0; + for (name, prior, lanes) in all_fixtures() { + let history = land_interleaved(&g(), &prior, &lanes); + let last = history.last().expect("non-empty history"); + let differs = history[..history.len() - 1].iter().any(|v| { + (0..12).any(|r| (0..4).any(|l| v.read(&g(), cell(r, l)) != last.read(&g(), cell(r, l)))) + }); + let got = protected_squash(&g(), &prior, &lanes, &[Reader::Intermediate]).err(); + if differs { + refused += 1; + assert_eq!( + got, + Some(ProtectedRefusal::Unretainable(Reader::Intermediate)), + "fixture {name}" + ); + } else { + assert_eq!( + got, None, + "fixture {name} has no differing intermediate read" + ); + } + } + // Anti-vacuity: most fixtures do lose an intermediate read. + assert!(refused >= 3, "only {refused} fixtures exercise the refusal"); +} + +/// D4 silent twins: a one-step history, and a multi-step history whose +/// intermediate reads all equal the final, are accepted with `Intermediate` +/// protected. +#[test] +fn d4_silent_twins_accept_when_no_intermediate_read_differs() { + let one = vec![ + Lane::new(3, vec![commit(1, vec![(cell(4, 1), 41)])]), + Lane::new(7, vec![commit(2, vec![(cell(5, 1), 51)])]), + ]; + assert!(protected_squash(&g(), &main_version(), &one, &[Reader::Intermediate]).is_ok()); + + let steady = vec![Lane::new( + 3, + vec![ + commit(1, vec![(cell(4, 1), 41)]), + commit(2, vec![(cell(4, 1), 41)]), + commit(3, vec![(cell(4, 1), 41)]), + ], + )]; + assert_eq!(land_interleaved(&g(), &main_version(), &steady).len(), 3); + assert!(protected_squash(&g(), &main_version(), &steady, &[Reader::Intermediate]).is_ok()); +} + +/// R1 and R2 are unchanged underneath: a protected squash still refuses +/// what the plain gate refuses. +#[test] +fn the_gate_refusals_pass_through() { + let div = vec![ + Lane::new(4, vec![commit(1, vec![(cell(5, 2), 7)])]), + Lane::new(9, vec![commit(2, vec![(cell(5, 2), 8)])]), + ]; + assert!(matches!( + protected_squash(&g(), &main_version(), &div, &[Reader::Final]), + Err(ProtectedRefusal::Gate(_)) + )); + let contra = vec![Lane::new(5, vec![commit(1, vec![(cell(1, 0), 999)])])]; + assert!(matches!( + protected_squash(&g(), &main_version(), &contra, &[Reader::Final]), + Err(ProtectedRefusal::Gate(_)) + )); +} + +/// D5. Bytes, reported not gated: transient grows with the writes; the +/// squash grows with the order list (one entry per thought) and the distinct +/// cells; the witness grows with the anomalies and the multiplicity cells. +#[test] +fn d5_retained_and_transient_bytes() { + const N: u32 = 8; + let mut rows = Vec::new(); + for k in [1u32, 4, 16, 64] { + let chain: Vec = (0..k) + .map(|round| { + commit( + round, + (0..N) + .map(|i| (cell(4 + i, 1), u64::from(round * 100 + i))) + .collect(), + ) + }) + .collect(); + let lanes = vec![Lane::new(1, chain)]; + let mut protect = vec![Reader::Final]; + protect.extend(LOGS); + let (s, w) = protected_squash(&g(), &main_version(), &lanes, &protect).expect("squash"); + let b = bytes(&lanes, &s, &w); + assert_eq!(b.transient, (k * N) as usize * 13, "transient at k = {k}"); + assert_eq!( + b.witness, + N as usize * 9, + "witness at k = {k}: N multiplicity cells" + ); + rows.push((k, b.transient, b.squash, b.witness)); + } + // The squash grows only through the order list: 5 bytes per thought. + assert_eq!(rows[1].2 - rows[0].2, 3 * 5); + println!("k, transient B, squash B, witness B: {rows:?}"); +} diff --git a/crates/thought-git-probe/tests/squash_gate.rs b/crates/thought-git-probe/tests/squash_gate.rs index 3e385bd4d..ca6d67933 100644 --- a/crates/thought-git-probe/tests/squash_gate.rs +++ b/crates/thought-git-probe/tests/squash_gate.rs @@ -6,160 +6,14 @@ //! before this file was written. Predictions P1..P7 are the tests below; the //! disables D1..D5 are named on the test each must turn red. //! -//! The gate lives here, in the test, because it is a lab question: it does -//! not change `oracle.rs`, `slots.rs` or `receipt.rs`. +//! The gate itself lives in `src/squash.rs` (moved there unchanged for +//! D-TGA-5D); it does not change `oracle.rs`, `slots.rs` or `receipt.rs`. use std::collections::{BTreeMap, BTreeSet}; use thought_git_probe::fixtures::{Cell, Outcome, Thought, cell, givens, main_version}; -use thought_git_probe::oracle::{Givens, Version, next_version, squash}; - -/// A thought lane: its id (1..=63) and its chain of origin, oldest first. -struct Lane { - id: u8, - chain: Vec, -} - -impl Lane { - fn new(id: u8, chain: Vec) -> Self { - assert!((1..=63).contains(&id), "lane {id} outside 1..=63"); - Self { id, chain } - } - - /// The thoughts whose writes land: only `Outcome::Commit`, as in - /// `next_version`. Plan, Prune and Veto stay in the history only. - fn landed(&self) -> impl Iterator { - self.chain.iter().filter(|t| t.outcome == Outcome::Commit) - } - - /// Total landed writes in the chain. - fn ops(&self) -> usize { - self.landed().map(|t| t.writes.len()).sum() - } - - /// The landed chain folded in order: later writes replace earlier ones. - fn final_state(&self) -> BTreeMap { - let mut f = BTreeMap::new(); - for t in self.landed() { - for &(c, v) in &t.writes { - f.insert(c, v); - } - } - f - } -} - -#[derive(Debug, PartialEq, Eq)] -enum Refusal { - Contradiction { - cell: Cell, - given: u64, - offered: u64, - lane: u8, - }, - Divergence { - cell: Cell, - offers: Vec<(u64, u8)>, - }, -} - -#[derive(Debug)] -struct Squashed { - target: u8, - cells: BTreeMap, - order: Vec<(u8, u32)>, - blame: BTreeMap, - seal: u64, -} - -impl Squashed { - /// The squash as one committed thought, for the oracle. - fn as_thought(&self, id: u32) -> Thought { - Thought::new( - id, - self.cells.iter().map(|(&c, &v)| (c, v)).collect(), - Outcome::Commit, - ) - } -} - -fn fnv1a(seal: &mut u64, x: u64) { - for b in x.to_le_bytes() { - *seal ^= u64::from(b); - *seal = seal.wrapping_mul(0x0000_0100_0000_01b3); - } -} - -/// The gate. Lanes are taken in ascending id order whatever order they are -/// passed in. -fn squash_gate(g: &Givens, lanes: &[Lane]) -> Result { - let mut lanes: Vec<&Lane> = lanes.iter().collect(); - lanes.sort_by_key(|l| l.id); - - // Target: most ops; on a tie the lowest id (max_by_key keeps the last - // maximum, so compare on (ops, Reverse(id))). - let target = lanes - .iter() - .max_by_key(|l| (l.ops(), std::cmp::Reverse(l.id))) - .map_or(0, |l| l.id); - - // R1, then R2, over final states only. - let mut offers: BTreeMap> = BTreeMap::new(); - for l in &lanes { - for (c, v) in l.final_state() { - if let Some(&given) = g.0.get(&c) { - if given != v { - return Err(Refusal::Contradiction { - cell: c, - given, - offered: v, - lane: l.id, - }); - } - continue; // agreement with a given is not stored - } - offers.entry(c).or_default().insert((v, l.id)); - } - } - let mut cells = BTreeMap::new(); - for (c, set) in offers { - let values: BTreeSet = set.iter().map(|&(v, _)| v).collect(); - if values.len() > 1 { - return Err(Refusal::Divergence { - cell: c, - offers: set.into_iter().collect(), - }); - } - cells.insert(c, *values.iter().next().expect("one value")); - } - - let mut order = Vec::new(); - let mut blame: BTreeMap = BTreeMap::new(); - let mut seal = 0xcbf2_9ce4_8422_2325_u64; - for l in &lanes { - for t in &l.chain { - order.push((l.id, t.id)); - fnv1a(&mut seal, u64::from(l.id)); - fnv1a(&mut seal, u64::from(t.id)); - for &(c, v) in &t.writes { - if t.outcome == Outcome::Commit { - *blame.entry(c).or_default() |= 1u64 << l.id; - } - fnv1a(&mut seal, u64::from(c.row)); - fnv1a(&mut seal, u64::from(c.lane)); - fnv1a(&mut seal, v); - } - } - } - - Ok(Squashed { - target, - cells, - order, - blame, - seal, - }) -} +use thought_git_probe::oracle::{Version, next_version, squash}; +use thought_git_probe::squash::{Lane, Refusal, squash_gate}; fn commit(id: u32, writes: Vec<(Cell, u64)>) -> Thought { Thought::new(id, writes, Outcome::Commit)