CB-WP-0033: a game is the unit
Some checks failed
ci / check (push) Failing after 3s

make trials reported "positions unreachable: 4, target 0 — the recording
exists but the position moved". All four were false. A recording holds one
hash, the final state, and reachability asked whether the note's hash was
in that file — so a mid-game note could never match, and a post-game note
from any but the last game could not either. Instance 8 of the ADR-0018
family: vary only WHEN a note was written and the answer flips, with
nothing having moved.

The root cause was not the metric. play again reused state belonging to a
game: it overwrote the previous game's recording (data loss), never
cleared the journal (game 2's log opened with game 1's commands), and so a
note's command index pointed into a recording without those commands.
Fixing reachability alone would have gone green while a session still
destroyed its own evidence.

A note now binds by (game, after) — an index into the recording's own
commands list, which a reader can replay to. The hash keeps a job as the
integrity check at the end of a game, where it can actually fail. Game 1
keeps the path it was given, so GameDesign §5's documented invocation is
unchanged; later games get -2, -3 and nothing is overwritten. Legacy
5-column logs stay readable and are reported as legacy, never as orphans —
an unsubstantiated orphan claim is the defect being fixed.

All three fixes mutation-proven, including at the call site via a real
two-game session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-07 17:41:11 +02:00
parent 4ddff3b17c
commit edab11c0e4
10 changed files with 909 additions and 41 deletions

View file

