formal-mas / scripts /export_replays.py
NicolasT08's picture
Formal-MAS source code
dfe1826 verified
Raw History Blame Contribute Delete
16.1 kB
"""Export recorded timelines from runs/ to demo/replays/*.json for the demo's Replay mode.
python scripts/export_replays.py --candidates # list problems worth replaying (prints only)
python scripts/export_replays.py --dry-run # show what SELECTION would export
python scripts/export_replays.py # write demo/replays/*.json + results.md
Reads runs/ (never writes there). Only the paid `experiment` runs of the `test` split are used
(the ones of docs/results/results.md); for each (config, problem) the last row that is not
`budget_stop` is taken.
What a replay keeps (a whitelist, nothing else leaves runs/): per event only the fields of
KEEP; for Generator calls, the strategy and sketch parsed from the model's JSON (no raw model
output, prompts, cache keys, fingerprints, paths, argv, git or platform data); the problem's
metadata and formal statement from data/eval.jsonl, with the informal text dropped for
PutnamBench rows (MAA permission); the result's status, proof, C2 solution and call/cost totals.
results.md: Tables 1 and 2 of docs/results/results.md, copied verbatim.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNS = ROOT / "runs"
OUT = ROOT / "demo" / "replays"
SCHEMA = 1
MAX_TEXT = 4000
# (file stem, problem id, config, title, note) — chosen with the team before exporting.
SELECTION: list[tuple[str, str, str, str, str]] = [
("1_amc12_2001_p9_C5", "amc12_2001_p9", "C5", "Medium: solved after one retry",
"The first Lean attempt is rejected by the parser; the Critic classifies it as `syntax` and retries, "
"and the second attempt is accepted."),
("2_imo_1964_p1_2_C5", "imo_1964_p1_2", "C5", "Hard (IMO 1964): solved by C5",
"Two tactic failures, two retries guided by Lean's errors, then a proof the kernel accepts. "
"C4 (no Critic) does not solve it."),
("3_algebra_absapb_C5", "algebra_absapbon1pabsapbleqsumabsaon1pabsa", "C5",
"Critic: retries and a replan (C4 fails)",
"Missing-lemma errors make the Critic retry with more retrieved lemmas and finally replan; "
"C4, which stops after the first failure, does not solve this problem (see the side-by-side view)."),
("3b_algebra_absapb_C4", "algebra_absapbon1pabsapbleqsumabsaon1pabsa", "C4",
"Same problem without the Critic (C4)",
"C4 is the full system without the Critic: the first failed Lean attempt ends the problem, "
"with no retry and no replan. In the recorded run its 5 LLM calls were answered from the on-disk LLM "
"cache (the same prompts as C5's first steps), so it took 2 s and $0 (nominal cost $0.0039)."),
("4_imo_1979_p1_C2", "imo_1979_p1", "C2", "Baseline B claims a proof: not confirmed by Lean",
"Baseline B writes a natural-language proof and judges it correct itself. Formalizing that proof, "
"Lean accepts none of the attempts, and no configuration solved this problem. The claim is "
"*not confirmed by Lean*; that does not make it incorrect (it may be a correct argument that failed "
"to formalize). A self-assessed claim cannot tell the two apart; only the Lean kernel can."),
]
COMMON = ("event", "level")
KEEP: dict[str, tuple[str, ...]] = {
"problem_start": ("problem_id", "config"),
"prepass": ("targets", "found", "seconds"),
"llm_call": ("role", "model_returned", "latency_s", "cost_usd", "cached", "finish_reason",
"completion_tokens", "reasoning_tokens"),
"plan_proposed": ("subgoals", "closing", "rationale"),
"plan_skeleton": ("stage", "compiles", "errors"),
"plan_refute_check": ("hypotheses_inconsistent", "refuted", "seconds"),
"plan_closing": ("accepted", "reason"),
"plan_closing_auto": ("reason", "failed_reason"),
"plan_fallback": ("reason",),
"plan_syntax_rejected": ("reason", "detail"),
"retrieve": ("subgoal", "expanded", "lemmas"),
"sketch_vote": ("subgoal", "votes", "strategy", "samples", "status"),
"lean_verify": ("status", "sorry", "axioms", "seconds"),
"formalize_failed": ("subgoal", "reason", "detail", "tactics", "errors"),
"formalize_verified": ("subgoal", "tactics"),
"critique": ("subgoal", "stage", "reason", "error_class", "action", "route", "attempts", "replans",
"calls", "missing_identifier", "suggestions"),
"subgoal_solved": ("subgoal", "solved_by", "attempts"),
"assemble": ("accepted", "reason", "errors"),
"pipeline_end": ("status", "solved", "stage", "error_classes", "replans"),
"self_assessment": ("sample", "verdict", "confidence"),
"claim_check_start": (),
"claim_check_attempt": ("attempt", "reason", "error_class"),
"claim_check_end": ("status", "accepted", "attempts", "error_classes"),
"invalid_statement": ("error",),
"llm_retry": (), "llm_json_invalid": (), "llm_json_repaired": (), "llm_length_finish": (),
"problem_end": ("status", "solved", "claimed_solved", "llm", "wall_s"),
}
LLM_SUMMARY = ("llm_calls", "b_max", "cached_calls", "cost_usd")
RESULT_TABLES = ("### Table 1.", "### Table 2.")
__all__ = ["KEEP", "SELECTION", "clean_event", "export_one", "find_row", "results_tables"]
def _ts(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def _clip(value):
if isinstance(value, str) and len(value) > MAX_TEXT:
return value[:MAX_TEXT] + " … (truncated)"
if isinstance(value, list):
return [_clip(v) for v in value]
return value
def clean_event(e: dict, t0: datetime) -> dict | None:
"""Whitelisted copy of a trace event with `t` = seconds since problem_start; None = dropped."""
name = e.get("event")
if name not in KEEP:
return None
out = {"t": round((_ts(e["ts"]) - t0).total_seconds(), 3)} if e.get("ts") else {"t": 0.0}
for key in (*COMMON, *KEEP[name]):
if key in e:
out[key] = _clip(e[key])
if name == "problem_end" and isinstance(out.get("llm"), dict):
out["llm"] = {k: out["llm"][k] for k in LLM_SUMMARY if k in out["llm"]}
if name == "llm_call" and e.get("role") == "generator":
try:
data = json.loads(e.get("content") or "")
except (ValueError, TypeError):
data = None
if isinstance(data, dict) and data.get("sketch"):
out["strategy"], out["sketch"] = data.get("strategy"), _clip(str(data["sketch"]))
return out
def _experiment_runs(config: str) -> list[Path]:
"""test-split runs of `config` with profile `experiment` (not experiment_pro), oldest first."""
out = []
for d in sorted(RUNS.glob(f"*_{config}_experiment*")):
cfg_path = d / "run_config.json"
if not cfg_path.is_file() or not (d / "results.jsonl").is_file():
continue
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
split = (cfg.get("dataset") or {}).get("split")
if cfg.get("profile") == "experiment" and split == "test" and cfg.get("config") == config:
out.append(d)
return out
def find_row(config: str, problem_id: str) -> tuple[Path, dict] | None:
"""(run folder, result row): the last row of the problem that is not budget_stop."""
found = None
for d in _experiment_runs(config):
for line in (d / "results.jsonl").read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
row = json.loads(line)
if row.get("problem_id") == problem_id and row.get("status") != "budget_stop":
found = (d, row)
return found
def _problem_events(run_dir: Path, problem_id: str) -> list[dict]:
"""Events of the problem's last attempt in the run (from its last problem_start)."""
events = []
with (run_dir / "trace.jsonl").open(encoding="utf-8") as fh:
for line in fh:
if f'"{problem_id}"' not in line:
continue
e = json.loads(line)
if e.get("problem_id") != problem_id:
continue
if e.get("event") == "problem_start":
events = []
events.append(e)
return events
def _dataset() -> dict[str, dict]:
path = ROOT / "data" / "eval.jsonl"
return {r["id"]: r for r in map(json.loads, filter(str.strip, path.read_text(encoding="utf-8").splitlines()))}
def _problem(p: dict) -> dict:
withheld = "MAA" in str(p.get("license") or "")
out = {k: p.get(k) for k in ("id", "split", "area", "difficulty", "source", "license")}
out["informal"] = None if withheld else p.get("informal")
if withheld:
out["informal_withheld"] = True
out["statement"] = p.get("statement")
return out
def _result(row: dict, withheld: bool) -> dict:
out = {k: row.get(k) for k in ("status", "solved", "claimed_solved", "stage", "error_classes", "replans", "wall_s")}
out["llm"] = {k: (row.get("llm") or {}).get(k) for k in LLM_SUMMARY}
if row.get("proof") and row.get("solved"):
out["proof"] = row["proof"]
if row.get("config") == "C2" and not withheld: # a C2 solution restates the problem
out["solution"] = _clip(row.get("solution"))
out["final_answer"] = row.get("final_answer")
if row.get("check"):
out["check"] = {"status": row["check"].get("status"), "accepted": row["check"].get("accepted"),
"attempts": len(row["check"].get("attempts") or [])}
return out
def _models(run_dir: Path) -> dict:
cfg = json.loads((run_dir / "run_config.json").read_text(encoding="utf-8"))
roles = ((cfg.get("models") or {}).get("roles") or {})
return {role: spec.get("model_id") for role, spec in roles.items() if isinstance(spec, dict)}
def export_one(problem_id: str, config: str, title: str, note: str, dataset: dict) -> dict:
hit = find_row(config, problem_id)
if hit is None:
raise SystemExit(f"no {config} experiment/test row for {problem_id}")
run_dir, row = hit
raw = _problem_events(run_dir, problem_id)
if not raw or raw[0].get("event") != "problem_start":
raise SystemExit(f"no trace for {problem_id} in {run_dir.name}")
t0 = _ts(raw[0]["ts"])
events = [c for c in (clean_event(e, t0) for e in raw) if c is not None]
problem = _problem(dataset[problem_id])
return {"schema": SCHEMA, "title": title, "note": note, "config": config, "run_id": run_dir.name,
"profile": "experiment", "models": _models(run_dir), "problem": problem, "events": events,
"result": _result(row, bool(problem.get("informal_withheld")))}
def results_tables(text: str) -> str:
"""Tables 1 and 2 of docs/results/results.md (each up to the next `### ` heading)."""
parts = ["# Results on the `test` split (n = 30)\n",
"Copied from `docs/results/results.md` (generated by `docs/results/make_tables.py`). "
"C1 = Baseline A (LLM writes the Lean proof, no feedback), C2 = Baseline B (natural language, "
"self-assessed, never verified), C3 = full system without RAG, C4 = without the Critic, "
"C5 = full system.\n"]
for head in RESULT_TABLES:
m = re.search(rf"^{re.escape(head)}.*?(?=^### |^## |\Z)", text, flags=re.M | re.S)
if m:
parts.append(m.group(0).rstrip() + "\n")
return "\n".join(parts)
def _table_rows(text: str, head: str) -> list[list[str]]:
m = re.search(rf"^{re.escape(head)}.*?(?=^### |^## |\Z)", text, flags=re.M | re.S)
if not m:
return []
rows = [ln.strip().strip("|").split("|") for ln in m.group(0).splitlines() if ln.startswith("|")]
return [[c.strip() for c in r] for r in rows[2:]] # skip the header and the --- line
def _nums(cell: str) -> list[float]:
return [float(x) for x in re.findall(r"[+-]?\d+(?:\.\d+)?", cell)]
def results_data(text: str) -> dict:
"""Numbers of Tables 1 and 2 (results.md) for the demo's plain-language results tab."""
sr, c2 = [], None
for r in _table_rows(text, "### Table 1."):
if r[0].startswith("C2"):
claimed, fpr = _nums(r[4]), _nums(r[5])
c2 = {"claimed": int(claimed[0]), "n": int(claimed[1]), "claimed_pct": claimed[2],
"not_confirmed": int(fpr[0]), "checked": int(fpr[1]), "not_confirmed_pct": fpr[2],
"not_confirmed_ci": fpr[3:5]}
elif re.fullmatch(r"C\d", r[0]):
solved, ci = _nums(r[1]), _nums(r[3])
sr.append({"config": r[0], "solved": int(solved[0]), "n": int(solved[1]), "pct": float(r[2]), "ci": ci[:2]})
tests = []
for r in _table_rows(text, "### Table 2."):
if re.fullmatch(r"H\d", r[0]):
a, b = re.findall(r"C\d", r[1])[:2]
tests.append({"id": r[0], "a": a, "b": b, "delta_pp": _nums(r[4])[0], "ci": _nums(r[5])[:2],
"p_adj": _nums(r[7])[0], "significant": r[8].lower().startswith("yes")})
return {"source": "docs/results/results.md (Tables 1 and 2)", "sr": sr, "c2": c2, "tests": tests}
def candidates() -> None:
def rows(config):
out = {}
for d in _experiment_runs(config):
for line in (d / "results.jsonl").read_text(encoding="utf-8").splitlines():
if line.strip():
r = json.loads(line)
if r.get("status") != "budget_stop":
out[r["problem_id"]] = r
return out
c = {k: rows(k) for k in ("C1", "C2", "C3", "C4", "C5")}
print("C5 solved and C4 not (the Critic's contribution):")
for pid, r in c["C5"].items():
if r.get("solved") and not c["C4"].get(pid, {}).get("solved"):
crit = [(x["error_class"], x["action"]) for x in r.get("critiques") or []]
print(f" {pid:45} {r['difficulty']:6} calls={r['llm']['llm_calls']:2} {r['wall_s'] / 60:4.1f} min {crit}")
print("C2 claimed but the Lean check did not verify:")
for pid, r in c["C2"].items():
if r.get("claimed_solved") and (r.get("check") or {}).get("status") != "verified":
by = [k for k in ("C1", "C3", "C4", "C5") if c[k].get(pid, {}).get("solved")]
print(f" {pid:30} {r['difficulty']:6} solved by {by or 'no configuration'}")
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--candidates", action="store_true", help="list candidate problems and exit")
ap.add_argument("--dry-run", action="store_true", help="print what would be written")
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args(argv)
if args.candidates:
candidates()
return 0
dataset = _dataset()
if not args.dry_run:
args.out.mkdir(parents=True, exist_ok=True)
for stem, pid, config, title, note in SELECTION:
rep = export_one(pid, config, title, note, dataset)
r = rep["result"]
print(f"{stem:30} {config} {pid:45} {r['status']:15} events={len(rep['events']):3} "
f"calls={r['llm']['llm_calls']} from {rep['run_id']}")
if not args.dry_run:
(args.out / f"{stem}.json").write_text(json.dumps(rep, ensure_ascii=False, indent=1) + "\n",
encoding="utf-8")
tables = results_tables((ROOT / "docs" / "results" / "results.md").read_text(encoding="utf-8"))
data = results_data((ROOT / "docs" / "results" / "results.md").read_text(encoding="utf-8"))
if not (data["sr"] and data["c2"] and data["tests"]):
raise SystemExit("could not read Tables 1 and 2 of docs/results/results.md")
if not args.dry_run:
(args.out / "results.md").write_text(tables, encoding="utf-8")
(args.out / "results.json").write_text(json.dumps(data, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print(f"results.md: {len(tables)} chars" + (" (dry run, nothing written)" if args.dry_run else f" → {args.out}"))
return 0
if __name__ == "__main__":
sys.exit(main())