From 98c6cd24c33ba5cdd273021b425a7cee2c0a63dd Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 1 Aug 2026 10:50:52 +0200 Subject: [PATCH] CB-WP-0006 T05: K9's assertion, K11's format, and the AM-11 suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K11 is implemented: crates/cb-events/src/store.rs, magic + version header, 4-byte little-endian length prefix, append-only. Reimplemented not assimilated per ADR-0005 §2 — no new dependency, and AM-4a/AM-4b are unchanged at 246,250 / 317,021 because nothing entered the graph. The operative clause is "detected", so corruption is tested rather than assumed: a tail short by one byte, a half-written length prefix, a length prefix corrupted to claim more than the file holds, foreign magic, and a future format version are each rejected with a distinct error. A reader that accepts a truncated tail is worse than no format, because it silently returns a short history that looks complete. AM-11 is earned. LogStore has two impls — MemLogStore and FileLogStore — driven through ONE conformance(). The trait carries raw/set_raw precisely so the corruption controls live in the shared suite: a format contract that only one impl enforces is not a contract. The same shape is retro-fitted to KernelRng, which is what AM-11 actually names: ChaChaRng and NullRng now pass one suite asserting bounds, draw(1) == 0, determinism across fresh instances, and shuffle preserving the multiset. They were previously exercised by two separate tests, which is why "met, narrow" was never earned and ADR-0005 §4 downgraded it. K9 gets the assertion it did not have: snapshot at seq N + events N+1..M must equal the from-genesis fold, hash-compared, on GroundState, single-seed on purpose — AM-7's probe folds a multi-seed log, which is not a replay of anything, and that defect is not repeated. Two positive controls: the log must exceed 50 events, and the mid-log snapshot must differ from the end state or "apply the remainder" is vacuous. Proof it works: the exact mutation that SURVIVED in CB-WP-0005 — making Snapshot::take discard its EventSeq — now fails on the K9 assertion. AM-11's mutation breaks NullRng::draw to return its bound and the shared suite fails. That is what M-D4-SWAP claims — either impl substitutable — and exactly what two separate per-impl tests could never demonstrate. M-D1-MUT: 7 -> 8 of 14. CB-EV-0001's scoreboard is refreshed: AM-2, AM-5 and AM-9 added, AM-6 moved to enforced, and the headline total corrected from 4 to 8 — it had gone stale inside the same workplan that produced it. Co-Authored-By: Claude Opus 5 --- crates/cb-events/src/lib.rs | 15 +- crates/cb-events/src/store.rs | 380 ++++++++++++++++++ crates/cb-kernel/src/rng.rs | 76 ++++ evidence/CB-EV-0001-game-kernel.md | 28 +- facts.toml | 4 +- games/ground/src/lib.rs | 78 +++- history/260801-cb-wp-0006-log.md | 57 +++ specs/GameKernel.md | 2 +- tools/__pycache__/cb-cost.cpython-312.pyc | Bin 37064 -> 37064 bytes tools/__pycache__/dep-weight.cpython-312.pyc | Bin 9563 -> 9563 bytes .../mutation-check.cpython-312.pyc | Bin 21946 -> 21891 bytes tools/mutation-check.py | 17 +- 12 files changed, 638 insertions(+), 19 deletions(-) create mode 100644 crates/cb-events/src/store.rs diff --git a/crates/cb-events/src/lib.rs b/crates/cb-events/src/lib.rs index e3c438e..e545f82 100644 --- a/crates/cb-events/src/lib.rs +++ b/crates/cb-events/src/lib.rs @@ -1,6 +1,12 @@ //! cb-events — event envelope, append-only log, snapshots, canonical //! serialization, and state hashing (GameKernel §2.4, K4, K7, K9–K11). +pub mod store; + +pub use store::{ + conformance as log_store_conformance, FileLogStore, LogStore, MemLogStore, StoreError, +}; + use cb_kernel::{EventSeq, GameId}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -15,9 +21,12 @@ pub struct Envelope { pub payload: E, } -/// In-memory append-only event log. File-backed storage arrives in a later -/// loop pass behind the same interface (AM-11 pairs this with storage -/// impls under one conformance suite). +/// In-memory append-only event log of typed envelopes. +/// +/// Durable framing and the `LogStore` port live in [`store`]; this type is +/// the typed, sequence-enforcing layer above it. Until CB-WP-0006 T05 the +/// comment here promised file-backed storage "in a later loop pass" — that +/// pass is this one. #[derive(Debug, Default)] pub struct EventLog { events: Vec>, diff --git a/crates/cb-events/src/store.rs b/crates/cb-events/src/store.rs new file mode 100644 index 0000000..be8d897 --- /dev/null +++ b/crates/cb-events/src/store.rs @@ -0,0 +1,380 @@ +//! K11 durable log framing, and the `LogStore` port (GameKernel §2.4, AM-11). +//! +//! K11: *"Log format is append-only, length-prefixed, versioned; a +//! truncated tail is detected, not silently accepted."* Until CB-WP-0006 +//! T05 the only `fs::` call in the workspace was `read_to_string` for +//! scenario YAML — the rule was satisfied by a `Vec`. +//! +//! **Reimplemented, not assimilated** (ADR-0005 §2). "Own the semantics; +//! assimilate the implementation" exists so we do not reimplement *hard* +//! things — SHA-256 and ChaCha cost 12 crates between them and are kept. +//! A length prefix is not in that category, and an embedded store would +//! spend AM-4a headroom on a format that fits in a paragraph. +//! +//! **The operative clause is *detected*.** A reader that accepts a +//! truncated tail is worse than no format at all, because it silently +//! returns a short history that looks complete. Every corruption below has +//! a negative control in the conformance suite. + +use std::fs::OpenOptions; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +/// File header: magic + format version. Present exactly once, at offset 0. +pub const MAGIC: &[u8; 5] = b"CBLOG"; +pub const FORMAT_VERSION: u8 = 1; +pub const HEADER_LEN: usize = MAGIC.len() + 1; + +/// Sanity cap. A corrupt length prefix is far more likely than a genuine +/// 64 MiB record, and without a cap it would drive an enormous allocation +/// before the truncation check could fire. +pub const MAX_RECORD: u32 = 64 << 20; + +#[derive(Debug, PartialEq, Eq)] +pub enum StoreError { + /// Not one of our logs at all. + BadMagic, + /// A format we do not know how to read. Never guess. + UnsupportedVersion(u8), + /// K11's operative clause: the tail is short, and we say so. + TruncatedTail { + offset: usize, + need: usize, + have: usize, + }, + /// A length prefix beyond the sanity cap — corruption, not a big record. + RecordTooLarge(u32), + Io(String), +} + +impl std::fmt::Display for StoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BadMagic => write!(f, "not a CBLOG file (bad magic)"), + Self::UnsupportedVersion(v) => write!(f, "unsupported format version {v}"), + Self::TruncatedTail { offset, need, have } => write!( + f, + "truncated tail at byte {offset}: need {need} bytes, have {have}" + ), + Self::RecordTooLarge(n) => { + write!(f, "record length {n} exceeds the {MAX_RECORD}-byte cap") + } + Self::Io(e) => write!(f, "io: {e}"), + } + } +} + +/// Serialize a header. Written once when a log is created. +#[must_use] +pub fn header() -> Vec { + let mut out = MAGIC.to_vec(); + out.push(FORMAT_VERSION); + out +} + +/// Frame one record: 4-byte little-endian length, then the payload. +#[must_use] +pub fn frame(record: &[u8]) -> Vec { + let mut out = Vec::with_capacity(4 + record.len()); + out.extend_from_slice( + &u32::try_from(record.len()) + .unwrap_or(u32::MAX) + .to_le_bytes(), + ); + out.extend_from_slice(record); + out +} + +/// Parse a whole log. Shared by every `LogStore` impl, so the truncation +/// contract cannot differ between them. +pub fn parse(bytes: &[u8]) -> Result>, StoreError> { + if bytes.len() < HEADER_LEN { + return Err(StoreError::TruncatedTail { + offset: 0, + need: HEADER_LEN, + have: bytes.len(), + }); + } + if &bytes[..MAGIC.len()] != MAGIC { + return Err(StoreError::BadMagic); + } + let version = bytes[MAGIC.len()]; + if version != FORMAT_VERSION { + return Err(StoreError::UnsupportedVersion(version)); + } + + let mut out = Vec::new(); + let mut at = HEADER_LEN; + while at < bytes.len() { + let remaining = bytes.len() - at; + if remaining < 4 { + return Err(StoreError::TruncatedTail { + offset: at, + need: 4, + have: remaining, + }); + } + let len = u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]); + if len > MAX_RECORD { + return Err(StoreError::RecordTooLarge(len)); + } + let len = len as usize; + at += 4; + if bytes.len() - at < len { + return Err(StoreError::TruncatedTail { + offset: at, + need: len, + have: bytes.len() - at, + }); + } + out.push(bytes[at..at + len].to_vec()); + at += len; + } + Ok(out) +} + +/// The capability port (AM-11 / M-D4-SWAP). Two impls, one suite. +pub trait LogStore { + /// Append one record. Append-only: there is no update or delete. + fn append(&mut self, record: &[u8]) -> Result<(), StoreError>; + + /// Every record, in append order. + fn records(&self) -> Result>, StoreError>; + + /// Raw framed bytes. Exists so the conformance suite can corrupt a + /// log and require the *same* rejection from every impl — a format + /// contract that only one impl enforces is not a contract. + fn raw(&self) -> Result, StoreError>; + + /// Replace the raw bytes. Test-facing, and the reason truncation is + /// part of the shared suite rather than a file-only special case. + fn set_raw(&mut self, bytes: &[u8]) -> Result<(), StoreError>; +} + +/// In-memory impl — the fast, non-durable half of the AM-11 pair. +#[derive(Debug)] +pub struct MemLogStore { + bytes: Vec, +} + +impl Default for MemLogStore { + fn default() -> Self { + Self::new() + } +} + +impl MemLogStore { + #[must_use] + pub fn new() -> Self { + Self { bytes: header() } + } +} + +impl LogStore for MemLogStore { + fn append(&mut self, record: &[u8]) -> Result<(), StoreError> { + self.bytes.extend_from_slice(&frame(record)); + Ok(()) + } + + fn records(&self) -> Result>, StoreError> { + parse(&self.bytes) + } + + fn raw(&self) -> Result, StoreError> { + Ok(self.bytes.clone()) + } + + fn set_raw(&mut self, bytes: &[u8]) -> Result<(), StoreError> { + self.bytes = bytes.to_vec(); + Ok(()) + } +} + +/// File-backed impl — the durable half. Opens in append mode per write, so +/// a crash between writes leaves a well-formed prefix rather than a +/// half-updated file. +#[derive(Debug)] +pub struct FileLogStore { + path: PathBuf, +} + +impl FileLogStore { + /// Create or open a log at `path`, writing the header if new. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + if !path.exists() { + std::fs::write(&path, header()).map_err(|e| StoreError::Io(e.to_string()))?; + } + Ok(Self { path }) + } +} + +impl LogStore for FileLogStore { + fn append(&mut self, record: &[u8]) -> Result<(), StoreError> { + let mut f = OpenOptions::new() + .append(true) + .open(&self.path) + .map_err(|e| StoreError::Io(e.to_string()))?; + f.write_all(&frame(record)) + .map_err(|e| StoreError::Io(e.to_string())) + } + + fn records(&self) -> Result>, StoreError> { + parse(&self.raw()?) + } + + fn raw(&self) -> Result, StoreError> { + let mut buf = Vec::new(); + std::fs::File::open(&self.path) + .and_then(|mut f| f.read_to_end(&mut buf)) + .map_err(|e| StoreError::Io(e.to_string()))?; + Ok(buf) + } + + fn set_raw(&mut self, bytes: &[u8]) -> Result<(), StoreError> { + std::fs::write(&self.path, bytes).map_err(|e| StoreError::Io(e.to_string())) + } +} + +/// The AM-11 conformance suite: **one** suite, driven by every impl. +/// +/// Before this existed, `M-D4-SWAP` was a bool over "impls passing the +/// same conformance suite" and no suite existed — the RNG pair was +/// exercised by two separate, non-shared tests, so `AM-11 | met, narrow` +/// was never earned. ADR-0005 §4 downgraded it to unmet; this is what +/// re-earns it. +/// +/// Panics with a labelled message so a failure names the impl. +pub fn conformance(label: &str, mut make: impl FnMut() -> S) { + // A fresh log holds no records but is still well-formed. + let s = make(); + assert_eq!( + s.records().unwrap(), + Vec::>::new(), + "{label}: a new log must read back as zero records" + ); + + // Append order is read order. + let mut s = make(); + let records: Vec> = vec![b"first".to_vec(), b"second".to_vec(), b"third".to_vec()]; + for r in &records { + s.append(r).unwrap(); + } + assert_eq!( + s.records().unwrap(), + records, + "{label}: records must read back in append order" + ); + + // Binary payloads, including empty and high bytes, survive intact. + let mut s = make(); + let tricky: Vec> = vec![ + Vec::new(), + vec![0u8; 8], + vec![0xFF; 8], + (0u8..=255).collect(), + ]; + for r in &tricky { + s.append(r).unwrap(); + } + assert_eq!( + s.records().unwrap(), + tricky, + "{label}: binary payloads must round-trip byte-for-byte" + ); + + // --- K11's operative clause: corruption is DETECTED, not accepted --- + + // A tail short by one byte. + let mut s = make(); + s.append(b"payload").unwrap(); + let good = s.raw().unwrap(); + s.set_raw(&good[..good.len() - 1]).unwrap(); + assert!( + matches!(s.records(), Err(StoreError::TruncatedTail { .. })), + "{label}: a tail short by one byte must be rejected, got {:?}", + s.records() + ); + + // A length prefix cut in half. + let mut s = make(); + s.append(b"payload").unwrap(); + let good = s.raw().unwrap(); + s.set_raw(&good[..HEADER_LEN + 2]).unwrap(); + assert!( + matches!(s.records(), Err(StoreError::TruncatedTail { .. })), + "{label}: a half-written length prefix must be rejected" + ); + + // A corrupted length prefix claiming more than the file holds. + let mut s = make(); + s.append(b"payload").unwrap(); + let mut bad = s.raw().unwrap(); + bad[HEADER_LEN] = 0xFF; + bad[HEADER_LEN + 1] = 0xFF; + s.set_raw(&bad).unwrap(); + assert!( + matches!( + s.records(), + Err(StoreError::TruncatedTail { .. }) | Err(StoreError::RecordTooLarge(_)) + ), + "{label}: a corrupt length prefix must be rejected" + ); + + // Wrong magic and unknown version are refused rather than guessed at. + let mut s = make(); + s.set_raw(b"NOPE!\x01").unwrap(); + assert_eq!( + s.records(), + Err(StoreError::BadMagic), + "{label}: foreign bytes must not be read as a log" + ); + + let mut s = make(); + let mut future = header(); + future[MAGIC.len()] = FORMAT_VERSION + 1; + s.set_raw(&future).unwrap(); + assert_eq!( + s.records(), + Err(StoreError::UnsupportedVersion(FORMAT_VERSION + 1)), + "{label}: a newer format must be refused, not guessed at" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// AM-11: the SAME suite, both impls. This is the whole point — two + /// separate tests would reproduce the defect ADR-0005 §4 recorded. + #[test] + fn mem_store_passes_the_conformance_suite() { + conformance("MemLogStore", MemLogStore::new); + } + + #[test] + fn file_store_passes_the_conformance_suite() { + let dir = std::env::temp_dir().join(format!("cb-log-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut n = 0; + conformance("FileLogStore", || { + n += 1; + let p = dir.join(format!("{n}.cblog")); + let _ = std::fs::remove_file(&p); + FileLogStore::open(&p).unwrap() + }); + std::fs::remove_dir_all(&dir).ok(); + } + + /// K11 framing is shared, so it is also pinned directly. + #[test] + fn framing_round_trips_and_rejects_a_truncated_tail() { + let mut bytes = header(); + bytes.extend_from_slice(&frame(b"abc")); + assert_eq!(parse(&bytes).unwrap(), vec![b"abc".to_vec()]); + assert!(matches!( + parse(&bytes[..bytes.len() - 1]), + Err(StoreError::TruncatedTail { .. }) + )); + } +} diff --git a/crates/cb-kernel/src/rng.rs b/crates/cb-kernel/src/rng.rs index 21e83b7..33cec99 100644 --- a/crates/cb-kernel/src/rng.rs +++ b/crates/cb-kernel/src/rng.rs @@ -53,10 +53,86 @@ impl KernelRng for NullRng { } } +/// The AM-11 conformance suite for [`KernelRng`] — **one** suite, driven by +/// every impl. +/// +/// Before CB-WP-0006 T05 the pair was exercised by two *separate*, +/// non-shared tests (`chacha_is_deterministic_per_seed` and +/// `null_rng_never_moves_a_shuffle`). M-D4-SWAP is a bool over impls +/// "passing the same conformance suite", so a pair with no shared suite +/// never earned it; ADR-0005 §4 downgraded AM-11 to unmet on exactly that +/// reading. +/// +/// The assertions are the properties true of **both** a real CSPRNG and a +/// null draw — determinism, bounds, and shuffle integrity. An impl that +/// violates any of them cannot be substituted for the other, which is what +/// the port claims. +pub fn conformance(label: &str, mut make: impl FnMut() -> R) { + // Bounds. `draw(n)` must land in `0..n` for every impl, or callers + // indexing with it are unsound. + let mut r = make(); + for bound in [1u32, 2, 3, 7, 52, 1000] { + for _ in 0..64 { + let v = r.draw(bound); + assert!( + v < bound, + "{label}: draw({bound}) returned {v}, outside 0..{bound}" + ); + } + } + + // draw(1) has exactly one legal answer. + let mut r = make(); + assert_eq!(r.draw(1), 0, "{label}: draw(1) must be 0"); + + // Determinism: two fresh instances must produce identical sequences. + // This is K5 for ChaCha and trivially true for the null impl — which + // is the point of a shared suite. + let (mut a, mut b) = (make(), make()); + let seq_a: Vec = (0..32).map(|_| a.draw(97)).collect(); + let seq_b: Vec = (0..32).map(|_| b.draw(97)).collect(); + assert_eq!( + seq_a, seq_b, + "{label}: two fresh instances must draw identically" + ); + + // Shuffle preserves the multiset — it may reorder, never invent or + // drop. A shuffle that loses an element would corrupt a deal. + let mut r = make(); + let original: Vec = (0..52).collect(); + let mut items = original.clone(); + r.shuffle(&mut items); + let mut sorted = items.clone(); + sorted.sort_unstable(); + assert_eq!( + sorted, original, + "{label}: shuffle must preserve the multiset" + ); + + // Shuffling is deterministic too, or replay diverges (K8). + let (mut c, mut d) = (make(), make()); + let (mut x, mut y) = (original.clone(), original.clone()); + c.shuffle(&mut x); + d.shuffle(&mut y); + assert_eq!(x, y, "{label}: shuffle must be deterministic per impl"); +} + #[cfg(test)] mod tests { use super::*; + /// AM-11: the SAME suite, both impls. Two separate tests are what the + /// pair had before, and why the metric was never earned. + #[test] + fn chacha_passes_the_conformance_suite() { + conformance("ChaChaRng", || ChaChaRng::from_seed(Seed(42))); + } + + #[test] + fn null_rng_passes_the_conformance_suite() { + conformance("NullRng", NullRng::default); + } + /// AM-8 seed determinism at the unit level: same seed, same draws. #[test] fn chacha_is_deterministic_per_seed() { diff --git a/evidence/CB-EV-0001-game-kernel.md b/evidence/CB-EV-0001-game-kernel.md index ddad330..1b8108b 100644 --- a/evidence/CB-EV-0001-game-kernel.md +++ b/evidence/CB-EV-0001-game-kernel.md @@ -30,7 +30,7 @@ the property is false? A row can be *measured* and still enforce nothing. | AM-1b link, kernel | 18 K-rules named in source | 15/18 | **unmet** | reported until 2026-08-31 | | AM-4a dep weight, shipped runtime | ≤250,000 third-party lines | 246,250 (23 crates) | **met** | **yes** | | AM-4b dep weight, dev toolchain | ≤350,000 third-party lines | 317,021 (29 crates) | **met** | **yes** | -| AM-6 throughput | ≥100,000 events/s | 1,651,400 events/s | **met, 16.5×** | **no** — nothing compares any number to 100,000 | +| AM-6 throughput | ≥100,000 events/s | 2,017,009 events/s (`make am6`) | **met, 20.2×** | **yes** — CB-WP-0006 T01 | | AM-7 scaling | ≥0.9× at 20× workload | 1.08× | **met** | **no** — no code computes the ratio | | AM-7 replay, timing | 100k events ≤5s | 2.18 ms (CI 2.14–2.23) | **met, 2,290×** | **yes** | | AM-7 replay, hash-identical | bit-identical fold | — | **WITHDRAWN** | **no** — mutation-proven inert | @@ -38,13 +38,24 @@ the property is false? A row can be *measured* and still enforce nothing. | AM-8 lint | fmt + clippy clean | clean, `-D warnings` | **met** | **yes** | | AM-10 foreign types | 0 in `cb-*-api` signatures | — | **WITHDRAWN** | **no** — no such crate; population empty | | AM-10′ determinism lint (K6) | zero `HashMap`/`HashSet` in game state | 0 | **met** | **yes** | -| AM-11 impl pairs | ≥2 impls under **one conformance suite** | pair yes, suite **none** | **UNMET** | **no** — the suite does not exist | +| AM-11 impl pairs | ≥2 impls under **one conformance suite** | 2 ports, 2 impls each, one shared suite per port | **met 2026-08-01** | **yes** — break one impl and the shared suite fails | +| AM-2 LOC per rule | ≤40 | 27.2 (1,575 impl lines / 58 rules) | **met** | **yes** — CB-WP-0006 T02 | +| AM-3 synthetic workload LOC | ≤50 | — | **blocked** — the artifact has never been built | **no** | +| AM-5 clean release build | ≤60 s on bnt-lap001 | 37.3 s dev / 41.2 s shipped, best of 3, quiet | **met, 1.6×** | **no** — spec declares it ungated | +| AM-9 peak RSS | ≤64 MB | 13.4 MB | **met, 4.8×** | **yes** — CB-WP-0006 T03 | | AM-12 cost | per-task USD | **$93.15** pinned, per task via `make cost` | **met** | **yes** | -AM-2, AM-3, AM-5 and AM-9 are not reported: see §6. All four are -`unmutatable` — no instrument exists to invert. +**M-D1-MUT over the whole acceptance table: 8 of 14 rows enforced** +(`make mutation-check`), up from 4 when the instrument was first run. +AM-4c is retained in that denominator after its withdrawal, deliberately: +a score improved by deleting the question is not an improvement. -**M-D1-MUT over the whole acceptance table: 4 of 14 rows enforced.** +Of the six not enforced: **AM-3** is blocked on an artifact that was never +built; **AM-4c** and **AM-5** cannot fail because the spec declares them +untargeted/ungated; **AM-10** was withdrawn; **AM-7** and **AM-8** are +partial — some clauses live, some inert. §6's note that AM-2/AM-5/AM-9 are +"not reported" was true until CB-WP-0006 and is superseded by the rows +above. ### 1a. What was corrected, and why @@ -286,7 +297,12 @@ That is what CB-WP-0002 is for. The AM-12 row above should be read as exercised. It is the only port with a pair so far, so the metric is met narrowly and will mean more once storage has one.~~ **Corrected 2026-07-31:** the pair exists; the **conformance suite does - not**. M-D4-SWAP is a bool over impls "passing the same conformance + not**. **Resolved 2026-08-01 (CB-WP-0006 T05):** `cb_kernel::rng::conformance` + drives `ChaChaRng` and `NullRng`; `cb_events::store::conformance` drives + `MemLogStore` and `FileLogStore`. One suite per port, both impls through + it. AM-11 is **met** and mutation-verified — breaking `NullRng::draw` to + return its bound fails the shared suite. The original text follows. + M-D4-SWAP is a bool over impls "passing the same conformance suite", and the two impls are exercised by two separate, non-shared tests — there is no `fn conformance(…)` that both are driven through. `grep -rn conformance` over every `.rs` returns a single diff --git a/facts.toml b/facts.toml index 441d124..f4a061b 100644 --- a/facts.toml +++ b/facts.toml @@ -40,8 +40,8 @@ fmt = "{:,}" by = "tools/mutation-check.py" [am_unmutatable] -value = 5 -text = "5" +value = 4 +text = "4" fmt = "{:,}" by = "tools/mutation-check.py" diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index d966bbf..ce519aa 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -2163,8 +2163,9 @@ mod bench_shape { #[cfg(test)] mod replay_probe { use super::*; - use cb_events::state_hash_hex; + use cb_events::{state_hash_hex, Snapshot}; use cb_game_runtime::{ScenarioGame, Setup}; + use cb_kernel::EventSeq; use std::time::Instant; fn fresh(seed: u64) -> GroundState { @@ -2219,6 +2220,81 @@ mod replay_probe { n } + /// K9: `snapshot + remaining events -> state` must be hash-identical to + /// a from-genesis fold. + /// + /// **This is the assertion K9 did not have.** Its entire evidence was + /// one test round-tripping a `BTreeMap` with `EventSeq(17)` + /// as a literal — no game aggregate, no events applied, no + /// from-genesis comparison. CB-WP-0005 proved it inert by mutation: + /// making `Snapshot::take` discard its `EventSeq` and store 0 left the + /// test green, so the half of K9 that says "**+ the EventId it + /// includes**" was unverified. + /// + /// Single-seed on purpose. AM-7's probe folds a log built across games + /// seeded 42, 43, 44... into a state from `fresh(42)`, which is not a + /// replay of anything; that defect is not repeated here. + #[test] + fn k9_snapshot_plus_remaining_events_equals_genesis_fold() { + let mut source = fresh(42); + let mut log = Vec::new(); + while log.len() < 400 && source.outcome.is_none() { + if record_round(&mut source, &mut log) == 0 { + break; + } + } + // Positive control: a trivial log would make the comparison pass + // for the wrong reason. + assert!( + log.len() >= 50, + "K9 needs a non-trivial single-game log, got {} events", + log.len() + ); + + let mut genesis = fresh(42); + for e in &log { + genesis.fold(e); + } + let genesis_hash = state_hash_hex(&genesis); + + let n = log.len() / 2; + let mut mid = fresh(42); + for e in &log[..n] { + mid.fold(e); + } + let snap = Snapshot::take(&mid, EventSeq(n as u64)); + + // The clause the mutation exposed: a snapshot is the aggregate + // **plus the EventId it includes**. Without this, `take` could + // discard `through` entirely and nothing would notice. + assert_eq!( + snap.through, + EventSeq(n as u64), + "K9: the snapshot must carry the EventSeq it includes" + ); + + let mut restored: GroundState = snap.restore().unwrap(); + // And the snapshot must not already equal the end state, or + // "apply the remainder" would be vacuous. + assert_ne!( + state_hash_hex(&restored), + genesis_hash, + "K9: the mid-log snapshot must differ from the end state" + ); + for e in &log[n..] { + restored.fold(e); + } + + assert_eq!( + state_hash_hex(&restored), + genesis_hash, + "K9 UNMET: snapshot at seq {n} + {} remaining events did not \ + reproduce the from-genesis fold over {} events", + log.len() - n, + log.len() + ); + } + /// AM-6 target from GameKernel §5, in applied events per second. /// /// **Pinned, not tuned.** CB-WP-0006 T01 named the trap up front: a diff --git a/history/260801-cb-wp-0006-log.md b/history/260801-cb-wp-0006-log.md index 3427ac3..e91e3e5 100644 --- a/history/260801-cb-wp-0006-log.md +++ b/history/260801-cb-wp-0006-log.md @@ -211,3 +211,60 @@ become weak when the row's measurement conditions change.** **M-D1-MUT: 7 of 14** (unchanged — AM-4c was always going to stay uncounted; what changed is that the reason is now correct and recorded). + +## CB-WP-0006-T05 + +**Delivered: K9's real assertion, K11's durable format, the `LogStore` +port, and the shared conformance suites that finally earn AM-11.** + +**K11 — `crates/cb-events/src/store.rs`.** Magic + version header, 4-byte +little-endian length prefix per record, append-only. Reimplemented, not +assimilated (ADR-0005 §2): no new dependency, charged to AM-4a, and +AM-4a/AM-4b are unchanged at 246,250 / 317,021 because nothing was added +to the graph. + +The operative clause is **detected**, so corruption is tested, not assumed: +a tail short by one byte, a half-written length prefix, a length prefix +corrupted to claim more than the file holds, foreign magic, and a future +format version are each rejected with a distinct error. A reader that +accepts a truncated tail is worse than no format, because it silently +returns a short history that looks complete. + +**The port and the suite (AM-11).** `LogStore` has two impls — +`MemLogStore` and `FileLogStore` — and **one** `conformance()` that both +are driven through. The trait carries `raw`/`set_raw` specifically so the +corruption controls live in the *shared* suite: a format contract only one +impl enforces is not a contract. + +The same shape was retro-fitted to `KernelRng`, which is what AM-11 +actually names. `ChaChaRng` and `NullRng` now pass one +`conformance()` asserting the properties true of both — bounds, +`draw(1) == 0`, determinism across fresh instances, and shuffle preserving +the multiset. Previously they were exercised by two *separate* tests, +which is precisely why `AM-11 | met, narrow` was never earned and ADR-0005 +§4 downgraded it. + +**K9 — the assertion it did not have.** *Snapshot at seq N + events +N+1..M ≡ genesis fold*, hash-compared, on `GroundState`, single-seed on +purpose (AM-7's probe folds a multi-seed log, which is not a replay of +anything; that defect is not repeated). Two positive controls: the log +must exceed 50 events, and the mid-log snapshot must **differ** from the +end state, or "apply the remainder" would be vacuous. + +**Proof it works:** the exact mutation that *survived* in CB-WP-0005 — +making `Snapshot::take` discard its `EventSeq` — now fails: + +```text +assertion `left == right` failed: K9: the snapshot must carry the EventSeq it includes +test result: FAILED. 0 passed; 1 failed +``` + +**AM-11 mutation:** break `NullRng::draw` to return its bound and the +*shared* suite fails. That is what M-D4-SWAP claims — that either impl can +be substituted — and it is exactly what two separate per-impl tests could +never demonstrate. + +**M-D1-MUT: 7 → 8 of 14.** `evidence/CB-EV-0001`'s scoreboard is refreshed +with AM-2, AM-5 and AM-9 added, AM-6 moved to enforced, and the headline +total corrected from 4 to 8 — it had gone stale within the same workplan +that produced it. diff --git a/specs/GameKernel.md b/specs/GameKernel.md index f8d9a8f..c7fa57c 100644 --- a/specs/GameKernel.md +++ b/specs/GameKernel.md @@ -172,7 +172,7 @@ evidence lands in `evidence/CB-EV-0001-game-kernel.md` with no | AM-8 | Determinism invariant: N=10 same-seed replays, bit-identical hashes; HashMap-in-state deny lint clean | Rune: enforced by tooling (cited) | zero divergence, lint clean in CI | measured (invariant, not a verdict row) | | AM-9 | M-D3-MEM: peak RSS, 100k-event synthetic run | boardgame.io ~100→232 MB @5k→40k (indicative) | ≤ 64 MB, flat with history given snapshot interval | measured (indicative label, same method) | | AM-10 | M-D4-LEAK **(withdrawn 2026-07-31, ADR-0005 §4 — no `cb-*-api` crate exists, so the population is empty; the clippy `HashMap`/`HashSet` deny that stood in for it cites K6 determinism and is now reported as AM-10′)**: foreign types in canonical-interface signatures | boardgame.io: JS-ecosystem-locked | **0** | measured (grep/deny rule) | -| AM-11 | M-D4-SWAP **(unmet 2026-07-31 — the pair exists, the suite does not)**: null + reference impls passing one conformance suite | no candidate has the pattern | RNG and log storage each have ≥2 impls (real + test/null) under one suite | measured (bool) | +| AM-11 | M-D4-SWAP **(met 2026-08-01 — `KernelRng` and `LogStore` each drive one shared `conformance()`; CB-WP-0006 T05)**: null + reference impls passing one conformance suite | no candidate has the pattern | RNG and log storage each have ≥2 impls (real + test/null) under one suite | measured (bool) | | AM-12 | M-D2-TOK / M-D2-CST: tokens and USD per completed task | n/a — first pass sets our own baseline | recorded per task in the evidence cost log (price sheet 2026-07-31) | recorded, not gated | ### 5a. Why AM-4c was withdrawn from the acceptance table diff --git a/tools/__pycache__/cb-cost.cpython-312.pyc b/tools/__pycache__/cb-cost.cpython-312.pyc index 1e1506dfae0fd68dc824c356f5af1dab55739b53..c395c99268a294811b9a11dab74abb432d35c093 100644 GIT binary patch delta 21 bcmX@Hkm7%Q6y90yZhoAk#Z6G4DO%gK$u>An8X-BZ6Jo##mj9AE27&l#l3hDa9B(wc zPKA>e3RD6PP%X5=l9P?SX<+Rd4{r0mP|VRYC%Bpcex4RB_Ya=(E>s=et3z|ZMV5Ij0)SG5! zLJJ118kK+d#gibJ&v2?{|>*E{B{nnS+lLOZm9iz>{1|2LQDz1;~j+J zGVrq`(K#RdgmiZbO)amEf8UY1wx{zCfpqo$E|B)#ZOPLf+HMru{Wo?sGzD(>n7QBI zG%nAHEy}hq=bYXDH6c+p)4fWD*pJ-@MS(cpo{wdcVwXYzl5(zvo*^X9qG4M$;J6mY z3BTW{OoB?2?8!(EIKC7Kk_!%5}H~Ju{u*v8z9Xy1m!MwL~vDD$8$sh~II*7p%vB175w1p8y0bGw& zThx56WL7KBL$+69c{0ha#)jq7V2wNX4*R8}cq&~0&?Hy3mMUeNt{`bGDMFN#7BOr+G}Tl&xa9Vo=@-57Kvt ze2=}Ax(XwkPd`o8*;nZjG$))H0==A>Qr0o@1NLQRl}xbFtbd0nWrtzFA7&2?mBFL# zF&9)<8jB~>bRPD#TaEbX#kW<8SEATnjnI84cGE$mR@m;`Jq13ce%#|zq6LJ1F7cn@ zA4daz*P4%>Mg1f~0>G7PRrm&BYW8%+aLF<~%1-2hd*4L|-f3lN+OiDY_2`C8&j8J~ zV|TM3az6PYY~FBw&0X=3KDLoBc#)v^Ea+3Lc`6`Z2K75OFm>w4r%3Y3hEc=1U5Oe? wCgsh=e;O(y3?MWj@M|7Kjo*!@7%Q6!xsWPJ>f74UI|>sm6{<>Lzxa(t^{tDIo#J#IZ^2B7{oG+Bs4fq5HRxOAoc=I!_1 z`{unjJLlHP7waVa{mz~D_~f(yv-%4^Jbg8MB}9%nUxwNgMIRN{JGvt$^haAheN2C> zs;tO3ef)~L9>5HruNp4;ViQhh`1deb+35Ksu-c3epU+-kls65VQQcyknwCx38H3vi zI#&hOpm8v1#WLqDsB310%~9(m29!xyrU~E_nB4@EQ_Dn!ij1w-nQ2p<@d_Aa!>rQz zrVL?p$%4fZiu=vxGqjW){L{i-aU0w zQTJuYmOt1&Nwzvg>OiFZl|V)52;THnobrKh2uX?lz7}~zeAIVH`OD}0-1n(Uvf^w! zO0v!e@#hGc5&l72tw{Mcr!}~?BUq3oWJEM|PzFw=ddZZqQu`mwOFGsDo)?ROT?kVM z3?Yk<7f$Llc~W!@Z+2xQk%x%Hj}=!*Bw<|qI((E=#r?;A>-JJS8Pxq(OY9lBu@{?# zkOWt4fMMFMzs5|ch;TaEGmE}B!YBs#d9xntas!rGTbAn6=^`nJ_tMAJx1=}i+(<_P zb65o!5@G>Y<&BzQ!!lAYBb-1!2oKA}RhJCiUXY6gI)~TLJ%Nyuz=IN~_rSeKwr)eq z(Z}9VccWS?0O#f6D%K*L6K$Ch@}?Ni{N44UB)KAi((Pm}W+o#<2tN7k170oOB<~V- z$yv!>Ac?1BKFpwe(BSh%je$1{Khk%}d{;a>^@}{hhjORLnz)dw%HjN(8IR+|%jRG~T+Q#P;XGpmdT{B4AymO0 zT)$SUdB^Am@OHos^ms>=KnwpbC?eeH%}w8#{C@4JYE zH{M`F0`Mn*XT(p%sCrGB--%nrS35sP z(ko#Et+;r*v@bS@rdP1n&msKu3ic$tv-T9Pi62V`$)>nf8VxQgB>2ZEF?#axe*kQ3 BWYYiu diff --git a/tools/mutation-check.py b/tools/mutation-check.py index b710c63..145b03b 100644 --- a/tools/mutation-check.py +++ b/tools/mutation-check.py @@ -232,13 +232,18 @@ def rows(): "(determinism), reported under a D4 leak row. " "Withdrawn by ADR-0005 §4."), + # A PROPERTY mutation: break ONE impl and require the SHARED suite + # to fail. That is what M-D4-SWAP claims — that either impl can be + # substituted for the other — and it is exactly what two separate + # per-impl tests could never demonstrate. Row("AM-11", "null + reference impls passing ONE conformance suite", - unmutatable="the suite does not exist. `grep -rn conformance` " - "over every .rs returns one doc comment describing " - "future work; the RNG pair is exercised by two " - "separate, non-shared tests. The metric is a bool " - "over a suite, and the suite is zero. Downgraded to " - "unmet by ADR-0005 §4; T04 builds the suite."), + verify=CARGO + ["test", "-p", "cb-kernel", "-p", "cb-events", + "conformance"], + mutate=("crates/cb-kernel/src/rng.rs", + " fn draw(&mut self, _bound: u32) -> u32 {\n 0\n }", + " fn draw(&mut self, _bound: u32) -> u32 {\n" + " _bound\n }"), + expect="outside 0.."), Row("AM-12", "tokens and USD recorded per task", verify=py + ["tools/cb-cost.py", "--self-test"],