@ -30,7 +30,13 @@ NOTE_EXPIRY_DAYS = 30
# The trial-log table's columns, named once. `write_trial_log` in
# `hotseat.rs` writes them; this reads them; a row that is not this shape
# is an error rather than a row to skip.
COLUMNS = ("n", "round", "step", "state_hash", "comment")
COLUMNS = ("n", "game", "after", "round", "step", "state_hash", "comment")
# ADR-0019 D4. Logs written before the binding was fixed. They are parsed
# and reported, and they are NOT called orphans: their positions were
# never checkable by anything this repo can run, and an unsubstantiated
# orphan claim is the exact defect ADR-0019 exists to delete.
LEGACY_COLUMNS = ("n", "round", "step", "state_hash", "comment")
def parse(text):
@ -56,14 +62,43 @@ def parse(text):
# at once. Adding a sixth column to the trial log (which CB-WP-0032
# considered, and did not do) would have emptied every existing
# log without a word.
if len(cells) != len(COLUMNS):
if len(cells) == len(COLUMNS):
rows.append(dict(zip(COLUMNS, cells)))
elif len(cells) == len(LEGACY_COLUMNS):
row = dict(zip(LEGACY_COLUMNS, cells))
row["legacy"] = True
rows.append(row)
else:
raise ValueError(
f"trial-log row has {len(cells)} columns, expected {len(COLUMNS)}: {line!r}"
f"trial-log row has {len(cells)} columns, expected "
f"{len(COLUMNS)} or {len(LEGACY_COLUMNS)}: {line!r}"
)
rows.append(dict(zip(COLUMNS, cells)))
return rows
def recording_for(md_path, row):
"""The recording holding this note's game (ADR-0019 D2).
Game 1 keeps the base path; later games carry `-2`, `-3`. A legacy row
names no game, so it gets the base path and is judged as legacy.
"""
stem = os.path.splitext(md_path)[0]
n = int(row.get("game", 1) or 1)
path = f"{stem}.yaml" if n <= 1 else f"{stem}-{n}.yaml"
return path if os.path.exists(path) else None
def command_count(recording):
"""How many commands the recording holds — the range a note's `after`
must fall inside. Counted from the `commands:` block, because that is
the list `after` indexes into."""
text = open(recording).read()
if "commands:" not in text:
return 0
block = text.split("commands:", 1)[1].split("\nexpect:", 1)[0]
return sum(1 for line in block.splitlines() if line.startswith("- actor:"))
def logs(root=TRIALS):
"""Every trial log, with its recording beside it if there is one."""
out = []
@ -98,9 +133,22 @@ def reachability(row, recording):
worth knowing, by the same reasoning that rewrote `gr-e01` rather than
retiring it.
"""
if row.get("legacy"):
return "legacy"
if not recording:
return "pending"
return "ok" if row["state_hash"] in open(recording).read() else "orphan"
after = int(row["after"])
n = command_count(recording)
if after > n:
# The note points past the end of its own game.
return "orphan"
if after == n:
# The one position the recording states outright. A mismatch here
# is real: the note claims the end of a game the recording does
# not agree with.
return "ok" if row["state_hash"] in open(recording).read() else "orphan"
# Reachable by replaying `after` commands of this recording.
return "ok"
def age_days(path, today):
@ -125,32 +173,41 @@ def report(root=TRIALS, today=None):
found = logs(root)
print("trials — what the players said, and where\n")
total, orphans, pending, expired = 0, 0, 0, 0
for path, rows, recording in found:
total, orphans, pending, expired, legacy = 0, 0, 0, 0, 0
for path, rows, _ in found:
name = os.path.basename(path)
age = age_days(path, today)
print(f" {name} ({len(rows)} note(s), {age}d"
f"{', recording not written yet' if not recording else ''})")
print(f" {name} ({len(rows)} note(s), {age}d)")
for r in rows:
total += 1
state = reachability(r, recording)
# ADR-0019 D2: each note names its own game, and each game has
# its own recording. One recording per FILE was the assumption
# that made `play again` overwrite the evidence.
rec = recording_for(path, r)
state = reachability(r, rec)
orphans += state == "orphan"
pending += state == "pending"
legacy += state == "legacy"
expired += age > NOTE_EXPIRY_DAYS
mark = {"orphan": "orphan", "pending": " .. ", "ok": " "}[state]
print(f" {mark} r{r['round']:<2} {r['step']:<8} {r['state_hash']:<12} "
f"{r['comment'][:70]}")
mark = {"orphan": "orphan", "pending": " .. ",
"legacy": "legacy", "ok": " "}[state]
where = f"g{r.get('game', '?')}+{r.get('after', '?')}"
print(f" {mark} {where:<7} r{r['round']:<2} {r['step']:<14} "
f"{r['comment'][:60]}")
print()
print(f" trial logs {len(found)}")
print(f" notes {total}")
print(f" positions unreachable {orphans} target 0"
+ (" <-- the recording exists but the position moved" if orphans else ""))
+ (" <-- a note points past the end of its own game" if orphans else ""))
if pending:
print(f" awaiting a recording {pending}"
" (normal during a live session; the recording lands at game end)")
if legacy:
print(f" legacy binding {legacy}"
" (written before ADR-0019; position not checkable, NOT orphaned)")
print(f" notes past {NOTE_EXPIRY_DAYS} days {expired} target 0")
if total and not orphans:
if total and not orphans and not legacy:
print("\n Every note points at a position a reader can reach.")
print("\n Raw notes stay in this repo (ADR-0014 D4). A note reaches"
"\n ground-game only by being promoted to a register finding, by a"
@ -174,18 +231,37 @@ def self_test():
ok = ok and bool(cond)
print(f" [{'ok ' if cond else 'FAIL'}] {name}" + (f"{detail}" if detail else ""))
good = (f"# Trial log\n\n{BEGIN}\n\n"
"| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n"
"| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |\n"
f"\n{END}\n")
def log_of(*rows):
head = ("| n | game | after | round | step | state_hash | comment |\n"
"|---|---|---|---|---|---|---|\n")
return f"# Trial log\n\n{BEGIN}\n\n{head}" + "".join(rows) + f"\n{END}\n"
good = log_of("| 1 | 1 | 4 | 3 | Select | abc123def456 | why is SOLVE doing nothing |\n")
legacy = (f"# Trial log\n\n{BEGIN}\n\n"
"| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n"
"| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |\n"
f"\n{END}\n")
def recording(commands, final_hash):
"""A recording with `commands` commands and one final hash — the
shape ADR-0019 D3 reads: a list to index into, plus the one
position the file states outright."""
body = "commands:\n"
for _ in range(commands):
body += "- actor: P1\n cmd: select_action\n args: {}\n"
return body + f"expect:\n state_hash: {final_hash}\n"
check("a trial log parses", len(parse(good)) == 1)
check("a legacy 5-column log still parses", len(parse(legacy)) == 1,
"ADR-0019 D4 \u2014 old logs stay readable")
check("a legacy row is MARKED legacy", parse(legacy)[0].get("legacy") is True)
check("the header row is not a note", all(r["n"] != "n" for r in parse(good)))
# CB-WP-0032. A row of the wrong shape must be LOUD. This used to
# `continue`, so a format change would have reported zero notes for
# every log at once -- the failure the parser's own docstring names.
six = good.replace("| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |",
"| 1 | 3 | Select | abc123def456 | extra | why is SOLVE doing nothing |")
# Six columns is neither the current shape (7) nor the legacy one (5),
# so it must RAISE rather than be skipped.
six = log_of("| 1 | 1 | 4 | 3 | Select | abc123def456 |\n")
try:
parse(six)
check("a row with the wrong column count is an error", False)
@ -212,21 +288,55 @@ def self_test():
with tempfile.TemporaryDirectory() as d:
open(os.path.join(d, "2026-08-06-x.md"), "w").write(good)
# No .yaml beside it → the position cannot be reached.
rows = parse(good)
row = parse(good)[0] # game 1, after 4, hash abc123def456
check("a note with no recording is PENDING, not orphaned",
reachability(rows[0], None) == "pending",
reachability(row, None) == "pending",
"a live session must not report orphans")
rec = os.path.join(d, "2026-08-06-x.yaml")
open(rec, "w").write("state_hash: abc123def456\n")
check("a note whose hash is in the recording is reachable",
reachability(rows[0], rec) == "ok",
"without this the check could always say orphan")
open(rec, "w").write("state_hash: something-else\n")
check("a note whose hash is absent from a REAL recording is an orphan",
reachability(rows[0], rec) == "orphan",
# ADR-0019 D3. A MID-GAME note is reachable by replaying `after`
# commands. Under the old check this case was IMPOSSIBLE to pass:
# the recording holds one hash, so every mid-game note was an
# orphan whatever had happened.
open(rec, "w").write(recording(10, "zzz"))
check("a mid-game note inside its game is reachable",
reachability(row, rec) == "ok",
"the old check called every one of these an orphan")
# And it can still say NO: past the end of its own game.
open(rec, "w").write(recording(2, "zzz"))
check("a note past the end of its game is an orphan",
reachability(row, rec) == "orphan",
"without this the metric cannot go red")
# At the end, the hash is the integrity check — and it can fail.
end_row = parse(log_of(
"| 1 | 1 | 4 | 3 | after the end | abc123def456 | done |\n"))[0]
open(rec, "w").write(recording(4, "abc123def456"))
check("an end-of-game note agreeing with the recording is reachable",
reachability(end_row, rec) == "ok")
open(rec, "w").write(recording(4, "something-else"))
check("an end-of-game note whose hash disagrees is an orphan",
reachability(end_row, rec) == "orphan",
"the position moved \u2014 the case the flag exists for")
# A legacy row is never called an orphan (ADR-0019 D4).
open(rec, "w").write(recording(2, "no-match"))
check("a legacy row is LEGACY, never orphan",
reachability(parse(legacy)[0], rec) == "legacy",
"an unsubstantiated orphan claim is the defect being fixed")
# ADR-0019 D2: game 2 reads its OWN recording.
open(os.path.join(d, "g.md"), "w").write("x")
open(os.path.join(d, "g.yaml"), "w").write(recording(1, "one"))
open(os.path.join(d, "g-2.yaml"), "w").write(recording(9, "two"))
g2 = parse(log_of("| 1 | 2 | 5 | 1 | Select | two | second game |\n"))[0]
check("a note from game 2 is judged against game 2's recording",
recording_for(os.path.join(d, "g.md"), g2).endswith("g-2.yaml")
and reachability(g2, recording_for(os.path.join(d, "g.md"), g2)) == "ok",
"one recording per FILE is what let play again overwrite it")
# THE control design-baseline.py lacked: exercise the reporting
# path and assert on what it printed.
buf = io.StringIO()