Some checks failed
ci / check (push) Failing after 4s
Notes were written to a file and shown back nowhere — the shape trials.py's own docstring calls this project's signature failure in a new medium. The log now carries the player's comments where they were made. Position is the feature: a remark like "why did that do nothing?" is about the move above it, and collected at the bottom it is a sentence with no subject. round/step cannot order a note against the log because several commands share a step, so the server records how many commands had been played — which it knows exactly — and the page places it there. A comment must not be readable as something the game did. The log is the recorder's vocabulary; notes render in their own block, attributed to the player, quoted. The .note CSS already existed and nothing had ever used it. No column was added to the trial log. trials.py skipped any row that was not five cells, silently, so a sixth column would have made `make trials` report zero notes for every log at once. That latent defect is fixed on its own terms: a wrong column count now raises, and the walk reports the real reason rather than blaming a missing block for every failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
10 KiB
Python
245 lines
10 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
|
|
|
|
# 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")
|
|
|
|
|
|
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):
|
|
raise ValueError(
|
|
f"trial-log row has {len(cells)} columns, expected {len(COLUMNS)}: {line!r}"
|
|
)
|
|
rows.append(dict(zip(COLUMNS, 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 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 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)))
|
|
|
|
# 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 |")
|
|
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)
|
|
# 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())
|