#!/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 import re, sys, glob, datetime ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TRIALS = os.path.join(ROOT, "trials") # The begin marker carries attributes since CB-WP-0046, so it is matched # by PREFIX. A bare `` is still a valid marker — # every log written before this one has it. BEGIN = "" # Which rules the session was played under (CB-WP-0046). # # **A session property, not a note property**, so it rides the marker # rather than becoming an eighth column. The variant is fixed at startup # and cannot change between games, so a per-row column would repeat one # fact on every line and force a third accepted row width on a parser # whose docstring is about exactly that hazard. VARIANT_ATTR = re.compile(r"variant=([A-Za-z0-9._-]+)") # 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") # Split off the marker's own line so an attribute cannot be read as # a table row, and so `variant=` is available to every row below it. tail = text.split(BEGIN, 1)[1].split(END)[0] marker, _, block = tail.partition("\n") hit = VARIANT_ATTR.search(marker) variant = hit.group(1) if hit else None 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): row = dict(zip(COLUMNS, cells)) elif len(cells) == len(LEGACY_COLUMNS): row = dict(zip(LEGACY_COLUMNS, cells)) row["legacy"] = True else: raise ValueError( f"trial-log row has {len(cells)} columns, expected " f"{len(COLUMNS)} or {len(LEGACY_COLUMNS)}: {line!r}" ) # **`None`, not a default of "baseline"** — a log written before # the marker carried the attribute does not say which rules were # played, and answering an unrecorded question with the common # case is how a baseline note gets read as an H2 result. row["variant"] = variant rows.append(row) 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 by_variant = {} for path, rows, _ in found: name = os.path.basename(path) age = age_days(path, today) # CB-WP-0046: which rules were played. `unrecorded` is said out # loud rather than filled in with the common case — a note read # under the wrong rules is worse than a note whose rules are # openly unknown. rules = (rows[0].get("variant") if rows else None) or "unrecorded" print(f" {name} ({len(rows)} note(s), {age}d, rules {rules})") for r in rows: total += 1 key = r.get("variant") or "unrecorded" by_variant[key] = by_variant.get(key, 0) + 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") # A tally by rules, because the question a reader now has is "was this # said about H2 or about the baseline" and counting the logs by hand # is how that gets answered wrong. if by_variant: print(" notes by rules " + ", ".join(f"{v} {n}" for v, n in sorted(by_variant.items()))) if "unrecorded" in by_variant: print(" (unrecorded = written before" " CB-WP-0046 stamped the marker)") 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 "")) # `BEGIN` is a PREFIX since CB-WP-0046, so a fixture has to close the # marker itself — which is the point: the closing text is what the # attribute sits inside. OPEN = BEGIN + " -->" def log_of(*rows, marker=None): head = ("| n | game | after | round | step | state_hash | comment |\n" "|---|---|---|---|---|---|---|\n") return (f"# Trial log\n\n{marker or OPEN}\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{OPEN}\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-0046 — WHICH RULES THE SESSION PLAYED. row = "| 1 | 1 | 4 | 3 | Select | abc123def456 | it felt harder |\n" stamped = log_of(row, marker=BEGIN + " variant=h2-scoped-problem-stress -->") check("a stamped log reports its variant", parse(stamped)[0]["variant"] == "h2-scoped-problem-stress") check("an unstamped log says NOTHING, not 'baseline'", parse(log_of(row))[0]["variant"] is None, "answering an unrecorded question with the common case is how a " "baseline note gets read as an H2 result") # The attribute must not be mistaken for a table row, and adding it # must not change how many notes the log has. check("the marker is not counted as a note", len(parse(stamped)) == 1) check("stamping does not disturb the row", parse(stamped)[0]["after"] == "4") # The marker is not a THIRD row width: the table shape is untouched, # which is why the parser needed no new accepted width. check("the stamped table has the same columns as the unstamped one", set(parse(stamped)[0]) - {"variant"} == set(parse(good)[0]) - {"variant"}) # **A LITERAL marker, not one built from `BEGIN`.** # # Every fixture above composes its marker out of `BEGIN`, so all of # them follow `BEGIN` wherever it goes — including away from what # `hotseat.rs` actually writes. Reverting `BEGIN` to an exact match # broke real stamped logs and every one of those checks stayed green. # The format is shared across two languages and this is the only # check that holds this side to the bytes the other side emits. literal = ("# Trial log\n\n" "\n\n" "| n | game | after | round | step | state_hash | comment |\n" "|---|---|---|---|---|---|---|\n" "| 1 | 1 | 4 | 3 | Select | abc123def456 | it felt harder |\n" "\n\n") # Caught, because the failure mode is `parse` REFUSING the writer's # output — and an uncaught traceback names a line number where a # named check names the defect. try: literal_rows = parse(literal) except ValueError: literal_rows = [] check("the bytes hotseat.rs writes are the bytes this parses", len(literal_rows) == 1 and literal_rows[0]["variant"] == "h2-scoped-problem-stress", "hotseat.rs's the_trial_log_records_which_rules_were_played " "asserts this same string from the writing side") # And the marker as it was written before CB-WP-0046, also literal. try: older = parse(literal.replace( "", "")) except ValueError: older = [] check("a pre-CB-WP-0046 marker still opens a log", len(older) == 1, "ADR-0019 D4's discipline: old logs stay readable") check("a legacy log can still be stamped", parse(f"# Trial log\n\n{BEGIN} variant=ground-darvo-r0 -->\n\n" "| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n" "| 1 | 3 | Select | abc123def456 | x |\n" f"\n{END}\n")[0]["variant"] == "ground-darvo-r0") # 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())