File size: 16,370 Bytes
97f78c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
#!/usr/bin/env python3
"""Rewrite the README's headline table from the evaluation JSON.

Every other number in this project is generated: RESULTS.md comes from
`make_results.py`, the arcade report from `report.py`. The README headline was
the exception, and it drifted -- it carried figures from a `runs/arm-*` probe
long after the shipped checkpoint had changed, which is the specific failure
this repository criticises NanoJev for.

The table lives between two HTML comment markers and is replaced wholesale.
`--check` re-renders and compares instead of writing, so a stale README fails
the test suite rather than shipping.
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def begin(block: str) -> str:
    return f"<!-- BEGIN:{block} (generated by scripts/readme_table.py) -->"


def end(block: str) -> str:
    return f"<!-- END:{block} -->"


def block(name: str, body) -> list[str]:
    """One generated block, markers included.

    Shared with the test that checks the README is in sync. It was duplicated
    there, and when marker emission moved out of the renderers the copy in the
    test was left comparing marker-less output against a README that has
    markers -- an assertion that could never pass, and did not fail only
    because the test skips when the run directory is incomplete.

    Trailing blanks are trimmed off the body so a renderer that ends its table
    with one does not stack it with the blank added here. Spacing is decided
    in this one place, not by each renderer.
    """
    body = list(body)
    while body and not body[0].strip():
        body.pop(0)
    while body and not body[-1].strip():
        body.pop()
    return [begin(name), ""] + body + ["", end(name)]


def read(path: Path):
    try:
        return json.loads(path.read_text())
    except (OSError, json.JSONDecodeError):
        return None


def maze_row(summaries: dict, key: str) -> dict | None:
    data = summaries.get(key)
    return (data.get("summary", {}) or {}).get("maze") if data else None


def snake_row(summaries: dict, key: str) -> dict | None:
    data = summaries.get(key)
    return (data.get("summary", {}) or {}).get("snake") if data else None


def pick_split(ev: dict) -> tuple[dict, str]:
    """The split the headline quotes, and its name.

    `test` first, and the preference is not cosmetic. `best.pt` and
    `balanced.pt` are selected on `val`, once per eval across the whole run,
    so a val figure is a maximum over ~18 noisy estimates of the same 74
    snake questions and is biased upward by construction. `test` is scored
    once, by a checkpoint that never competed on it.

    evaluate.py keys its output by `Path(split).stem`, so the keys are bare
    names. This used to look up "data/frozen/test.jsonl", matched nothing,
    and silently rendered the headline with no accuracy table at all -- the
    lookup and the writer had never been run against each other, because the
    test fixture made up its own key format. Path-style keys are still
    accepted in case that writer changes back, and an unrecognised shape now
    says so rather than rendering an empty section.
    """
    for name in ("test", "val"):
        for key in (name, f"data/frozen/{name}.jsonl"):
            if isinstance(ev.get(key), dict) and ev[key].get("groups"):
                return ev[key], name
    return {}, ""


def render(run: Path) -> list[str]:
    play = run / "play"
    summaries = {p.stem.replace("_summary", ""): read(p)
                 for p in sorted(play.glob("*_summary.json"))}
    ev = read(run / "eval.json") or {}
    out = []

    # Accuracy next to its control, because a number without one is what the
    # NanoJev log reports and the whole point of this repo is not to.
    block, source = pick_split(ev)
    groups = block.get("groups", {})
    acc = [(g, groups[g]) for g in ("maze/choice", "snake/choice") if g in groups]
    if acc:
        out += [f"**Decision accuracy** (held-out `{source}` split, next to "
                "the uniform control). "
                "`boards` is the number of distinct boards behind those "
                "questions; it tracks `n` because the generator draws a fresh "
                "board per state, which is what makes `n` an honest "
                "denominator rather than an assumed one:", "",
                "| question | n | boards | accuracy | uniform control |",
                "| --- | ---: | ---: | ---: | ---: |"]
        for name, row in acc:
            b = row.get("boards")
            out.append(f"| `{name}` | {row['n']} | {'--' if b is None else b} | "
                       f"**{row['accuracy']:.4f}** | "
                       f"{row['uniform_control']:.4f} |")
        out.append("")

    rows = [("`model` (network alone)", "maze_model"),
            ("`model-field` (architectural)", "maze_model_field"),
            ("`random` (floor)", "maze_random"),
            ("`reference` (BFS)", "maze_reference")]
    body = [(lbl, maze_row(summaries, k)) for lbl, k in rows]
    body = [(lbl, m) for lbl, m in body if m]
    if body:
        # Episode count read from the summary, not typed: this block exists
        # because typed numbers drift.
        n = max(m.get("episodes", 0) for _, m in body)
        out += [f"**Maze**, {n} episodes across topologies and sizes:", "",
                "| controller | solve rate | efficiency | cells visited |",
                "| --- | ---: | ---: | ---: |"]
        for lbl, m in body:
            # Bold the model rows only. Bolding a control's solve rate reads
            # as a boast about the floor.
            sr = (f"**{m['solve_rate']:.2f}**" if "model" in lbl
                  else f"{m['solve_rate']:.2f}")
            out.append(f"| {lbl} | {sr} | {m['mean_efficiency']:.3f} | "
                       f"{m.get('mean_distinct_cells', float('nan')):.1f} |")
        out.append("")

    srows = [("`model`", "snake_model"), ("`random` (floor)", "snake_random"),
             ("`reference` (oracle)", "snake_reference")]
    sbody = [(lbl, snake_row(summaries, k)) for lbl, k in srows]
    sbody = [(lbl, s) for lbl, s in sbody if s]
    if sbody:
        n = max(v.get("episodes", 0) for _, v in sbody)
        out += [f"**Snake**, {n} episodes:", "",
                "| controller | mean food | max food | survival |",
                "| --- | ---: | ---: | ---: |"]
        for lbl, v in sbody:
            food = (f"**{v['mean_food']:.2f}**" if "model" in lbl
                    else f"{v['mean_food']:.2f}")
            out.append(f"| {lbl} | {food} | {v['max_food']} | "
                       f"{v['survival_rate']:.2f} |")
        out.append("")

    if ev and not groups:
        out += ["_`eval.json` has no recognised split; expected a `test` or "
                "`val` key from `scripts/evaluate.py`._", ""]
    if not out:
        out.append("_No evaluation artifacts yet; run `scripts/benchmark.sh`._")
        out.append("")
    return out


def render_memory(run: Path) -> list[str]:
    """`model+memory` beside `random+memory`, per board size.

    The one table in the README that has to be per-size, because the finding
    *is* the size dependence: at 11 the memory alone matches the model on
    arrival, and at 21 it beats it. A whole-benchmark average hides that by
    mixing the size where the control wins into the sizes where it does not.
    """
    play = run / "play"
    summaries = {p.stem.replace("_summary", ""): read(p)
                 for p in sorted(play.glob("*_summary.json"))}
    pairs = [("`model+memory`", "maze_model_memory"),
             ("`random+memory`", "maze_random_memory")]
    have = {lbl: maze_row(summaries, k) for lbl, k in pairs}
    if not all(have.values()):
        return [
                "_No `+memory` play artifacts yet; run `scripts/benchmark.sh`._"]
    sizes = sorted({int(z) for m in have.values()
                    for z in (m.get("by_size") or {})})
    out = ["| controller | size | solve | efficiency |",
           "| --- | ---: | ---: | ---: |"]
    for size in sizes:
        for lbl, m in have.items():
            row = (m.get("by_size") or {}).get(str(size))
            if not row:
                continue
            # Bold whichever of the pair wins the column, rather than always
            # bolding the model. At size 21 the control wins solve rate, and a
            # table that bolded the model there would be advertising a loss.
            other = (have["`random+memory`"] if lbl == "`model+memory`"
                     else have["`model+memory`"])
            orow = (other.get("by_size") or {}).get(str(size)) or {}
            sr, eff = row["solve_rate"], row.get("mean_efficiency", 0.0)
            sr_s = (f"**{sr:.2f}**" if sr > orow.get("solve_rate", sr)
                    else f"{sr:.2f}")
            eff_s = (f"**{eff:.3f}**" if eff > orow.get("mean_efficiency", eff)
                     else f"{eff:.3f}")
            out.append(f"| {lbl} | {size} | {sr_s} | {eff_s} |")
    n = max(((m.get("by_size") or {}).get(str(sizes[0])) or {}).get("episodes", 0)
            for m in have.values())
    out += ["",
            f"{n} episodes per size per topology, from the shipped checkpoint."]
    return out


# Block name -> renderer. Adding a generated table to the README means adding
# a marker pair and one entry here; nothing else in main() changes.
def render_volatility(run: Path) -> list[str]:
    """How much `val` still moves between evals, split into halves of training.

    This paragraph used to quote the numbers by hand, and said the movement
    "stops" because four consecutive evals had come back byte-identical. Two
    evals later one gave back six questions and the sentence was false. The
    claim was structurally unstable: anything phrased about the last N evals
    goes stale the moment training continues past them.

    Halves are stable under continued training in a way a trailing window is
    not, and they are the comparison the paragraph is actually making -- that
    the movement shrinks by a large factor without reaching zero. Both columns
    are derived here so the prose can point at the table instead of repeating
    it.
    """
    history = read(run / "history.json")
    if not history or len(history) < 4:
        return ["_No training history to report volatility from._"]
    rows = [(r["step"], r.get("snake_action"), r.get("maze_action"))
            for r in history if r.get("snake_action") is not None]
    if len(rows) < 4:
        return ["_Training history carries no `snake_action` to report._"]
    # Questions per split, recovered from the accuracy grid rather than
    # hard-coded: an accuracy on n questions is always a multiple of 1/n, so
    # the smallest gap between distinct observed values bounds n. Falls back
    # to reporting moves alone if the values are too few to infer it.
    def questions(values: list[float]) -> int | None:
        uniq = sorted(set(round(v, 6) for v in values))
        gaps = [b - a for a, b in zip(uniq, uniq[1:]) if b - a > 1e-9]
        if not gaps:
            return None
        n = round(1.0 / min(gaps))
        return n if 1 <= n <= 10_000 else None

    n_snake = questions([r[1] for r in rows])
    mid = len(rows) // 2
    out = ["| half of the run | evals | largest eval-to-eval move in "
           "`snake_action` |" + (" in questions |" if n_snake else ""),
           "| --- | ---: | ---: |" + (" ---: |" if n_snake else "")]
    for label, chunk in (("first", rows[:mid + 1]), ("second", rows[mid:])):
        # The halves overlap by one eval on purpose: a move is a property of
        # a *pair*, so splitting the list cleanly would drop the move across
        # the seam and quietly understate whichever half it belonged to.
        moves = [abs(b[1] - a[1]) for a, b in zip(chunk, chunk[1:])]
        biggest = max(moves) if moves else 0.0
        row = (f"| {label} ({chunk[0][0]}-{chunk[-1][0]}) | {len(chunk)} | "
               f"`{biggest:.3f}` |")
        if n_snake:
            row += f" {round(biggest * n_snake)} of {n_snake} |"
        out.append(row)
    return out


def render_shipped(run: Path) -> list[str]:
    """The two checkpoints the shipped run actually wrote, side by side.

    The section above makes its case with a snapshot taken mid-training,
    which a reader cannot check against anything: those checkpoints were
    overwritten by the next eval. These two are on disk. If the argument for
    selecting on `min(maze_action, snake_action)` is right, it should be
    visible in the files the repository ships, not only in an anecdote.

    Says so plainly when the two selectors agree. That is a real outcome --
    it means loss and gameplay pointed at the same step -- and rendering an
    identical pair of columns as though it were a contrast would be worse
    than saying nothing.
    """
    best, balanced = read(run / "best.json"), read(run / "balanced.json")
    if not best or not balanced:
        return ["_No selection metadata yet; train a run to write "
                "`best.json` and `balanced.json`._"]
    if best.get("step") == balanced.get("step"):
        return [f"_On this run both selectors chose step "
                f"{best.get('step')}: lowest eval loss and best "
                f"`min(maze_action, snake_action)` landed on the same "
                f"checkpoint, so there is nothing to contrast._"]
    rows = [("eval loss", "loss", 4, False),
            ("`acc_boolean`", "acc_boolean", 5, True),
            ("`acc_score`", "acc_score", 5, True),
            ("`acc_choice`", "acc_choice", 4, True),
            ("`maze_action`", "maze_action", 4, True),
            ("`snake_action`", "snake_action", 4, True)]
    out = [f"| | `best.pt` (step {best['step']}) | "
           f"`balanced.pt` (step {balanced['step']}) |",
           "| --- | ---: | ---: |"]
    for label, key, places, higher_is_better in rows:
        a, b = best.get(key), balanced.get(key)
        if a is None or b is None:
            continue
        # Bold the better cell, with the direction depending on the metric:
        # loss is the one row where smaller wins, and bolding the larger
        # number there would silently invert the whole table's argument.
        win_a = a > b if higher_is_better else a < b
        fa, fb = f"{a:.{places}f}", f"{b:.{places}f}"
        if abs(a - b) > 10 ** -(places + 1):
            fa, fb = (f"**{fa}**", fb) if win_a else (fa, f"**{fb}**")
        out.append(f"| {label} | {fa} | {fb} |")
    return out


BLOCKS = {"headline": render, "memory": render_memory,
          "volatility": render_volatility, "shipped": render_shipped}


def main() -> None:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--run", default="runs/jevon-final")
    p.add_argument("--readme", default=str(ROOT / "README.md"))
    p.add_argument("--check", action="store_true",
                   help="exit 1 if the README block is stale instead of writing")
    args = p.parse_args()

    readme = Path(args.readme)
    text = updated = readme.read_text()
    for name, renderer in BLOCKS.items():
        b, e = begin(name), end(name)
        if b not in updated or e not in updated:
            print(f"markers for {name!r} not found in {readme}; add:\n{b}\n{e}",
                  file=sys.stderr)
            raise SystemExit(2)
        head, rest = updated.split(b, 1)
        _, tail = rest.split(e, 1)
        # The markers are re-emitted here, not by the renderers. They used to
        # be each renderer's job, which meant a new renderer that forgot them
        # deleted its own markers on the first write and made every later run
        # die with "markers not found" -- pointing at the README, which was
        # fine until this script ate it.
        updated = head + "\n".join(block(name, renderer(Path(args.run)))) + tail
    if args.check:
        if updated != text:
            print("README generated tables are stale; run "
                  "scripts/readme_table.py", file=sys.stderr)
            raise SystemExit(1)
        print(f"README generated tables are in sync ({len(BLOCKS)} blocks)")
        return
    readme.write_text(updated)
    print(f"updated {readme} ({len(BLOCKS)} blocks)")


if __name__ == "__main__":
    main()