#!/usr/bin/env python """Build the self-contained HTML experiment report: report/index.html Reads results.json + curves.png from both model run dirs, renders extra figures (probe errors, halting scatter) inline, embeds everything as base64 — one file, viewable on a phone over the tailnet. Usage: .venv/bin/python -m scripts.build_report [--runs-dir DIR] """ import argparse import base64 import io import json import os import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from src.eval import FLAGGED_SIEVE_PREDS CODE_LEGEND = { "O1": "Sharp transition — grokking-like", "O2": "Memorization won (train 100%, val low)", "O3": "Gradual rise — smooth heuristic, NOT grokking by our definition", "O4": "Train never saturated — setup/optimization failure, no interpretation", "O-PARTIAL": "Train saturated, val ended mid-range — partial in-range generalization", "H1": "Halt gate collapsed to the min_steps floor", "H2": "Halt gate pinned at K — never learned to halt", "H3": "Intermediate steps, positive gap correlation — learned compute budget", "H4": "Intermediate steps, no gap correlation — noisy/unused", "P1": "Errors = predictions of 121/143/169/187/209 — learned {2,3,5,7} sieve", "P2": "Scattered errors — memorization / non-transferable heuristics", "P3": "Probe accuracy ≥85% — surprising, needs seed verification", "P4": "Fails trivial evens/5-multiples — pure memorization", } INTERP = { "O1": "The tied cell + heavy weight decay found a structured in-range solution; memorization was repelled. NOT yet evidence of the full sieve — may be skip-evens/5s + divisibility heuristics.", "O2": "The lookup table is the lower-norm solution under these hyperparameters — consistent with the spec's 'memorization is a stronger attractor'.", "O3": "A 'fast' generalizing solution exists that gradient descent finds directly — still publishable, but not grokking.", "O4": "No scientific interpretation until the training setup is fixed.", "O-PARTIAL": "The model learned part of the mapping but never completed the in-range transition within budget.", "H1": "ACT failed — read architecture conclusions with a collapsed gate; the fixed-K ablation becomes the informative run.", "H2": "Never learned to halt — penalty too weak.", "H3": "Evidence of a learned computation budget: more compute steps for larger gaps.", "H4": "The halt signal is not being used meaningfully.", "P1": "Strongest positive result available at this scale: definitive evidence of a learned sieve with the training-range divisor set.", "P2": "No evidence of a divisibility-based algorithm transferring out-of-range.", "P3": "Richer algorithm than the {2,3,5,7} sieve — treat with suspicion, verify across seeds.", "P4": "The in-range solution didn't even transfer the trivial heuristics — strong memorization evidence.", } def b64(png_bytes: bytes) -> str: return "data:image/png;base64," + base64.b64encode(png_bytes).decode() def img_b64(path: str) -> str: with open(path, "rb") as fh: return b64(fh.read()) def probe_fig(result: dict, model: str) -> str: errors = result.get("errors", []) flagged = [e for e in errors if e["pred"] in FLAGGED_SIEVE_PREDS] fig, ax = plt.subplots(figsize=(10, 3.2)) xs, ys, cs, ls = [], [], [], [] for e in errors: xs.append(e["n"]); ys.append(1.0) cs.append("tab:red" if e["pred"] in FLAGGED_SIEVE_PREDS else "tab:blue") ls.append(f"n={e['n']}\npred {e['pred']}\ntarget {e['target']}") ax.scatter(xs, ys, c=cs, s=28, zorder=3) ax.set_xlim(100, 201) ax.set_ylim(0.6, 1.4) ax.set_yticks([1.0]); ax.set_yticklabels(["wrong"]) ax.set_xlabel("input n") ax.set_title(f"{model}: probe errors (red = predicted a no-small-divisor composite " f"{sorted(FLAGGED_SIEVE_PREDS)})") for x, y, l in zip(xs, ys, ls): ax.annotate(l, (x, y), textcoords="offset points", xytext=(0, 10), fontsize=6, ha="center", rotation=90, va="bottom") plt.tight_layout() buf = io.BytesIO(); plt.savefig(buf, dpi=110); plt.close(fig) return b64(buf.getvalue()) def halting_fig(result: dict, cfg: dict) -> str: gaps = result.get("gaps", []) steps = result.get("steps_by_n", []) n = list(range(cfg["range_start"], cfg["range_end"] + 1)) fig, ax = plt.subplots(figsize=(8, 3)) ax.scatter(gaps, steps, s=14) ax.set_xlabel("gap to next prime") ax.set_ylabel("mean halt steps") ax.set_title(f"RNN halting vs gap-to-next-prime (rho={result.get('corr_gap', 0):.3f})") plt.tight_layout() buf = io.BytesIO(); plt.savefig(buf, dpi=110); plt.close(fig) return b64(buf.getvalue()) def meta_table(run_dir: str) -> str: rows = [] try: with open(os.path.join(run_dir, "run_meta.json")) as fh: m = json.load(fh) for k, v in m.items(): rows.append(f"
Signature: {code_badge(sig["code"])}
') if hlt: parts.append(f'Halting: {code_badge(hlt["code"])} ' f'(mean {hlt["mean_steps"]:.2f} steps, ρ(gap, steps) = {hlt["corr_gap"]:.3f})
') parts.append(f'Probe [101,200]: {code_badge(probe.get("code", "?"))} ' f'— accuracy {probe.get("acc", float("nan")):.3f} ' f'({probe.get("correct", 0)}/{probe.get("total", 0)}), ' f'{len(probe.get("errors", []))} errors
') parts.append(f'Val exact-match (last.pt, unselected): {final["val_exact_match"]:.3f} · ' f'(best.pt, val-selected): {res["val_selected"]["val_exact_match"]:.3f} ' f'— best.pt is selection-holed by design
') parts.append(f'Params: {final["params"]:,} · vocab={cfg["vocab"]} · ' f'wd={cfg["weight_decay"]} · lr={cfg["lr"]} · K={cfg["max_steps"]} ' f'· halting={cfg["halting"]}
') parts.append(f'{json.dumps({'signature': sig, 'probe': {k: v for k, v in probe.items() if k != 'errors'}, 'probe_errors': probe.get('errors', []), 'halting': {k: v for k, v in (hlt or {}).items() if k not in ('gaps', 'steps_by_n')}}, indent=2)}")
return "".join(parts)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--runs-dir", default="runs-take2")
ap.add_argument("--out", default="report/index.html")
a = ap.parse_args()
rnn_sec = model_section("rnn", a.runs_dir)
tf_sec = model_section("transformer", a.runs_dir)
meta_rnn = meta_table(os.path.join(a.runs_dir, "rnn", "seed0"))
meta_tf = meta_table(os.path.join(a.runs_dir, "transformer", "seed0"))
html = f"""
Can a minimal architecture grok the next-prime function? Pre-registered experiment · 2026-08-14 · runs on voidlaptop CPU · repo: ssh://meru/~/projects/prime-grokking.git
Grokking — the delayed jump from memorization to generalization — is one of the cleanest windows into the memorization → generalization transition (Q3 of the research agenda). The canonical grokking task is modular addition, which has smooth group structure (a low-complexity solution: rotate on a circle). Next-prime strips that away: it looks smooth but is structurally a combinatorial search (skip candidates, test divisibility, stop at the first hit). The hypothesis, from the spec: a weight-tied recurrent cell should be able to represent the algorithmic solution (one divisibility operation reused K times) with fewer effective parameters than a lookup table — so under heavy weight decay, the algorithm should beat memorization. If it can't happen here, in this maximally simple setting, that's evidence architectures need something fundamentally different to cross the gap.
00c696d → b3fe898 → 3fe3935 → 3015189.Every code below has a pre-registered meaning; the report only matches measurements against it.
| Code | Meaning (from preregistration.md) |
|---|---|
| {k} | {v} |
Seed-0 caveat: everything here is one seed. Seeds {{1,2}} are required before any claim. Val EM on best.pt is selection-holed (early stopping uses it); probe + halting use the unselected last checkpoint. Training-range caveat: for n ≤ 100 the sieve only needs divisors {{2,3,5,7}} — in-range generalization does not imply the general algorithm.
| rnn | |
|---|---|
| transformer |
git clone ssh://meru/~/projects/prime-grokking.git && cd prime-grokking python3 -m venv .venv .venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu .venv/bin/pip install numpy matplotlib pytest .venv/bin/pytest tests/ -q scripts/run_experiment.sh rnn 0 scripts/run_experiment.sh transformer 0 .venv/bin/python -m src.eval rnn 0; .venv/bin/python -m src.eval transformer 0 .venv/bin/python -m scripts.plot rnn 0; .venv/bin/python -m scripts.plot transformer 0
Generated by scripts/build_report.py · design docs: design/experiment-spec.md, design/preregistration.md, design/reviews/ · honest-limitations policy: results are compared against the pre-registered matrix verbatim; nothing is interpreted post-hoc.
""" os.makedirs(os.path.dirname(a.out), exist_ok=True) with open(a.out, "w") as fh: fh.write(html) print(f"saved {a.out} ({len(html)//1024} KB)") if __name__ == "__main__": main()