#!/usr/bin/env python3 """trials — what the players said, and where (CB-WP-0027 T04). **Surfacing is the deliverable, not storage.** ADR-0014 D6: a commentary feature that stores comments and shows them nowhere would be this project's signature failure in a new medium — after the message that sat unread for four days and the ten rulings that arrived and were never collected. So this reports every note from every trial log, with its position and its age, and flags the ones whose position can no longer be reached. Reuses `design.py`'s parsing shape because the trial log deliberately reuses `FindingRegister.md`'s idiom: a table between HTML-comment markers inside a readable Markdown file. """ import os, re, sys, glob, datetime ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TRIALS = os.path.join(ROOT, "trials") BEGIN = "" END = "" # GameDesign §3.1's figure, shared rather than reinvented: a note that # cannot expire ages into an apparent finding. 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", "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): """Rows between the markers. A log without the block is an error, not an empty log — silently reporting zero notes for a file full of them is precisely the failure this tool exists to prevent.""" if BEGIN not in text or END not in text: raise ValueError("no trial-log block") block = text.split(BEGIN)[1].split(END)[0] rows = [] for line in block.splitlines(): line = line.strip() if not line.startswith("|") or line.startswith("|---"): continue cells = [c.strip() for c in line.strip("|").split("|")] if cells[0] == "n": continue # CB-WP-0032. A wrong column count RAISES; it used to `continue`. # # The docstring above already says silently reporting zero notes # for a file full of them is the failure this tool exists to # prevent -- and a `continue` here did exactly that for every row # 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): 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 " f"{len(COLUMNS)} or {len(LEGACY_COLUMNS)}: {line!r}" ) 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 = [] for path in sorted(glob.glob(os.path.join(root, "*.md"))): try: rows = parse(open(path).read()) except ValueError as e: # The REASON, not a guess at it. This said "no trial-log block # -- not a trial log?" for every failure, which is now wrong # for two of them: a malformed row is not a missing block, and # telling a maintainer to check for the wrong thing costs more # than saying nothing. print(f" WARN {os.path.basename(path)}: {e}") continue recording = os.path.splitext(path)[0] + ".yaml" out.append((path, rows, recording if os.path.exists(recording) else None)) return out def reachability(row, recording): """Can this note's position be reached? One of three answers. **"No recording" and "the hash is not in the recording" are different states and must not be one number.** A note written mid-game is pending — the recording is only written when the game ends — while a note whose hash is absent from a recording that *exists* means the position moved. Collapsing them makes every live session report orphans, and a metric that cries wolf is one nobody reads, which is the failure this whole pass is about. Orphans are reported, never deleted (ADR-0014 D3): a moved position is 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" 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): """Dated from the log's filename (`YYYY-MM-DD-slug.md`), falling back to mtime — the name is the intent, the mtime is what happened.""" m = re.match(r"(\d{4}-\d{2}-\d{2})", os.path.basename(path)) if m: try: return (today - datetime.date.fromisoformat(m.group(1))).days except ValueError: pass return (today - datetime.date.fromtimestamp(os.path.getmtime(path))).days def report(root=TRIALS, today=None): today = today or datetime.date.today() if not os.path.isdir(root): print("trials — no trials/ directory yet\n") print(" Play one: cb-play --serve 0 --record trials/-.yaml \\") print(" --trial trials/-.md") return 0 found = logs(root) print("trials — what the players said, and where\n") 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)") for r in rows: total += 1 # 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": " .. ", "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" + (" <-- 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 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" "\n human, with the wording chosen then.") return 0 def self_test(): """Positive controls, including for the REPORTING path. `design-baseline.py` had a green self-test and an unexercised reporting path, and the reporting path is where it rotted (ADR-0012 D8). So this runs `report` against a fixture and checks what it says. """ import tempfile, io, contextlib ok = True def check(name, cond, detail=""): nonlocal ok ok = ok and bool(cond) print(f" [{'ok ' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) 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 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) except ValueError as e: check("a row with the wrong column count is an error", "6 columns" in str(e), "a silent skip would empty every log at once") # And the surrounding walk must report the REASON it failed. with tempfile.TemporaryDirectory() as d: with open(os.path.join(d, "2026-08-07-bad.md"), "w") as fh: fh.write(six) buf = io.StringIO() with contextlib.redirect_stdout(buf): logs(d) check("the walk names the real reason", "6 columns" in buf.getvalue(), "it used to blame a missing block for every failure") try: parse("# Trial log\n\nno block here\n") check("a file with no block is an error, not an empty log", False) except ValueError: check("a file with no block is an error, not an empty log", True, "silently reporting zero is the failure this tool prevents") with tempfile.TemporaryDirectory() as d: open(os.path.join(d, "2026-08-06-x.md"), "w").write(good) row = parse(good)[0] # game 1, after 4, hash abc123def456 check("a note with no recording is PENDING, not orphaned", reachability(row, None) == "pending", "a live session must not report orphans") rec = os.path.join(d, "2026-08-06-x.yaml") # 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() with contextlib.redirect_stdout(buf): report(d, datetime.date(2026, 8, 6)) out = buf.getvalue() check("the reporting path runs and names the note", "why is SOLVE doing nothing" in out, "not just the parser") check("the reporting path counts", "notes 1" in out) print("trials self-test (positive control)") return 0 if ok else 1 if __name__ == "__main__": sys.exit(self_test() if "--self-test" in sys.argv else report())