clay-borg/tools/mutation-check.py
tegwick 1edadac9a2 CB-WP-0006 T06: K10 replay bundles, --replay, and AM-7 re-earned
INTENT design decision 8 of 10, unimplemented for six passes. cb-sim had
no flag parsing at all, so --replay had nowhere to go.

The bundle is manifest + commands.log + initial.snapshot + expected.yaml,
dev-only behind the scenarios feature and charged to AM-4b. The command
stream goes through the K11 framing built in T05, so a truncated bundle is
detected rather than replayed short — the two tasks compose rather than
duplicating.

The reviewer's D2 correction was real: this was not "a directory of four
files". Pass carried only the end state, RunOutcome::Failed was a
formatted String, and scenario.rs created an EventLog, appended to it and
never read it. All three had to change.

The first round trip failed to reproduce, and the cause is worth keeping:
state_hash_hex over a serde_json::Value is a different canonical form than
over the typed aggregate — Value's map is key-sorted, a struct serializes
in declaration order. The bundle was written with one basis and verified
with the other. A round trip written to recompute its own comparison value
would have PASSED this bug; it failed because the recorded hash came from
the producing process, which is control 2's entire purpose.

make replay-test implements ADR-0005 §6's four controls, 14/14: a
committed deliberately-failing fixture outside the corpus with covers: []
so it neither fails `make sim` nor inflates AM-1; a tampered recorded hash
must fail; a log short by one byte and a corrupted length prefix must be
rejected; and a mutated manifest seed must fail — which bites only because
replay re-derives the initial state from seed+setup and checks it against
the recorded snapshot, since restoring from the snapshot alone would leave
the seed inert. Plus a control on the controls: the bundle must still
replay after every mutation is reverted.

AM-7's hash-identical clause is re-earned. The probe records a hash per
per-game segment and replays each from its own genesis; folding from the
wrong seed now fails. That is the clause ADR-0005 §4 withdrew as
mutation-proven inert. The scaling >= 0.9x clause is still unenforced, so
AM-7 stays PARTIAL — reported, not rounded up.

Kernel coverage 15/18 -> 16/18. facts-check immediately caught the spec's
copy of that number going stale, on a number that moved the same hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:05:37 +02:00

474 lines
22 KiB
Python

#!/usr/bin/env python3
"""M-D1-MUT: does anything fail when an acceptance row's property is false?
CB-WP-0005 T02, per ADR-0005 §1. Every other instrument in this project
counts **names** — M-D1-COV counts `covers:` tags, M-D1-LNK counts rule-ID
strings. Both answer "is this rule mentioned?" Neither answers "does
anything fail if it is violated?"
Four of the seven defects found by CB-RES-0004 were named in the source
and inert. Two were mutation-proven: AM-7's `hash-identical` clause
survives folding a 100k-event log from an unrelated genesis state, and
K9's `through` field survives `Snapshot::take` discarding it.
So: for each acceptance row in `specs/GameKernel.md` §5, invert the
property and require the verifying command to go **red**.
adapted:mutation-testing — the denominator is acceptance rows, not
source lines. Exhaustive tools (cargo-mutants, Stryker, PIT) score a
kill rate over lines; that is slow and measures something we do not
claim. Our claims are the AM-* rows, so those are the population.
## Verdicts
red baseline green, mutant red — the row is enforced
SURVIVED baseline green, mutant ALSO green — the row asserts
nothing; this is the defect being hunted
unmutatable no property to invert, with a stated reason — **counts
against** the metric (ADR-0005 §1). A row nobody can
invert asserts nothing.
inconclusive baseline already red, so "mutant red" would prove nothing
## Positive controls
A mutation harness that silently fails to apply its mutation reports every
row as green-baseline/green-mutant and looks thorough. So each run
asserts, per row: the file content actually changed; the baseline command
passed **before** mutating; and the file was restored afterwards. This
project has seven harness-does-nothing instances and one was found in
`rule-coverage.py` an hour ago.
Usage:
python3 tools/mutation-check.py # run every row
python3 tools/mutation-check.py --row AM-1
python3 tools/mutation-check.py --self-test
"""
import argparse
import os
import subprocess
import sys
from repo import ROOT, cargo_env, enter_root
CARGO = ["cargo"]
class Row:
"""One acceptance row and the mutation that must break it."""
def __init__(self, id, claim, verify=None, mutate=None, unmutatable=None,
clauses=None, expect=None):
self.id = id
self.claim = claim
self.verify = verify # command that must be green, then red
self.mutate = mutate # (relpath, old, new)
self.unmutatable = unmutatable
# CB-WP-0005 T08 / the FA class: a mutation is only evidence if it
# fails FOR ITS STATED REASON. A no-op mutation produces no failure
# at all, and would otherwise be scored as a genuine SURVIVED —
# publishing the claim that working code is broken. When `expect`
# is set, the mutant's output must contain it.
self.expect = expect
# A row with several stated clauses is red only if EVERY clause has
# a mutation that goes red. AM-7 is the reason this exists.
self.clauses = clauses or []
def rows():
"""The acceptance table, with a mutation or a stated reason for none.
Ordered as in specs/GameKernel.md §5. AM-4 splits into a/b/c, so there
are **14** rows, not the twelve ADR-0005 asserted — see the
correction note in the report.
"""
py = [sys.executable]
return [
Row("AM-1", "100% of GR-rules covered by >=1 passing scenario",
verify=py + ["tools/rule-coverage.py"],
mutate=("scenarios/ground/gr-r06-round-resolve.yaml",
"GR-R06, ", "")),
# A PROPERTY mutation: inflate the implementation past the ratio.
# 58 rules x 40 = 2,320; the impl is 1,575, so ~800 lines of filler
# crosses it. Raising the threshold instead would only prove the
# comparison runs.
Row("AM-2", "<= 40 spec lines per rule in games/ground",
verify=py + ["tools/size-metrics.py"],
mutate=("games/ground/src/lib.rs",
"impl Aggregate for GroundState {",
"fn _am2_filler() {\n" + " let _x = 0;\n" * 800
+ "}\n\nimpl Aggregate for GroundState {"),
expect="FAIL target <= 40"),
Row("AM-3", "synthetic workload definition <= 50 LOC",
unmutatable="BLOCKED, not uninstrumented — the distinction "
"matters. `make size-metrics` ships the measurement "
"(a marker-delimited region) and reports the row "
"blocked, because the artifact it measures has never "
"been built: games/ contains only ground, and "
"benches/synthetic.rs drives GROUND rather than "
"defining a synthetic game. The baseline is a "
"declarative 3p commit/reveal game object (~36 LOC, "
"boardgame.io); measuring GROUND's 1,575 impl lines "
"against it would compare two different games and "
"call the difference a D1 result. Still counts "
"against M-D1-MUT (ADR-0005 §1) — a row that cannot "
"fail asserts nothing, however good the reason."),
Row("AM-4a", "third-party LOC, shipped runtime <= 250,000",
verify=py + ["tools/dep-weight.py"],
mutate=("tools/dep-weight.py",
'"shipped-runtime": 250_000,', '"shipped-runtime": 1_000,')),
Row("AM-4b", "third-party LOC, dev toolchain <= 350,000",
verify=py + ["tools/dep-weight.py"],
mutate=("tools/dep-weight.py",
'"dev-toolchain": 350_000,', '"dev-toolchain": 1_000,')),
# Deliberately RETAINED in this denominator after its withdrawal
# from the acceptance table. Dropping it would move M-D1-MUT from
# 7/14 to 7/13 without enforcing anything — a score improved by
# deleting the question.
Row("AM-4c", "own source per third-party 100k lines",
unmutatable="WITHDRAWN from the acceptance table 2026-08-01 "
"(CB-WP-0006 T04, GameKernel §5a) and retained here "
"as a diagnostic. The ratio has no monotone better "
"direction — rising can mean owning semantics or "
"reimplementing what should be assimilated; falling "
"can mean leverage or dependency bloat — so it "
"cannot carry a threshold. It is also redundant: "
"AM-4a/AM-4b bound the denominator and AM-2 bounds "
"own-source density. Kept in this denominator on "
"purpose, so the metric cannot be improved by "
"deleting rows."),
Row("AM-5", "clean release build <= 60 s",
unmutatable="now RECORDED (`make build-time`) and **met**: "
"37.3 s dev toolchain, 41.2 s shipped runtime, best "
"of 3 on a quiet machine, against a 60 s target on "
"bnt-lap001 (1.6x headroom). An earlier reading of "
"87.0 s was taken under contention and was wrong. "
"Still counts "
"against M-D1-MUT because GameKernel §5 declares the "
"row `recorded not gated` — a row that cannot fail "
"asserts nothing, and promoting it is a spec change "
"needing an ADR. The breach is a maintainer decision, "
"not something this tool should resolve by gating "
"itself."),
Row("AM-6", ">= 100,000 applied events/s",
# Release: the gate runs where headroom is ~24x. In debug the
# test only asserts a sanity floor, so a debug mutation run
# would need a far larger slowdown to register.
verify=CARGO + ["test", "--release", "-p", "games-ground",
"--all-features", "am6_throughput", "--",
"--ignored", "--test-threads=1"],
# A PROPERTY mutation, not a threshold tweak: slow the fold hot
# path and require the gate to notice. Raising the target
# instead would only prove the comparison runs.
mutate=("games/ground/src/lib.rs",
" fn fold(&mut self, event: &Self::Event) {\n"
" match event {",
" fn fold(&mut self, event: &Self::Event) {\n"
" for _ in 0..100_000 { std::hint::black_box(0u8); }\n"
" match event {"),
expect="AM-6 UNMET"),
Row("AM-7", "scaling >= 0.9x, and replay of 100k events <= 5 s, "
"hash-identical",
verify=CARGO + ["test", "-p", "games-ground", "--all-features",
"replay_100k"],
mutate=("games/ground/src/lib.rs",
'assert!(elapsed.as_secs_f64() < 5.0,',
'assert!(elapsed.as_secs_f64() < 0.0,'),
clauses=[
("timing <= 5 s", True,
"the elapsed assert is live; tightening it to 0.0 s goes red"),
("hash-identical", True,
"RE-EARNED (CB-WP-0006 T06): the probe now replays each "
"per-game segment from its own genesis and asserts its "
"recorded hash. Folding a segment from the wrong seed fails."),
("scaling >= 0.9x", False,
"no code computes the ratio of throughput @100k to @5k or "
"compares it to 0.9; Criterion reports both and nothing "
"relates them."),
]),
Row("AM-8", "determinism: same-seed replays bit-identical",
verify=CARGO + ["run", "-q", "-p", "cb-sim", "--",
"scenarios/ground/gr-r06-round-resolve.yaml"],
mutate=("crates/cb-kernel/src/rng.rs",
"Self(rand_chacha::ChaCha12Rng::seed_from_u64(seed.0))",
"{ static N: std::sync::atomic::AtomicU64 = "
"std::sync::atomic::AtomicU64::new(0); "
"let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); "
"Self(rand_chacha::ChaCha12Rng::seed_from_u64(seed.0 + n)) }"),
clauses=[
("N=10 same-seed replays", False,
"the runner executes each scenario TWICE (K8), not ten "
"times; the spec says N=10. The double-run is live and the "
"count is not."),
("HashMap deny lint clean", True,
"clippy.toml denies HashMap/HashSet and `make check` runs "
"with -D warnings"),
]),
# A PROPERTY mutation: make the workload actually use memory.
Row("AM-9", "peak RSS <= 64 MB",
verify=py + ["tools/runtime-metrics.py", "--fast"],
mutate=("games/ground/src/lib.rs",
" fn replay_100k_events_is_linear_and_fast() {",
" fn replay_100k_events_is_linear_and_fast() {\n"
" let hog: Vec<u8> = vec![7u8; 300_000_000];\n"
" std::hint::black_box(&hog);"),
expect="FAIL target <= 64 MB"),
Row("AM-10", "0 foreign types in cb-*-api-visible signatures",
unmutatable="the population is empty — there is no `cb-*-api` "
"crate, so the claim is true over nothing. What is "
"measured instead is a clippy deny of "
"HashMap/HashSet whose stated reason cites K6 "
"(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",
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"],
mutate=("tools/cb-cost.py",
'toks["output"] = max(t["output"] for t in per_row)',
'toks["output"] = per_row[0]["output"]')),
]
def run(cmd, timeout=900):
"""(ok, tail) for one verifying command, run at the repo root."""
try:
r = subprocess.run(cmd, cwd=ROOT, env=cargo_env(),
capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return False, "TIMEOUT", "TIMEOUT"
out = (r.stdout + r.stderr).strip()
tail = out.splitlines()
return r.returncode == 0, (tail[-1][:70] if tail else ""), out
def check_row(row):
"""(verdict, detail). Restores the tree even when the command fails."""
if row.unmutatable:
return "unmutatable", row.unmutatable
path = os.path.join(ROOT, row.mutate[0])
original = open(path).read()
old, new = row.mutate[1], row.mutate[2]
# Positive control 1: the mutation must be applicable at all. A
# find-string that no longer matches would otherwise mutate nothing
# and report the baseline result as the mutant result.
if original.count(old) < 1:
return "HARNESS-BROKEN", (
f"mutation target not found in {row.mutate[0]}: {old!r}")
# Positive control 2: the baseline must be green, or "mutant red"
# proves nothing.
base_ok, base_tail, _ = run(row.verify)
if not base_ok:
return "inconclusive", f"baseline already red: {base_tail}"
try:
mutated = original.replace(old, new, 1)
# Positive control 3: the file content must actually differ.
if mutated == original:
return "HARNESS-BROKEN", "replace produced no change"
open(path, "w").write(mutated)
if open(path).read() == original:
return "HARNESS-BROKEN", "write did not take effect"
mut_ok, mut_tail, mut_out = run(row.verify)
finally:
open(path, "w").write(original)
# Positive control 4: restoration must have worked, or every later
# row runs against a corrupted tree.
if open(path).read() != original:
return "HARNESS-BROKEN", f"failed to restore {row.mutate[0]}"
if mut_ok:
return "SURVIVED", "mutant is green — this row asserts nothing"
if row.expect and row.expect not in mut_out:
# The FA guard. The mutant went red, but not for the reason
# claimed — a compile error, a panic elsewhere, an unrelated
# assertion. Scoring that as `red` would credit the row with an
# assertion it does not have.
return "WRONG-REASON", (
f"mutant failed, but its output does not contain {row.expect!r}"
f"this is not evidence the row is enforced")
return "red", mut_tail or "verifier failed as required"
def report(only=None):
rs = [r for r in rows() if not only or r.id == only]
print("M-D1-MUT — does anything fail when the property is false?")
print(f" {len(rows())} acceptance rows in specs/GameKernel.md §5")
print(" NOTE: ADR-0005 and CB-WP-0005 both say 'twelve'. There are "
"**14** — AM-4\n splits into a/b/c. The 9-of-12 prediction "
"(75%) is evaluated below\n on the same basis: >=10 of 14.\n")
tally = {}
broken = []
for r in rs:
verdict, detail = check_row(r)
# A multi-clause row is red only if every clause is enforced.
if verdict == "red" and r.clauses:
unmet = [c for c in r.clauses if not c[1]]
if unmet:
verdict = "PARTIAL"
detail = (f"{len(r.clauses) - len(unmet)}/{len(r.clauses)} "
f"clauses enforced")
tally[verdict] = tally.get(verdict, 0) + 1
if verdict == "HARNESS-BROKEN":
broken.append(r.id)
mark = {"red": "red ", "SURVIVED": "SURVIVED ",
"unmutatable": "unmutatable", "inconclusive": "inconclusive",
"PARTIAL": "PARTIAL ",
"HARNESS-BROKEN": "BROKEN ",
"WRONG-REASON": "WRONG-REASON"}[verdict]
print(f" [{mark}] {r.id:<6} {r.claim[:52]}")
if detail:
for line in _wrap(detail, 66):
print(f" {line}")
for name, enforced, why in r.clauses:
print(f" - {'red ' if enforced else 'NONE'} "
f"{name}: {why[:60]}")
if broken:
# The harness asserting it did the work it reports.
print(f"\nERROR — harness broken on {', '.join(broken)}; "
f"no verdict is valid", file=sys.stderr)
return 1
red = tally.get("red", 0)
total = len(rs)
withdrawn = sum(1 for r in rs if "WITHDRAWN" in (r.unmutatable or ""))
print(f"\n M-D1-MUT: {red}/{total} rows enforced")
if withdrawn:
print(f" ({withdrawn} withdrawn row(s) retained in the denominator "
f"on purpose — a score\n improved by deleting the question "
f"is not an improvement)")
for k in ("PARTIAL", "SURVIVED", "WRONG-REASON", "unmutatable",
"inconclusive"):
if tally.get(k):
print(f" {k:<13} {tally[k]}")
if only:
return 0
print(f"\n prediction was >=10 of 14 (the ADR's 9-of-12, 75%). "
f"Measured {red}: "
f"{'MET' if red >= 10 else 'UNMET'}")
if red <= 3:
print(" CB-WP-0005 stop condition: at or below 3 of 12-equivalent, "
"the pass is\n under-scoped and must re-plan before Phase C.")
return 0
def _wrap(text, width):
words, line, out = text.split(), "", []
for w in words:
if len(line) + len(w) + 1 > width:
out.append(line)
line = w
else:
line = f"{line} {w}".strip()
if line:
out.append(line)
return out
def self_test():
"""Each assertion pins a failure this harness must detect."""
results = []
def check(name, ok, detail=""):
results.append((name, ok, detail))
rs = rows()
check("the acceptance table is enumerated", len(rs) == 14, f"{len(rs)} rows")
check("every row has a mutation or a stated reason",
all(r.mutate or r.unmutatable for r in rs))
unmut = [r for r in rs if r.unmutatable]
check("no unmutatable row has a token reason",
bool(unmut) and all(len(r.unmutatable) > 40 for r in unmut),
f"{len(unmut)} unmutatable; a bare 'unmutatable' is how a row "
f"escapes the metric")
check("every mutation target file exists",
all(os.path.isfile(os.path.join(ROOT, r.mutate[0]))
for r in rs if r.mutate))
# The control that matters: a mutation whose find-string no longer
# matches must be reported BROKEN, not silently skipped.
stale = Row("AM-X", "fixture", verify=[sys.executable, "-c", "pass"],
mutate=("Makefile", "this-string-does-not-exist", "x"))
v, _ = check_row(stale)
check("a stale mutation target is reported broken, not skipped",
v == "HARNESS-BROKEN", v)
# And a mutation that does apply must be detected as applying.
live = Row("AM-Y", "fixture",
verify=[sys.executable, "-c",
"import sys,os; sys.exit(0 if 'ZZMARKER' "
"not in open('Makefile').read() else 1)"],
mutate=("Makefile", "PY := python3", "PY := python3 # ZZMARKER"))
v2, _ = check_row(live)
check("an applied mutation that breaks the verifier reports red",
v2 == "red", v2)
check("the tree is restored after a mutation run",
"ZZMARKER" not in open(os.path.join(ROOT, "Makefile")).read())
# The FA guard: a mutant that fails for the WRONG reason must not be
# scored as red. Without this, a mutation that merely fails to compile
# would credit its row with an assertion it does not have.
wrong = Row("AM-W", "fixture",
verify=[sys.executable, "-c",
"import sys; sys.exit(0 if 'ZZW' not in "
"open('Makefile').read() else 7)"],
mutate=("Makefile", "PY := python3", "PY := python3 # ZZW"),
expect="a message the verifier never prints")
v4, _ = check_row(wrong)
check("a mutant failing for the wrong reason is not scored red",
v4 == "WRONG-REASON", v4)
# A verifier that is already red must not be scored.
dead = Row("AM-Z", "fixture", verify=[sys.executable, "-c", "raise SystemExit(3)"],
mutate=("Makefile", "PY := python3", "PY := python3 "))
v3, _ = check_row(dead)
check("a red baseline is inconclusive, not red", v3 == "inconclusive", v3)
print("mutation-check self-test (positive control)")
ok = True
for name, passed, det in results:
print(f" [{'ok ' if passed else 'FAIL'}] {name}"
+ (f"{det}" if det else ""))
ok &= passed
return 0 if ok else 1
def main():
enter_root()
ap = argparse.ArgumentParser()
ap.add_argument("--row")
ap.add_argument("--self-test", action="store_true")
args = ap.parse_args()
if args.self_test:
return self_test()
return report(args.row)
if __name__ == "__main__":
sys.exit(main())