CB-WP-0006 T05: K9's assertion, K11's format, and the AM-11 suites

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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-01 10:50:52 +02:00
parent 5f7d9015d9
commit 98c6cd24c3
12 changed files with 638 additions and 19 deletions

View file

@ -1,6 +1,12 @@
//! cb-events — event envelope, append-only log, snapshots, canonical
//! serialization, and state hashing (GameKernel §2.4, K4, K7, K9K11).
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<E> {
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<E> {
events: Vec<Envelope<E>>,

View file

@ -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<u8> {
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<u8> {
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<Vec<Vec<u8>>, 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<Vec<Vec<u8>>, 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<Vec<u8>, 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<u8>,
}
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<Vec<Vec<u8>>, StoreError> {
parse(&self.bytes)
}
fn raw(&self) -> Result<Vec<u8>, 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<Path>) -> Result<Self, StoreError> {
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<Vec<Vec<u8>>, StoreError> {
parse(&self.raw()?)
}
fn raw(&self) -> Result<Vec<u8>, 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<S: LogStore>(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::<Vec<u8>>::new(),
"{label}: a new log must read back as zero records"
);
// Append order is read order.
let mut s = make();
let records: Vec<Vec<u8>> = 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<u8>> = 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 { .. })
));
}
}

View file

@ -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<R: KernelRng>(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<u32> = (0..32).map(|_| a.draw(97)).collect();
let seq_b: Vec<u32> = (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<u8> = (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() {

View file

@ -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** | <!-- fact:am4a_loc -->
| AM-4b dep weight, dev toolchain | ≤350,000 third-party lines | 317,021 (29 crates) | **met** | **yes** | <!-- fact:am4b_loc -->
| 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.142.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** | <!-- fact:pinned_total -->
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.
<!-- historical --> 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<R: KernelRng>(…)` that both are
driven through. `grep -rn conformance` over every `.rs` returns a single

View file

@ -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"

View file

@ -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<String, u8>` 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

View file

@ -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.

View file

@ -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

View file

@ -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"],