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:
parent
4ddff3b17c
commit
edab11c0e4
10 changed files with 909 additions and 41 deletions
172
tools/trials.py
172
tools/trials.py
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue