The meta view beside the table, and a note channel that provably cannot carry a move. T01 (ADR-0014). ADR-0007 D5 is SCOPED, NOT AMENDED, and the reason it was easy is that PointerFact::parse already refuses any unrecognised field -- a comment could not reach the command path even by accident. So /command carries pointer facts, /note carries text, and Note has no code path to GroundCommand. Comments live in trials/<date>-<slug>.md, not in ScenarioFile: a scenario is executed, replayed and hashed, and prose in it is data the runner must ignore, which is how a format rots. The state hash binds; round and step are for reading. And the retention question, decided before any comment was written: RAW NOTES NEVER LEAVE clay-borg. A note reaches ground-game only by being promoted to a register finding, by a human, with the wording chosen then -- "the DARVO sequence is infuriating" is useful signal and a bad way to open a message to the game's designer. T02. CSS grid, minmax(0,1fr) on both tracks -- load-bearing, because a grid child defaults to min-content width and without it the SVG table refuses to shrink and pushes the meta column off-screen, looking correct on the developer's monitor and broken everywhere else. Single-column fallback under 64rem. The running tally moved into the panel so it is visible WHILE PLAYING; it only appeared on the ending page before, and a score you see once the game is over informs nothing. T03. A plain <form method="post">, so the box works with the script disabled; the command channel needs JavaScript because a drag is not a form submission, a comment is one. 303 See Other so a reload does not re-post. esc()'s first hostile input: <script>alert(1)</script> renders escaped AND STILL READABLE -- escaping that eats the player's words is its own defect. Verified over real HTTP: note posted 303, hostile note stored as text, empty note refused 400, game did not advance. T04. tools/trials.py and make trials. THE REPORT'S DESIGN CHANGED BECAUSE I RAN IT: the first version called any note without a recording an orphan, so a live session reported every note as broken -- the recording is only written at game end. A metric that cries wolf is one nobody reads, which is the exact failure this pass exists to prevent. Now ok / pending / orphan, and only orphan is a target-0 number. The self-test exercises the REPORTING path, not just the parser, because design-baseline.py had a green self-test and an unexercised reporting path and that is where it rotted. And a latent Makefile defect surfaced: make trials did nothing, because trials is also a directory and Make saw an up-to-date file. design, difficulty and trials -- added by CB-WP-0022, CB-WP-0025 and this pass -- were ALL missing from .PHONY; only the one that collided revealed it. make all: exit 0. 49 render tests, 26 cb-play, loop-lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
8.3 KiB
Python
201 lines
8.3 KiB
Python
#!/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 = "<!-- trial-log:begin -->"
|
|
END = "<!-- trial-log:end -->"
|
|
|
|
# GameDesign §3.1's figure, shared rather than reinvented: a note that
|
|
# cannot expire ages into an apparent finding.
|
|
NOTE_EXPIRY_DAYS = 30
|
|
|
|
|
|
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 len(cells) != 5 or cells[0] == "n":
|
|
continue
|
|
rows.append(dict(zip(("n", "round", "step", "state_hash", "comment"), cells)))
|
|
return rows
|
|
|
|
|
|
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:
|
|
print(f" WARN {os.path.basename(path)}: no trial-log block — not a trial log?")
|
|
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 not recording:
|
|
return "pending"
|
|
return "ok" if row["state_hash"] in open(recording).read() else "orphan"
|
|
|
|
|
|
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/<date>-<slug>.yaml \\")
|
|
print(" --trial trials/<date>-<slug>.md")
|
|
return 0
|
|
|
|
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:
|
|
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 ''})")
|
|
for r in rows:
|
|
total += 1
|
|
state = reachability(r, recording)
|
|
orphans += state == "orphan"
|
|
pending += state == "pending"
|
|
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]}")
|
|
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 ""))
|
|
if pending:
|
|
print(f" awaiting a recording {pending}"
|
|
" (normal during a live session; the recording lands at game end)")
|
|
print(f" notes past {NOTE_EXPIRY_DAYS} days {expired} target 0")
|
|
if total and not orphans:
|
|
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 ""))
|
|
|
|
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")
|
|
check("a trial log parses", len(parse(good)) == 1)
|
|
check("the header row is not a note", all(r["n"] != "n" for r in parse(good)))
|
|
|
|
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)
|
|
# No .yaml beside it → the position cannot be reached.
|
|
rows = parse(good)
|
|
check("a note with no recording is PENDING, not orphaned",
|
|
reachability(rows[0], 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",
|
|
"the position moved \u2014 the case the flag exists for")
|
|
|
|
# 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())
|