From 36e0734f4042f5ebdd3e29ab55e41e4e0d9b4af9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:40:53 +0000 Subject: [PATCH 1/6] D-TGB-4: thought-git receipt and replay (lab, thought-git-probe) Receipt carries base ref, program id + inputs, seed, exec config, the live slot (provenance only), a fold over the values read, provenance roots and a fold over the writes. Replay recomputes from the receipt alone and refuses on missing seed, changed config, unknown base, changed sources or a different output. Landed receipts live in a metadata table keyed by (dataset, version, thought), staged then sealed; replay accepts only a sealed, untorn row whose data version exists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fbZ92dFKQ5BJHrA3YcH9A --- crates/thought-git-probe/src/lib.rs | 1 + crates/thought-git-probe/src/receipt.rs | 576 ++++++++++++++++++++++++ 2 files changed, 577 insertions(+) create mode 100644 crates/thought-git-probe/src/receipt.rs diff --git a/crates/thought-git-probe/src/lib.rs b/crates/thought-git-probe/src/lib.rs index 4a13cd988..e5133e5c1 100644 --- a/crates/thought-git-probe/src/lib.rs +++ b/crates/thought-git-probe/src/lib.rs @@ -25,4 +25,5 @@ pub mod fixtures; pub mod oracle; +pub mod receipt; pub mod slots; diff --git a/crates/thought-git-probe/src/receipt.rs b/crates/thought-git-probe/src/receipt.rs new file mode 100644 index 000000000..be2667c8e --- /dev/null +++ b/crates/thought-git-probe/src/receipt.rs @@ -0,0 +1,576 @@ +//! D-TGB-4: a receipt instead of history. +//! +//! A thought's receipt records what is needed to recompute its writes after +//! its live slot is gone: the sealed base it read, the program and its +//! reproducibility inputs, the seed, the execution configuration, the +//! sources it read and the provenance roots those sources carry. Replay runs +//! the program again from the receipt; it never reads the old slot. +//! +//! Comparison is by folds. A receipt carries two witnesses, one over the +//! values it read and one over the writes it produced. Replay recomputes +//! both and refuses on any difference. It does not keep the writes and does +//! not decode them to compare. +//! +//! A landed receipt lives in a [`MetadataTable`] keyed by +//! (dataset, data version, thought id), separate from the data version it +//! describes. Data and metadata are two writes, so the table distinguishes a +//! staged row from a sealed one. Replay accepts only a sealed row whose seal +//! still matches its contents, and only if the data version it names exists. +//! +//! Lab only. [`BaseRef`] stands in for D-CPG's `DurableRef` (dataset, branch, +//! version), which has no contract type yet; [`VersionStore`] stands in for +//! Lance. Nothing here touches `cycle_driver`, `LanceCycleWriter` or CE64. + +use std::collections::BTreeMap; + +use crate::fixtures::{Cell, Outcome, Thought}; +use crate::oracle::{Givens, Version}; +use crate::slots::{Slot, SlotPool, Stale}; + +/// A sealed version a thought reads from. Probe stand-in for D-CPG's +/// `DurableRef`; a version number alone is not enough once branches exist. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BaseRef { + pub dataset: u64, + pub branch: u32, + pub version: u64, +} + +/// Sealed versions by reference. Probe stand-in for Lance. +#[derive(Debug, Clone, Default)] +pub struct VersionStore { + pub givens: Givens, + versions: BTreeMap, +} + +impl VersionStore { + #[must_use] + pub fn new(givens: Givens) -> Self { + Self { + givens, + versions: BTreeMap::new(), + } + } + + /// Seal `version` under `at`. A reference is sealed once; sealing it + /// again with different contents is refused. + /// + /// # Errors + /// [`AlreadySealed`] when `at` already holds a different version. + pub fn seal(&mut self, at: BaseRef, version: Version) -> Result<(), AlreadySealed> { + match self.versions.get(&at) { + Some(v) if *v != version => Err(AlreadySealed(at)), + Some(_) => Ok(()), + None => { + self.versions.insert(at, version); + Ok(()) + } + } + } + + #[must_use] + pub fn get(&self, at: BaseRef) -> Option<&Version> { + self.versions.get(&at) + } + + /// Test seam: replace the contents under `at` as if the sealed source had + /// been changed behind the receipt's back. Replay must notice. + pub fn tamper(&mut self, at: BaseRef, version: Version) { + self.versions.insert(at, version); + } +} + +/// `at` already holds different contents. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AlreadySealed(pub BaseRef); + +/// Which program produced the writes. The id names the template; `version` +/// is the template's revision, so an edited template is a different program. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ProgramId { + pub id: u32, + pub version: u32, +} + +/// The one program this probe knows: for each target, read its source cell +/// from the base and write `source + jitter(seed, target)`, where an absent +/// source reads as 0 for the arithmetic only. Its inputs are the +/// reproducibility-relevant part of the receipt. +pub const DERIVE: ProgramId = ProgramId { id: 1, version: 1 }; + +/// The program's inputs as two lanes read at the same position `i`: the +/// source cell `sources[i]` feeds the target `targets[i]`. No pair is stored. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ProgramInputs { + pub sources: Vec, + pub targets: Vec, +} + +impl ProgramInputs { + /// # Panics + /// When the lanes differ in length; a receipt with unequal lanes has no + /// defined program. + #[must_use] + pub fn new(sources: Vec, targets: Vec) -> Self { + assert_eq!(sources.len(), targets.len(), "lanes differ in length"); + Self { sources, targets } + } +} + +/// Execution settings that change the output. Recorded so a replay under a +/// different setting is refused rather than silently compared. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ExecConfig { + /// Revision of the jitter generator (SplitMix64, finalizer as published). + pub rng_revision: u32, + /// Jitter range: values are `0..modulus`. + pub modulus: u64, +} + +pub const EXEC_DEFAULT: ExecConfig = ExecConfig { + rng_revision: 1, + modulus: 1000, +}; + +/// A count plus an order-independent 64-bit fold. A witness, not a proof: two +/// different sets could fold equal, with probability about 2^-64 per pair. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct Fold { + pub count: u64, + pub fold: u64, +} + +impl Fold { + fn add(&mut self, a: u64, b: u64) { + self.count += 1; + self.fold = self.fold.wrapping_add(mix(mix(a) ^ b.rotate_left(17))); + } +} + +/// SplitMix64's finalizer. +const fn mix(mut z: u64) -> u64 { + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +const fn cell_key(c: Cell) -> u64 { + ((c.row as u64) << 8) | c.lane as u64 +} + +/// The fold of a set of writes. +#[must_use] +pub fn fold_writes(writes: &[(Cell, u64)]) -> Fold { + let mut f = Fold::default(); + for &(c, v) in writes { + f.add(cell_key(c), v); + } + f +} + +/// The fold of the values the program read: each source cell with its value, +/// absence distinct from every value. +fn fold_sources(store: &VersionStore, base: &Version, inputs: &ProgramInputs) -> Fold { + let mut f = Fold::default(); + for &src in &inputs.sources { + let read = base.read(&store.givens, src); + f.add( + cell_key(src), + read.map_or(u64::MAX, |v| mix(v) & !(1 << 63)), + ); + } + f +} + +fn jitter(seed: u64, target: Cell, cfg: ExecConfig) -> u64 { + mix(seed ^ mix(cell_key(target) ^ u64::from(cfg.rng_revision))) % cfg.modulus +} + +/// Run the program. Pure: same arguments, same writes. +fn run( + program: ProgramId, + inputs: &ProgramInputs, + seed: u64, + cfg: ExecConfig, + store: &VersionStore, + base: &Version, +) -> Result, Refusal> { + if program != DERIVE { + return Err(Refusal::UnknownProgram(program)); + } + Ok((0..inputs.targets.len()) + .map(|i| { + let (src, dst) = (inputs.sources[i], inputs.targets[i]); + let s = base.read(&store.givens, src).unwrap_or(0); + (dst, s.wrapping_add(jitter(seed, dst, cfg))) + }) + .collect()) +} + +/// Everything needed to recompute a thought's writes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Receipt { + pub thought: u32, + pub base: BaseRef, + pub program: ProgramId, + pub inputs: ProgramInputs, + /// `None` models a receipt written without its seed; replay refuses it. + pub seed: Option, + pub config: ExecConfig, + /// The slot and generation the thought ran in. Provenance only: replay + /// never consults it, and it is not part of [`Receipt::identity`]. + pub captured_in: Slot, + /// Fold of the values read from `base`. + pub sources: Fold, + /// Provenance roots of those sources, as a bit set. Replayed material + /// carries them as inherited, never as new. + pub roots: u64, + /// Fold of the writes the live run produced. + pub output: Fold, + pub outcome: Outcome, +} + +/// The parts of a receipt that decide what replay produces. The live slot is +/// excluded: the same inputs in another slot are the same derivation. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ReceiptIdentity { + pub thought: u32, + pub base: BaseRef, + pub program: ProgramId, + pub inputs: ProgramInputs, + pub seed: Option, + pub config: ExecConfig, + pub sources: Fold, + pub roots: u64, + pub output: Fold, +} + +impl Receipt { + #[must_use] + pub fn identity(&self) -> ReceiptIdentity { + ReceiptIdentity { + thought: self.thought, + base: self.base, + program: self.program, + inputs: self.inputs.clone(), + seed: self.seed, + config: self.config, + sources: self.sources, + roots: self.roots, + output: self.output, + } + } +} + +/// Why a capture or replay did not happen. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// The slot handle is no longer live, or now holds another thought. + Stale(Slot), + /// The live slot holds `found`, not the receipt's thought. + WrongThought { + expected: u32, + found: u32, + }, + /// The base reference is not sealed in the store. + UnknownBase(BaseRef), + UnknownProgram(ProgramId), + MissingSeed, + /// The execution settings differ from the receipt's. + ConfigChanged, + /// The values read from the base differ from what the receipt recorded. + SourceChanged { + recorded: Fold, + found: Fold, + }, + /// The recomputed writes differ from the recorded output. + OutputMismatch { + recorded: Fold, + found: Fold, + }, + /// No metadata row under the key. + MissingReceipt(MetaKey), + /// The row was written but never sealed. + Unsealed(MetaKey), + /// The seal no longer matches the row's contents. + Torn(MetaKey), + /// The row names a data version that is not sealed. + DataMissing(MetaKey), + /// The landing names a different thought than the receipt. + KeyMismatch(MetaKey), +} + +impl From for Refusal { + fn from(s: Stale) -> Self { + Self::Stale(s.0) + } +} + +/// What a thought declares before it runs. +#[derive(Debug, Clone)] +pub struct Plan { + pub thought: u32, + pub base: BaseRef, + pub program: ProgramId, + pub inputs: ProgramInputs, + pub seed: u64, + pub config: ExecConfig, + pub roots: u64, + pub outcome: Outcome, +} + +/// Run a thought in its live slot and capture its receipt. Returns the +/// thought (its writes, for the caller to land or drop) and the receipt. +/// +/// # Errors +/// [`Refusal::Stale`] / [`Refusal::WrongThought`] when `slot` does not hold +/// `plan.thought`; [`Refusal::UnknownBase`]; [`Refusal::UnknownProgram`]. +pub fn capture( + pool: &SlotPool, + slot: Slot, + store: &VersionStore, + plan: Plan, +) -> Result<(Thought, Receipt), Refusal> { + let found = pool.thought(slot)?; + if found != plan.thought { + return Err(Refusal::WrongThought { + expected: plan.thought, + found, + }); + } + let base = store + .get(plan.base) + .ok_or(Refusal::UnknownBase(plan.base))?; + let writes = run( + plan.program, + &plan.inputs, + plan.seed, + plan.config, + store, + base, + )?; + let receipt = Receipt { + thought: plan.thought, + base: plan.base, + program: plan.program, + sources: fold_sources(store, base, &plan.inputs), + inputs: plan.inputs, + seed: Some(plan.seed), + config: plan.config, + captured_in: slot, + roots: plan.roots, + output: fold_writes(&writes), + outcome: plan.outcome, + }; + Ok((Thought::new(plan.thought, writes, plan.outcome), receipt)) +} + +/// A successful replay: the recomputed thought and the evidence it carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Replayed { + pub thought: Thought, + pub output: Fold, + /// The receipt's roots, carried as inherited. + pub inherited_roots: u64, + /// Always 0: a replay has no root its receipt did not already name. + pub independent_roots: u64, +} + +/// Replay from the receipt alone, under `config`. Never reads a slot pool. +/// +/// # Errors +/// Every [`Refusal`] from the seed, configuration, base, source and output +/// checks, in that order. +pub fn replay( + receipt: &Receipt, + store: &VersionStore, + config: ExecConfig, +) -> Result { + let seed = receipt.seed.ok_or(Refusal::MissingSeed)?; + if config != receipt.config { + return Err(Refusal::ConfigChanged); + } + let base = store + .get(receipt.base) + .ok_or(Refusal::UnknownBase(receipt.base))?; + let sources = fold_sources(store, base, &receipt.inputs); + if sources != receipt.sources { + return Err(Refusal::SourceChanged { + recorded: receipt.sources, + found: sources, + }); + } + let writes = run(receipt.program, &receipt.inputs, seed, config, store, base)?; + let output = fold_writes(&writes); + if output != receipt.output { + return Err(Refusal::OutputMismatch { + recorded: receipt.output, + found: output, + }); + } + Ok(Replayed { + thought: Thought::new(receipt.thought, writes, receipt.outcome), + output, + inherited_roots: receipt.roots, + independent_roots: 0, + }) +} + +/// Replay while the thought is still live: the slot must still hold it. +/// +/// # Errors +/// [`Refusal::Stale`] / [`Refusal::WrongThought`] first, then as [`replay`]. +pub fn replay_live( + pool: &SlotPool, + receipt: &Receipt, + store: &VersionStore, + config: ExecConfig, +) -> Result { + let found = pool.thought(receipt.captured_in)?; + if found != receipt.thought { + return Err(Refusal::WrongThought { + expected: receipt.thought, + found, + }); + } + replay(receipt, store, config) +} + +/// Key of the metadata relation: the data version a thought landed in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MetaKey { + pub data: BaseRef, + pub thought: u32, +} + +/// One metadata row. `seal` is written last and covers every other field. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MetaRow { + pub receipt: Receipt, + pub seal: Option, +} + +fn seal_of(key: MetaKey, r: &Receipt) -> u64 { + let mut f = Fold::default(); + let ident = r.identity(); + f.add(key.data.dataset, key.data.version); + f.add(u64::from(key.data.branch), u64::from(key.thought)); + f.add(ident.base.dataset, ident.base.version); + f.add(u64::from(ident.base.branch), u64::from(ident.thought)); + f.add( + u64::from(ident.program.id), + u64::from(ident.program.version), + ); + f.add( + ident.seed.unwrap_or(u64::MAX), + u64::from(ident.seed.is_some()), + ); + f.add(u64::from(ident.config.rng_revision), ident.config.modulus); + f.add(ident.sources.count, ident.sources.fold); + f.add(ident.output.count, ident.output.fold); + f.add(ident.roots, ident.inputs.targets.len() as u64); + for i in 0..ident.inputs.targets.len() { + let (s, d) = (ident.inputs.sources[i], ident.inputs.targets[i]); + f.add(i as u64, (cell_key(s) << 32) | cell_key(d)); + } + f.fold ^ f.count +} + +/// The separate, version-linked relation holding landed receipts. +#[derive(Debug, Clone, Default)] +pub struct MetadataTable { + rows: BTreeMap, +} + +impl MetadataTable { + /// Step 1 of a landing, after the data version is sealed: write the row + /// unsealed. The slot must still hold the thought. + /// + /// # Errors + /// [`Refusal::Stale`] / [`Refusal::WrongThought`]; [`Refusal::KeyMismatch`] + /// when `key.thought` is not the receipt's; [`Refusal::DataMissing`] when + /// the data version is not sealed yet (data first, then metadata). + pub fn stage( + &mut self, + pool: &SlotPool, + store: &VersionStore, + key: MetaKey, + receipt: Receipt, + ) -> Result<(), Refusal> { + let found = pool.thought(receipt.captured_in)?; + if found != receipt.thought { + return Err(Refusal::WrongThought { + expected: receipt.thought, + found, + }); + } + if key.thought != receipt.thought { + return Err(Refusal::KeyMismatch(key)); + } + if store.get(key.data).is_none() { + return Err(Refusal::DataMissing(key)); + } + self.rows.insert( + key, + MetaRow { + receipt, + seal: None, + }, + ); + Ok(()) + } + + /// Step 2: seal the staged row. Only a sealed row is a landing. + /// + /// # Errors + /// [`Refusal::MissingReceipt`] when nothing was staged under `key`. + pub fn seal(&mut self, key: MetaKey) -> Result<(), Refusal> { + let row = self + .rows + .get_mut(&key) + .ok_or(Refusal::MissingReceipt(key))?; + row.seal = Some(seal_of(key, &row.receipt)); + Ok(()) + } + + #[must_use] + pub fn row(&self, key: MetaKey) -> Option<&MetaRow> { + self.rows.get(&key) + } + + /// Test seam: a write that changed a row after it was sealed. + pub fn row_mut(&mut self, key: MetaKey) -> Option<&mut MetaRow> { + self.rows.get_mut(&key) + } + + /// The sealed receipt under `key`, checked against its seal and against + /// the data version it names. + /// + /// # Errors + /// [`Refusal::MissingReceipt`], [`Refusal::Unsealed`], [`Refusal::Torn`], + /// [`Refusal::DataMissing`]. + pub fn landed(&self, store: &VersionStore, key: MetaKey) -> Result<&Receipt, Refusal> { + let row = self.rows.get(&key).ok_or(Refusal::MissingReceipt(key))?; + let seal = row.seal.ok_or(Refusal::Unsealed(key))?; + if seal != seal_of(key, &row.receipt) { + return Err(Refusal::Torn(key)); + } + if store.get(key.data).is_none() { + return Err(Refusal::DataMissing(key)); + } + Ok(&row.receipt) + } + + /// Replay a landed thought from its sealed row. Needs no slot pool. + /// + /// # Errors + /// As [`landed`](Self::landed), then as [`replay`]. + pub fn replay_landed( + &self, + store: &VersionStore, + key: MetaKey, + config: ExecConfig, + ) -> Result { + replay(self.landed(store, key)?, store, config) + } +} From 20412ed01f8c788c053eacad8344895dafc6b894 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:42:44 +0000 Subject: [PATCH 2/6] ci: run thought-git-probe (excluded crate, no workflow covered it) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fbZ92dFKQ5BJHrA3YcH9A --- .github/workflows/thought-git-probe.yml | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/thought-git-probe.yml diff --git a/.github/workflows/thought-git-probe.yml b/.github/workflows/thought-git-probe.yml new file mode 100644 index 000000000..46b109358 --- /dev/null +++ b/.github/workflows/thought-git-probe.yml @@ -0,0 +1,39 @@ +name: Thought-git probe +# `crates/thought-git-probe` (D-TGB-0/1/4) is excluded from the workspace and +# has its own [workspace], so no workspace job builds it. It path-deps only +# lance-graph-contract, which has no dependencies, so no sibling checkout is +# needed. +on: + pull_request: + paths: + - 'crates/thought-git-probe/**' + - 'crates/lance-graph-contract/**' + - '.github/workflows/thought-git-probe.yml' + - 'rust-toolchain.toml' + push: + branches: [main] + paths: + - 'crates/thought-git-probe/**' + - 'crates/lance-graph-contract/**' + - '.github/workflows/thought-git-probe.yml' + - 'rust-toolchain.toml' + workflow_dispatch: +permissions: + contents: read +jobs: + probe: + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: crates/thought-git-probe + env: + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_INCREMENTAL: "0" + steps: + - uses: actions/checkout@v4 + - name: Setup rust toolchain (pinned by rust-toolchain.toml) + run: rustup show + - run: cargo fmt --check + - run: cargo clippy --all-targets -- -D warnings + - run: cargo test From 7461ea803d2bb4de8cd40138d482c6ac1a6a85d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:44:14 +0000 Subject: [PATCH 3/6] D-TGB-4: receipt falsifier tests (10), including the ABA slot case Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fbZ92dFKQ5BJHrA3YcH9A --- crates/thought-git-probe/tests/receipt.rs | 472 ++++++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 crates/thought-git-probe/tests/receipt.rs diff --git a/crates/thought-git-probe/tests/receipt.rs b/crates/thought-git-probe/tests/receipt.rs new file mode 100644 index 000000000..7606908b6 --- /dev/null +++ b/crates/thought-git-probe/tests/receipt.rs @@ -0,0 +1,472 @@ +//! D-TGB-4: a receipt instead of history. Every test is a falsifier; its doc +//! comment names the deliberate break of `receipt.rs` under which it must turn +//! red. Results are compared by folds. The independent fold below uses FNV-1a +//! over the row, lane and value bytes, summed per write, so it shares no code +//! and no mixing function with the receipt's own fold. + +use lance_graph_contract::revision::{ + BasisView, CodebookId, EncounterEvidence, EvidentialEffect, GadamerRevision, GrammarId, + HorizonId, InterpretiveHorizon, LanguageId, LensId, QuestionId, RevisionPolicy, +}; +use thought_git_probe::fixtures::{self, Cell, Outcome, Thought, cell, main_version}; +use thought_git_probe::oracle::{Version, next_version}; +use thought_git_probe::receipt::{ + self, BaseRef, DERIVE, EXEC_DEFAULT, ExecConfig, MetaKey, MetadataTable, Plan, ProgramInputs, + Receipt, Refusal, Replayed, VersionStore, +}; +use thought_git_probe::slots::{Eviction, Slot, SlotPool}; + +// ---------------------------------------------------------------- helpers + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut h = 0xcbf2_9ce4_8422_2325_u64; + for &b in bytes { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// Count and order-independent sum of per-write FNV-1a hashes. +fn independent_fold(writes: &[(Cell, u64)]) -> (u64, u64) { + let mut sum = 0_u64; + for &(c, v) in writes { + let mut bytes = Vec::with_capacity(13); + bytes.extend_from_slice(&c.row.to_le_bytes()); + bytes.push(c.lane); + bytes.extend_from_slice(&v.to_le_bytes()); + sum = sum.wrapping_add(fnv1a(&bytes)); + } + (writes.len() as u64, sum) +} + +/// Both folds agree between the original run and the replay, and the replay +/// reports the fold the receipt recorded. +fn assert_same_by_folds(original: &Thought, rep: &Replayed, r: &Receipt) { + assert_eq!( + independent_fold(&original.writes), + independent_fold(&rep.thought.writes), + "independent fold differs" + ); + assert_eq!(rep.output, r.output, "receipt fold differs"); + assert_eq!(rep.output.count, original.writes.len() as u64); +} + +fn base_ref() -> BaseRef { + BaseRef { + dataset: 7, + branch: 0, + version: 1, + } +} + +fn setup() -> (VersionStore, BaseRef) { + let mut store = VersionStore::new(fixtures::givens()); + let base = base_ref(); + store.seal(base, main_version()).expect("seal base"); + (store, base) +} + +/// Sources: two given cells and one absent cell. Targets: the +/// `rubicon_outcomes` cells. +fn inputs() -> ProgramInputs { + ProgramInputs::new( + vec![cell(0, 0), cell(1, 0), cell(9, 3)], + vec![cell(4, 1), cell(5, 1), cell(6, 1)], + ) +} + +fn plan(base: BaseRef, thought: u32, outcome: Outcome, seed: u64) -> Plan { + Plan { + thought, + base, + program: DERIVE, + inputs: inputs(), + seed, + config: EXEC_DEFAULT, + roots: 0b0011, + outcome, + } +} + +/// Branch `thought` and capture its receipt. +fn run(store: &VersionStore, pool: &mut SlotPool, p: Plan) -> (Slot, Thought, Receipt) { + let (slot, _) = pool.branch(p.thought, None).expect("branch"); + let (t, r) = receipt::capture(pool, slot, store, p).expect("capture"); + (slot, t, r) +} + +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, + } +} + +// ------------------------------------------------------------------ tests + +/// 1. A thought whose slot was freed (and reused) replays exactly from the +/// receipt alone. Disable: `replay` reads `captured_in` from the pool and +/// refuses (or misreads) once the slot is gone or recycled. +#[test] +fn a_released_thought_replays_exactly() { + let (store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let outcomes = [ + (10, Outcome::Commit, 11_u64), + (11, Outcome::Plan, 22), + (12, Outcome::Prune, 33), + ]; + let mut captured = Vec::new(); + for (id, outcome, seed) in outcomes { + captured.push(run(&store, &mut pool, plan(base, id, outcome, seed))); + } + for (slot, _, _) in &captured { + pool.free(*slot).expect("free"); + assert!(pool.thought(*slot).is_err(), "slot still live after free"); + } + + // The freed slot is reused by another thought: same index, new generation. + let first = captured[0].0; + let (reused, _) = pool.branch(99, None).expect("rebranch"); + assert_eq!(reused.index, first.index); + assert_ne!(reused.generation, first.generation); + + for (_, original, r) in &captured { + let rep = receipt::replay(r, &store, EXEC_DEFAULT).expect("replay"); + assert_same_by_folds(original, &rep, r); + assert_eq!(&rep.thought, original, "cell-for-cell"); + } +} + +/// 2. A receipt without its seed refuses. Disable: default the seed to 0. +#[test] +fn a_missing_seed_refuses() { + let (store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (_, t, mut r) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + let rep = receipt::replay(&r, &store, EXEC_DEFAULT).expect("with seed"); + assert_same_by_folds(&t, &rep, &r); + r.seed = None; + assert_eq!( + receipt::replay(&r, &store, EXEC_DEFAULT), + Err(Refusal::MissingSeed) + ); +} + +/// 3. A different seed produces different writes and the output check +/// refuses; the recorded seed replays. Disable: skip the output check. +#[test] +fn a_changed_seed_is_an_output_mismatch() { + let (store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (_, t1, r1) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + let (_, t2, _) = run(&store, &mut pool, plan(base, 11, Outcome::Commit, 6)); + // Anti-vacuity: the two seeds really give different writes. + assert_ne!( + independent_fold(&t1.writes), + independent_fold(&t2.writes), + "seeds 5 and 6 produce the same writes; the test would prove nothing" + ); + + let rep = receipt::replay(&r1, &store, EXEC_DEFAULT).expect("original seed"); + assert_same_by_folds(&t1, &rep, &r1); + + let mut changed = r1.clone(); + changed.seed = Some(6); + let err = receipt::replay(&changed, &store, EXEC_DEFAULT).expect_err("changed seed"); + assert!(matches!(err, Refusal::OutputMismatch { .. }), "got {err:?}"); +} + +/// 4. Replaying under another execution configuration refuses. Disable: drop +/// the configuration comparison. +#[test] +fn a_changed_config_refuses() { + let (store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (_, t, r) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + let other = ExecConfig { + modulus: 999, + ..EXEC_DEFAULT + }; + assert_eq!( + receipt::replay(&r, &store, other), + Err(Refusal::ConfigChanged) + ); + let rep = receipt::replay(&r, &store, EXEC_DEFAULT).expect("same config"); + assert_same_by_folds(&t, &rep, &r); +} + +/// 5. A sealed source changed behind the receipt's back refuses; a change to +/// a cell that is not a source does not. Disable: skip the source witness +/// check (the first half then reaches the output check or passes). +#[test] +fn a_changed_source_refuses_and_a_non_source_change_does_not() { + // Fire: write the ABSENT source cell. + let (mut store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (_, _, r) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + let mut v = Version::default(); + v.cells.insert(cell(9, 3), 5); + store.tamper(base, v); + let err = receipt::replay(&r, &store, EXEC_DEFAULT).expect_err("source changed"); + assert!(matches!(err, Refusal::SourceChanged { .. }), "got {err:?}"); + + // Silent: write a cell that no source reads. + let (mut store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (_, t, r) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + let mut v = Version::default(); + v.cells.insert(cell(11, 3), 5); + store.tamper(base, v); + let rep = receipt::replay(&r, &store, EXEC_DEFAULT).expect("non-source change"); + assert_same_by_folds(&t, &rep, &r); +} + +/// 6. A stale or reused slot fails closed; a live slot is accepted. +/// Disable: ignore the generation in the slot check. +#[test] +fn stale_and_reused_slots_fail_closed() { + let (store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (slot, t, r) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + + // Silent half: the live slot replays. + let rep = receipt::replay_live(&pool, &r, &store, EXEC_DEFAULT).expect("live"); + assert_same_by_folds(&t, &rep, &r); + + // WrongThought: a live slot holding another thought than the plan's. + let err = receipt::capture(&pool, slot, &store, plan(base, 11, Outcome::Plan, 5)) + .expect_err("wrong thought"); + assert_eq!( + err, + Refusal::WrongThought { + expected: 11, + found: 10 + } + ); + + // Freed: stale. + pool.free(slot).expect("free"); + assert!(matches!( + receipt::replay_live(&pool, &r, &store, EXEC_DEFAULT), + Err(Refusal::Stale(_)) + )); + assert!(matches!( + receipt::capture(&pool, slot, &store, plan(base, 10, Outcome::Commit, 5)), + Err(Refusal::Stale(_)) + )); + + // Re-branched by a different thought: same index, still stale. + let (reused, _) = pool.branch(77, None).expect("rebranch"); + assert_eq!(reused.index, slot.index); + assert_ne!(reused.generation, slot.generation); + assert!(matches!( + receipt::replay_live(&pool, &r, &store, EXEC_DEFAULT), + Err(Refusal::Stale(_)) + )); + + // ABA: the SAME thought id re-branched into the same index. Only the + // generation tells the old handle from the new one. + pool.free(reused).expect("free reused"); + let (aba, _) = pool.branch(10, None).expect("aba"); + assert_eq!(aba.index, slot.index); + assert_eq!(pool.thought(aba), Ok(10)); + assert!(matches!( + receipt::replay_live(&pool, &r, &store, EXEC_DEFAULT), + Err(Refusal::Stale(_)) + )); +} + +/// 7. A replay keeps its contradictions and roots. Disable: replay drops or +/// rewrites a write, or reports the receipt roots as independent. +#[test] +fn contradictions_and_roots_keep_their_meaning() { + let (store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + // Target (1, 0) is a given (101); source (2, 0) reads 102, so every + // offered value is at least 102 and differs from the given. + let p = Plan { + inputs: ProgramInputs::new(vec![cell(2, 0), cell(9, 3)], vec![cell(1, 0), cell(4, 1)]), + ..plan(base, 10, Outcome::Commit, 5) + }; + let (_, original, r) = run(&store, &mut pool, p); + let rep = receipt::replay(&r, &store, EXEC_DEFAULT).expect("replay"); + assert_same_by_folds(&original, &rep, &r); + + let g = store.givens.clone(); + let v_orig = next_version(&g, &main_version(), std::slice::from_ref(&original)); + let v_rep = next_version(&g, &main_version(), std::slice::from_ref(&rep.thought)); + assert_eq!(v_orig, v_rep); + assert_eq!(v_rep.contradictions.len(), 1); + let c = v_rep.contradictions[0]; + assert_eq!(c.cell, cell(1, 0)); + assert_eq!(c.given, 101); + assert_ne!(c.offered, 101); + assert_eq!(c.thought, 10); + assert_eq!(v_orig.contradictions[0], c); + + assert_eq!(rep.inherited_roots, r.roots); + assert_eq!(rep.independent_roots, 0); +} + +/// 8. A replay creates no new certification. Disable: report the receipt +/// roots as independent (the replay would then look like a new root). +#[test] +fn replay_cannot_raise_evidence_but_a_new_root_can() { + let (store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (_, _, r) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + let rep = receipt::replay(&r, &store, EXEC_DEFAULT).expect("replay"); + + let prior = horizon(0b0011, r.roots); + let e = encounter(0b0011, rep.independent_roots, rep.inherited_roots, 0); + let d = GadamerRevision.revise(&prior, &e, &ancestry(&prior)); + assert_ne!(d.evidential_effect, EvidentialEffect::IncreaseEligible); + + // Can-fire twin: same encounter plus a root outside the prior's. + let new_bit = 0b0100_u64; + assert_eq!(r.roots & new_bit, 0, "bit must be new"); + let fresh = encounter(0b0111, rep.independent_roots | r.roots | new_bit, 0, 0); + let d = GadamerRevision.revise(&prior, &fresh, &ancestry(&prior)); + assert_eq!(d.evidential_effect, EvidentialEffect::IncreaseEligible); +} + +/// 9. A staged-but-unsealed or torn metadata row is not a landing. Disable: +/// accept rows with `seal == None` (or skip the seal comparison). +#[test] +fn a_partial_metadata_write_cannot_pass_as_a_landing() { + let (mut store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (slot, original, r) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + let landed = next_version( + &store.givens.clone(), + &main_version(), + std::slice::from_ref(&original), + ); + let data = BaseRef { + dataset: 7, + branch: 0, + version: 2, + }; + let key = MetaKey { data, thought: 10 }; + let mut table = MetadataTable::default(); + + // Data first, then metadata: staging before the data is sealed refuses. + assert!(matches!( + table.stage(&pool, &store, key, r.clone()), + Err(Refusal::DataMissing(_)) + )); + store.seal(data, landed).expect("seal data"); + + // A key naming another thought than the receipt's. + let bad = MetaKey { data, thought: 11 }; + assert!(matches!( + table.stage(&pool, &store, bad, r.clone()), + Err(Refusal::KeyMismatch(_)) + )); + + // Never staged. + let never = MetaKey { data, thought: 999 }; + assert!(matches!( + table.replay_landed(&store, never, EXEC_DEFAULT), + Err(Refusal::MissingReceipt(_)) + )); + + // Staged, not sealed. + table.stage(&pool, &store, key, r.clone()).expect("stage"); + assert!(matches!( + table.replay_landed(&store, key, EXEC_DEFAULT), + Err(Refusal::Unsealed(_)) + )); + + // Sealed: a landing. + table.seal(key).expect("seal row"); + let rep = table + .replay_landed(&store, key, EXEC_DEFAULT) + .expect("sealed row"); + assert_same_by_folds(&original, &rep, &r); + + // Torn: a field changed after the seal. + table.row_mut(key).expect("row").receipt.roots ^= 1; + assert!(matches!( + table.replay_landed(&store, key, EXEC_DEFAULT), + Err(Refusal::Torn(_)) + )); + table.row_mut(key).expect("row").receipt.roots ^= 1; + assert!(table.replay_landed(&store, key, EXEC_DEFAULT).is_ok()); + + // The landing replays without the pool. + pool.free(slot).expect("free"); + let rep = table + .replay_landed(&store, key, EXEC_DEFAULT) + .expect("after free"); + assert_same_by_folds(&original, &rep, &r); +} + +/// 10. The same plan in two different slots is the same derivation. Disable: +/// include `captured_in` in `identity`. +#[test] +fn identity_ignores_the_slot_but_not_the_seed() { + let (store, base) = setup(); + let p = plan(base, 10, Outcome::Commit, 5); + + let mut pool_a = SlotPool::new(Eviction::Fifo); + let (_, ta, ra) = run(&store, &mut pool_a, p.clone()); + + let mut pool_b = SlotPool::new(Eviction::Fifo); + pool_b.branch(500, None).expect("d1"); + let (d2, _) = pool_b.branch(501, None).expect("d2"); + pool_b.branch(502, None).expect("d3"); + pool_b.free(d2).expect("free d2"); + let (_, tb, rb) = run(&store, &mut pool_b, p.clone()); + + // Anti-vacuity: the slots really differ. + assert_ne!(ra.captured_in, rb.captured_in); + + assert_eq!(ra.identity(), rb.identity()); + let rep_a = receipt::replay(&ra, &store, EXEC_DEFAULT).expect("a"); + let rep_b = receipt::replay(&rb, &store, EXEC_DEFAULT).expect("b"); + assert_same_by_folds(&ta, &rep_a, &ra); + assert_same_by_folds(&tb, &rep_b, &rb); + assert_eq!( + independent_fold(&rep_a.thought.writes), + independent_fold(&rep_b.thought.writes) + ); + + // Fire: only the seed differs. + let (_, _, rc) = run(&store, &mut pool_a, Plan { seed: 6, ..p }); + assert_ne!(ra.identity(), rc.identity()); +} From 5c96f2e13731348d308457cf31683983e42cc33f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:45:07 +0000 Subject: [PATCH 4/6] D-TGB-4: test 8's prior horizon predates the receipt roots, so mislabelled roots are caught Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fbZ92dFKQ5BJHrA3YcH9A --- crates/thought-git-probe/tests/receipt.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/thought-git-probe/tests/receipt.rs b/crates/thought-git-probe/tests/receipt.rs index 7606908b6..901c744e7 100644 --- a/crates/thought-git-probe/tests/receipt.rs +++ b/crates/thought-git-probe/tests/receipt.rs @@ -351,7 +351,15 @@ fn replay_cannot_raise_evidence_but_a_new_root_can() { let (_, _, r) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); let rep = receipt::replay(&r, &store, EXEC_DEFAULT).expect("replay"); - let prior = horizon(0b0011, r.roots); + // The prior horizon predates the receipt's roots: it holds another root + // (bit 3) and not 0b0011. Labelled correctly, the replay's roots arrive as + // inherited and earn nothing; labelled independent they would look new. + let prior = horizon(0b0011, 0b1000); + assert_eq!( + prior.independent_roots & r.roots, + 0, + "receipt roots must be new to the prior" + ); let e = encounter(0b0011, rep.independent_roots, rep.inherited_roots, 0); let d = GadamerRevision.revise(&prior, &e, &ancestry(&prior)); assert_ne!(d.evidential_effect, EvidentialEffect::IncreaseEligible); From 3496242fc7bd674f7c28b7d68a08fb2b173df471 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:46:18 +0000 Subject: [PATCH 5/6] board: D-TGB-4 receipt entry, STATUS row In PR, pre-registration row Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fbZ92dFKQ5BJHrA3YcH9A --- .claude/board/STATUS_BOARD.md | 2 +- .../2026-10-21-d-tgb-4-receipt-replay.md | 35 +++++++++++++++++++ .claude/board/entries/README.md | 3 +- crates/thought-git-probe/PREREGISTRATION.md | 2 +- 4 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 .claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index e4b3ec1a0..9cdad92c9 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -389,7 +389,7 @@ Plan: `.claude/plans/ce64-hypothesis-layers-v1.md`. Plateau: `.claude/knowledge/ | **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 | | **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 | +| **D-TGB-4** | receipt (base ref, program id, seeds, slot generation) instead of history | In PR (lab, `thought-git-probe/src/receipt.rs`, entry 2026-10-21-d-tgb-4-receipt-replay; 10 tests, 10 disables red) | replay from receipt reproduces writes cell for cell | | **D-TGB-5** | reads as resident folds: `checkout`/`log`/`diff`/`blame` | Queued (lab) | equal the oracle; durable `diff` equals Lance `delta()` | | **D-TGB-7** | reusable git API: verb trait + types in the contract, one implementation crate; phase-1 driver `ogar-loco` / `ActionDef`, ACT-R in phase 2; first runs may call the engine crates directly; stack below: datafrog > mask-risc > relalg > r2il | Queued (lab) | driver independence: two drivers with the same decisions give byte-identical cells, receipts, metadata rows | | **D-TGB-8** | abstraction API: Datalog rules over the metadata table on `datafrog`; ACT-R `Candidate` / intent as the decision vocabulary | Queued (lab, after D-TGB-7) | rule answers == direct scan; recorded intents replayed as verbs reproduce the run byte for byte | diff --git a/.claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md b/.claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md new file mode 100644 index 000000000..388531213 --- /dev/null +++ b/.claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md @@ -0,0 +1,35 @@ +# 2026-10-21 — D-TGB-4: a receipt instead of history (lab) + +**Status:** TEST-PINNED (lab probe `crates/thought-git-probe`, `src/receipt.rs`, `tests/receipt.rs`; 10 tests, 10 disable runs red). Not wired into `cycle_driver`, `LanceCycleWriter`, the supervisor or CE64. Claim: EXO-50 `session_019fbZ92dFKQ5BJHrA3YcH9A/003`; issue EXO-58. + +## What landed + +- `Receipt`: base ref (dataset, branch, version), program id with its template version, program inputs as two lanes (sources, targets) read at one position, seed, execution config, the live slot and generation (provenance only), a fold over the values read, provenance roots, a fold over the writes, and the outcome. +- `replay(receipt, store, config)` recomputes from the receipt alone; it takes no slot pool. It refuses on a missing seed, a changed config, an unknown base, changed sources (the source fold) or a different output (the output fold). Comparison is by folds; the writes are not kept in the receipt. +- `replay_live` and `MetadataTable::stage` check that the slot still holds the thought under the same generation. +- `MetadataTable`: landed receipts keyed by (dataset, data version, thought id). `stage` refuses before the data version is sealed (data first, then metadata) and on a key naming another thought. `seal` writes a fold over every field last. `landed` / `replay_landed` accept only a sealed row whose seal still matches and whose data version exists. +- `Replayed` carries the receipt's roots as inherited and 0 independent roots. +- CI: `.github/workflows/thought-git-probe.yml`. Before this, no workflow ran the crate. + +## Falsifiers (each with its disable, all verified red 2026-10-21) + +| test | disable | +|---|---| +| a released thought (Commit, Plan, Prune), its slot freed and reused, replays cell for cell | — (replay takes no pool, by signature) | +| a receipt without its seed refuses | default the seed to 0 | +| a different seed is an output mismatch (seeds shown to give different writes first) | skip the output check | +| a different execution config refuses | skip the config check | +| a changed source refuses; a change to a non-source cell does not | skip the source fold | +| a freed, reused or ABA-reused slot (same thought id, new generation) fails closed | ignore the slot generation | +| contradictions and roots keep their meaning under replay (equal `next_version`, one contradiction) | report receipt roots as independent | +| a replay earns no new certification; a genuinely new root does (Gadamer) | report receipt roots as independent | +| an unsealed, torn, missing or data-less metadata row is not a landing | accept unsealed rows; skip the seal comparison; stage before the data version | +| the same plan in two slots has one identity; a different seed changes it | put the slot into the identity | + +## Measured vs not measured + +- Measured: the 10 tests and the 10 disable runs above, on the fixture address space (12 rows × 4 lanes), one program (`DERIVE`). +- Not measured: timing (none taken); Lance-backed metadata (the store and table are in-memory stand-ins); atomicity of the two Lance versions (still OPEN in thought-git-basics item 8); programs other than `DERIVE`; receipts across a rebase. +- Fold collisions: the folds are 64-bit witnesses, not proofs. + +OPEN: `BaseRef` stands in for D-CPG's `DurableRef`, which has no contract type; the plan places receipt, slot and `DurableRef` in `lance-graph-contract` at D-TGB-7. D-RFT-5 can now reference this receipt shape. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index fe1ea317e..21bbfa63d 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. -420 entries, 2026-08-06 .. 2026-10-21. +421 entries, 2026-08-06 .. 2026-10-21. | date | entry id | finding | file | |---|---|---|---| @@ -37,6 +37,7 @@ exactly one of them, never both. | 2026-10-21 | `pg-classview-polyfill-map` | | [2026-10-21-pg-classview-polyfill-map.md](2026-10-21-pg-classview-polyfill-map.md) | | 2026-10-21 | `eval-egglog-datafrog-ascent` | | [2026-10-21-eval-egglog-datafrog-ascent.md](2026-10-21-eval-egglog-datafrog-ascent.md) | | 2026-10-21 | `dr256-iam-nested-groups` | | [2026-10-21-dr256-iam-nested-groups.md](2026-10-21-dr256-iam-nested-groups.md) | +| 2026-10-21 | `D-TGB-4` | | [2026-10-21-d-tgb-4-receipt-replay.md](2026-10-21-d-tgb-4-receipt-replay.md) | | 2026-10-21 | `D-RU-6-LAB-1` | | [2026-10-21-d-ru-6-tile-walk-probe.md](2026-10-21-d-ru-6-tile-walk-probe.md) | | 2026-10-21 | `D-RU-6-LAB-2` | | [2026-10-21-d-ru-6-sparse-compute.md](2026-10-21-d-ru-6-sparse-compute.md) | | 2026-10-21 | `D-RU-6-LAB-3` | | [2026-10-21-d-ru-6-dirty-frontier.md](2026-10-21-d-ru-6-dirty-frontier.md) | diff --git a/crates/thought-git-probe/PREREGISTRATION.md b/crates/thought-git-probe/PREREGISTRATION.md index 80338a33a..815c20d7e 100644 --- a/crates/thought-git-probe/PREREGISTRATION.md +++ b/crates/thought-git-probe/PREREGISTRATION.md @@ -40,7 +40,7 @@ on the real `lance-graph-contract` code (`tests/signals.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-4 receipt (built, `src/receipt.rs`) | replaying a landed or discarded thought from its receipt reproduces its writes cell for cell; missing or changed seed, changed config, changed source, stale/ABA slot, unsealed or torn metadata row all refuse | drop the seeds from the receipt (verified 2026-10-21, with nine further disables in `tests/receipt.rs`) | `rubicon_outcomes` cells (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 | From ed54930e2540687ced4705c89cfc509b46344987 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:51:11 +0000 Subject: [PATCH 6/6] D-TGB-4: seal the receipt outcome (Codex P2) The metadata seal folded only ReceiptIdentity, which left out the outcome. A sealed row changed from Commit to Prune would still pass landed() and replay_landed(), and next_version would then drop the writes while every fold still matched. The outcome is now part of ReceiptIdentity and of the seal, and the partial-write test adds a torn case that mutates it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fbZ92dFKQ5BJHrA3YcH9A --- crates/thought-git-probe/src/fixtures.rs | 2 +- crates/thought-git-probe/src/receipt.rs | 13 +++++++++++++ crates/thought-git-probe/tests/receipt.rs | 10 ++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/thought-git-probe/src/fixtures.rs b/crates/thought-git-probe/src/fixtures.rs index ced713b2a..a0a5830b3 100644 --- a/crates/thought-git-probe/src/fixtures.rs +++ b/crates/thought-git-probe/src/fixtures.rs @@ -27,7 +27,7 @@ pub const fn cell(row: u32, lane: u8) -> Cell { } /// How a thought's write intention ends on the Rubicon lifecycle. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Outcome { /// `Planning → CognitiveWork → Evaluation → Commit`: the result lands. Commit, diff --git a/crates/thought-git-probe/src/receipt.rs b/crates/thought-git-probe/src/receipt.rs index be2667c8e..94d000006 100644 --- a/crates/thought-git-probe/src/receipt.rs +++ b/crates/thought-git-probe/src/receipt.rs @@ -243,6 +243,8 @@ pub struct ReceiptIdentity { pub sources: Fold, pub roots: u64, pub output: Fold, + /// Decides whether `next_version` lands the replayed writes. + pub outcome: Outcome, } impl Receipt { @@ -258,6 +260,7 @@ impl Receipt { sources: self.sources, roots: self.roots, output: self.output, + outcome: self.outcome, } } } @@ -450,6 +453,15 @@ pub struct MetaRow { pub seal: Option, } +const fn outcome_code(o: Outcome) -> u64 { + match o { + Outcome::Commit => 1, + Outcome::Plan => 2, + Outcome::Prune => 3, + Outcome::Veto => 4, + } +} + fn seal_of(key: MetaKey, r: &Receipt) -> u64 { let mut f = Fold::default(); let ident = r.identity(); @@ -468,6 +480,7 @@ fn seal_of(key: MetaKey, r: &Receipt) -> u64 { f.add(u64::from(ident.config.rng_revision), ident.config.modulus); f.add(ident.sources.count, ident.sources.fold); f.add(ident.output.count, ident.output.fold); + f.add(outcome_code(ident.outcome), 0); f.add(ident.roots, ident.inputs.targets.len() as u64); for i in 0..ident.inputs.targets.len() { let (s, d) = (ident.inputs.sources[i], ident.inputs.targets[i]); diff --git a/crates/thought-git-probe/tests/receipt.rs b/crates/thought-git-probe/tests/receipt.rs index 901c744e7..bc2588464 100644 --- a/crates/thought-git-probe/tests/receipt.rs +++ b/crates/thought-git-probe/tests/receipt.rs @@ -436,6 +436,16 @@ fn a_partial_metadata_write_cannot_pass_as_a_landing() { table.row_mut(key).expect("row").receipt.roots ^= 1; assert!(table.replay_landed(&store, key, EXEC_DEFAULT).is_ok()); + // Torn: the outcome changed after the seal. Commit -> Prune would stop + // `next_version` landing the writes while every fold still matches. + table.row_mut(key).expect("row").receipt.outcome = Outcome::Prune; + assert!(matches!( + table.replay_landed(&store, key, EXEC_DEFAULT), + Err(Refusal::Torn(_)) + )); + table.row_mut(key).expect("row").receipt.outcome = Outcome::Commit; + assert!(table.replay_landed(&store, key, EXEC_DEFAULT).is_ok()); + // The landing replays without the pool. pool.free(slot).expect("free"); let rep = table