formal-mas / scripts /run_eval.py
NicolasT08's picture
Formal-MAS source code
dfe1826 verified
Raw History Blame Contribute Delete
21.3 kB
"""Run one configuration over a dataset → runs/<UTC time>_<config>_<profile>/.
MODEL_PROFILE=dev python scripts/run_eval.py --config C1 --data data/smoke.jsonl --echo
MODEL_PROFILE=dev python scripts/run_eval.py --config C2 --data data/smoke.jsonl --limit 3
C1-C5 all run on the one graph of orchestrator/graph.py; a configuration is pipeline.yaml plus the
flags of config/ablations/<config>_*.yaml. `solved` comes only from the acceptance predicate.
`--config PG` is the Phase 4 preview, not an experimental configuration: Planner + Generator
only (plans and sketches, no proof), one shared budget per problem:
MODEL_PROFILE=dev python scripts/run_eval.py --config PG --data data/dev_preview.jsonl --echo
Outputs: run_config.json (RNF-2, rewritten at the end), trace.jsonl (RNF-5), results.jsonl
(one line per problem), console.log (a copy of everything printed, CLI only). The profile comes from MODEL_PROFILE (models.yaml → default_profile).
Paid profiles need the team's explicit permission (CLAUDE.md §2).
Spend (llm/spend.py, pipeline.yaml → spend): prints the project's spend so far and what is
left before the first problem, and the run's cost at the end. When a paid call could pass
spend.total_usd or spend.run_usd the problem is `budget_stop` and the run stops there
(run_config.json status `budget_stop`); the remaining problems are not run.
FPR_B (C2 only, `--check`, usually through scripts/run_baseline_b.py): every claimed proof is
formalized by baseline_b.check_claim (Autoformalizer–Verifier loop, critic.k_max attempts, its own
budget of the base pipeline.yaml budget.b_max calls, at most k_max × 2 = 6 in practice); the row gets
`check` (status, accepted, attempts, its own `llm`) and the summary `check` (claimed, verified,
not_verified, fpr_b, calls, cost). C2's own `llm` excludes it. FPR_B = not_verified / (verified +
not_verified); `budget_exhausted` counts as not_verified; a row marked `lean_down`, and checks ended
by provider_error / budget_stop / error, are left out (re-run once, docs/analysis_plan.md).
Lean (lean/runner.py): before the first problem the runner must load the base imports and answer
its probes, in the REPL or with the file back-end. If neither works, no problem is run and no LLM
call is made (status `lean_unavailable`, exit code 2); the same stop happens between problems if
both back-ends give up during the run.
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from formal_mas.agents.generator import run_generator # noqa: E402
from formal_mas.agents.planner import run_planner # noqa: E402
from formal_mas.agents.prompting import fragment # noqa: E402
from formal_mas.baselines.baseline_b import check_claim # noqa: E402
from formal_mas.config import ABLATIONS, ROOT, apply_ablation, load_models, load_pipeline # noqa: E402
from formal_mas.lean.runner import LeanRunner # noqa: E402
from formal_mas.llm.client import LLMClient # noqa: E402
from formal_mas.llm.pricing import in_peak # noqa: E402
from formal_mas.llm.spend import SpendGuard # noqa: E402
from formal_mas.llm.specs import profile_name # noqa: E402
from formal_mas.lean.syntax_check import SyntaxChecker # noqa: E402
from formal_mas.orchestrator.graph import run_graph # noqa: E402
from formal_mas.retrieval.retriever import Retriever # noqa: E402
from formal_mas.run_config import build_run_config, run_start_info, write_run_config # noqa: E402
from formal_mas.tracing import Budget, Tracer, iso, utc_now # noqa: E402
CONFIGS = (*ABLATIONS, "PG")
LOG = logging.getLogger("formal_mas.run_eval")
def load_problems(path: Path, split: str | None, limit: int | None) -> list[dict]:
rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
if split:
rows = [r for r in rows if r.get("split") == split]
return rows[:limit] if limit else rows
def summarize_check(rows: list[dict]) -> dict | None:
"""FPR_B (Eq. 16) over the claims that were checked to the end (verified or not_verified)."""
checks = [r["check"] for r in rows if "check" in r]
if not checks:
return None
status: dict[str, int] = {}
for r in rows: # Lean lost during the row: its check may have failed for Lean, not for the proof
if "check" in r:
key = "lean_down" if r.get("lean_down") and r["check"]["status"] != "not_claimed" else r["check"]["status"]
status[key] = status.get(key, 0) + 1
verified = status.get("verified", 0)
not_verified = status.get("not_verified", 0) + status.get("budget_exhausted", 0) # the budget is the method's
done = verified + not_verified
return {"claimed": sum(1 for r in rows if r.get("claimed_solved")), "status": status, "verified": verified,
"not_verified": not_verified, "fpr_b": round(not_verified / done, 6) if done else None,
"llm_calls": sum(c["llm"]["llm_calls"] for c in checks if "llm" in c),
"cost_usd": round(sum(c["llm"]["cost_usd"] for c in checks if "llm" in c), 6),
"cost_usd_nominal": round(sum(c["llm"]["cost_usd_nominal"] for c in checks if "llm" in c), 6)}
def run_check(p: dict, res: dict, client, runner, retriever, tracer: Tracer, pipeline_cfg: dict, syntax, b_max: int,
rag: bool) -> dict:
"""`--check`: formalize C2's claimed proof (nothing to check if it claimed nothing)."""
if not res.get("claimed_solved"):
return {"status": "not_claimed", "accepted": False}
solution = res.get("solution") or ""
if res.get("final_answer"):
solution += fragment("final_answer", answer=res["final_answer"])
budget = Budget(b_max)
tracer.event("claim_check_start", node="baseline_b_check", b_max=b_max)
out = check_claim(p, solution, client, runner, retriever, budget, tracer,
attempts=int((pipeline_cfg.get("critic") or {}).get("k_max", 3)), syntax=syntax, rag=rag,
k_retry=int((pipeline_cfg.get("retrieval") or {}).get("k_retry", 32)))
return {**out, "llm": budget.summary()}
def summarize(rows: list[dict]) -> dict:
status: dict[str, int] = {}
for r in rows:
status[r["status"]] = status.get(r["status"], 0) + 1
check = summarize_check(rows)
return {"problems": len(rows), "status": status, **({"check": check} if check else {}),
"solved": sum(1 for r in rows if r.get("solved")),
"claimed_solved": sum(1 for r in rows if r.get("claimed_solved")),
"llm_calls": sum(r["llm"]["llm_calls"] for r in rows),
"length_finishes": sum(r["llm"]["length_finishes"] for r in rows),
"cost_usd": round(sum(r["llm"]["cost_usd"] for r in rows), 6),
"cost_usd_nominal": round(sum(r["llm"]["cost_usd_nominal"] for r in rows), 6)}
def run_planner_generator(p: dict, client, runner, retriever, budget: Budget, tracer: Tracer,
pipeline_cfg: dict, syntax=None) -> dict:
"""Phase 4 preview: plan, then sketch every subgoal (status `budget_stop` if either stopped)."""
pc, gc = pipeline_cfg.get("planner") or {}, pipeline_cfg.get("generator") or {}
plan = run_planner(p, client, runner, retriever, budget, tracer, n_max=int(pc.get("n_max", 4)),
retrieve=bool(pc.get("retrieve", True)), check_skeleton=bool(pc.get("check_skeleton", True)),
refute=pc.get("refute"), closing_auto=pc.get("closing_auto"), syntax=syntax)
gen = {"status": "not_run", "subgoals": [], "detail": None}
if plan["plan"] is not None and plan["status"] != "budget_stop":
gen = run_generator(p, plan["plan"], client, retriever, budget, tracer, samples=int(gc.get("samples", 3)),
retrieve=bool(gc.get("retrieve", True)))
if "budget_stop" in (plan["status"], gen["status"]):
status = "budget_stop"
elif plan["plan"] is None:
status = plan["status"]
else:
status = f"plan_{plan['plan']['source']}/sketch_{gen['status']}"
return {"status": status, "solved": None, "planner": plan, "generator": gen}
def spend_line(info: dict) -> str:
return (f"spend: project ${info['total_spent_usd']:.4f} of ${info['total_usd_cap']:.2f} "
f"(${info['total_remaining_usd']:.4f} left); run cap ${info['run_usd_cap']:.2f}")
def main(argv: list[str] | None = None, *, models_cfg: dict | None = None, pipeline_cfg: dict | None = None,
client_kwargs: dict | None = None, runner_factory=LeanRunner, retriever_factory=Retriever,
runs_dir: Path | None = None, on_run_dir=None) -> int:
"""Keyword arguments are for offline tests (fake transport, clock and Lean; temporary folders);
`on_run_dir(run_dir)` is called once the folder exists (the CLI copies the console there)."""
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--config", required=True, choices=sorted(CONFIGS))
ap.add_argument("--data", type=Path, default=ROOT / "data" / "eval.jsonl")
ap.add_argument("--split", default="dev", help="dev | test | all")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--seed", type=int, default=None, help="default: pipeline.yaml → run.seed")
ap.add_argument("--run-name", default=None, help="suffix for the run folder")
ap.add_argument("--echo", action="store_true", help="print one line per problem")
ap.add_argument("--run-usd", type=float, default=None,
help="lower the per-run spend cap for this run (never above pipeline.yaml → spend.run_usd)")
ap.add_argument("--no-prepass", action="store_true",
help="C3-C5: turn the automation pre-pass off (prepass.enabled: false, recorded in run_config.json)")
ap.add_argument("--check", action="store_true",
help="C2 only: formalize every claimed proof to measure FPR_B (Eq. 16); see the docstring")
args = ap.parse_args(argv)
start = run_start_info() # git state and time at launch; run_config.json is rewritten at the end
if args.check and args.config != "C2":
ap.error("--check is only for C2 (Baseline B)")
if not args.data.exists():
ap.error(f"{args.data} does not exist (data/eval.jsonl arrives in Phase 7; try --data data/smoke.jsonl)")
models_cfg = models_cfg if models_cfg is not None else load_models()
pipeline_cfg = pipeline_cfg if pipeline_cfg is not None else load_pipeline()
check_b_max = int((pipeline_cfg.get("budget") or {}).get("b_max", 0)) # C3-C5's B_max, before any ablation
if args.config in ABLATIONS:
pipeline_cfg = apply_ablation(pipeline_cfg, args.config)
if args.no_prepass:
pipeline_cfg = {**pipeline_cfg, "prepass": {**(pipeline_cfg.get("prepass") or {}), "enabled": False}}
profile = profile_name(models_cfg)
split = None if args.split == "all" else args.split
problems = load_problems(args.data, split, args.limit)
seed = args.seed if args.seed is not None else int((pipeline_cfg.get("run") or {}).get("seed", 0))
b_max = int((pipeline_cfg.get("budget") or {})["b_max"])
flow = pipeline_cfg.get("flow", "full") if args.config in ABLATIONS else "planner_generator"
attempts = int(((pipeline_cfg.get("baselines") or {}).get(flow) or {}).get("attempts", 1))
started = utc_now()
name = args.run_name or ("check" if args.check else None)
run_id = f"{started:%Y%m%dT%H%M%SZ}_{args.config}_{profile}" + (f"_{name}" if name else "")
run_dir = (runs_dir or ROOT / "runs") / run_id
run_dir.mkdir(parents=True, exist_ok=False)
if on_run_dir is not None:
on_run_dir(run_dir)
console = logging.StreamHandler(sys.stderr) # warnings only; INFO events go to the trace
console.setLevel(logging.WARNING)
console.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
logging.getLogger().addHandler(console)
tracer = Tracer(run_dir / "trace.jsonl")
tracer.attach_logging()
spend = SpendGuard.from_config(pipeline_cfg, run_id=run_id)
if args.run_usd is not None and spend.run_usd is not None:
spend.run_usd = min(spend.run_usd, args.run_usd)
if spend.stopped:
LOG.warning("spend guard stopped before the run: %s; every paid call will be refused", spend.stopped)
elif spend.configured:
print(spend_line(spend.info()), flush=True)
else:
LOG.warning("pipeline.yaml has no `spend` section (or no `spend.ledger`): every paid call will be refused")
client = LLMClient(profile, models_cfg=models_cfg, pipeline_cfg=pipeline_cfg, tracer=tracer, spend=spend,
**(client_kwargs or {}))
peak = in_peak(client.priced_models(), started)
if peak:
tracer.event("run_peak_warning", level="warning", models=peak, started=iso(started))
LOG.warning("starting inside a peak-price window for %s (models.yaml → pricing.peak_windows_utc); "
"off-peak is cheaper", peak)
runner = runner_factory() if flow != "baseline_b" or args.check else None
use_rag = ((flow in ("full", "planner_generator") or args.check)
and (pipeline_cfg.get("retrieval") or {}).get("enabled", True))
retriever = retriever_factory() if use_rag else None
lean_cfg = pipeline_cfg.get("lean") or {}
syntax = (SyntaxChecker(runner, float(lean_cfg.get("syntax_timeout_s", 30)))
if runner is not None and lean_cfg.get("syntax_check", False) else None)
def config(**extra) -> dict:
return build_run_config(run_id=run_id, config_name=args.config, seed=seed, dataset=args.data, split=split,
llm_info=client.info(), lean_run_info=runner.run_info() if runner else None,
pipeline_cfg=pipeline_cfg, argv=sys.argv, start=start,
extra={"spend": spend.info(), "flow": flow, **({"retriever": {"mode": retriever.mode,
"warnings": retriever.warnings}}
if retriever is not None else {}),
**({"check": {"attempts": int((pipeline_cfg.get("critic") or {}).get("k_max", 3)),
"b_max": check_b_max, "retrieval": use_rag}} if args.check else {}),
**extra})
write_run_config(run_dir, config(status="running", problems=[p["id"] for p in problems]))
tracer.event("run_start", run_id=run_id, config=args.config, profile=profile, problems=len(problems),
b_max=b_max, attempts=attempts, seed=seed, spend=spend.info())
rows = []
lean_down = None # set when Lean cannot check anything: the run stops (no LLM call is made)
try:
# import Mathlib once (~33 s warm, ~22 min cold on the HDD) and check it really loaded
# (runner probes); if neither the REPL nor the file back-end passes, nothing is run
if runner and not runner.ensure_available():
lean_down = "before the first problem"
tracer.event("run_lean_unavailable", level="error", when=lean_down, warnings=runner.warnings[-5:],
not_run=len(problems))
LOG.error("Lean is not available (neither the REPL nor `lake env lean` loaded %s with the probes); "
"no problem was run and no LLM call was made. See lean_runner_warning in %s",
"+".join(runner.base_imports), run_dir / "trace.jsonl")
problems_to_run = []
else:
problems_to_run = problems
with (run_dir / "results.jsonl").open("a", encoding="utf-8") as results:
for p in problems_to_run:
with tracer.context(problem_id=p["id"]):
budget = Budget(b_max)
t0 = time.perf_counter()
tracer.event("problem_start", config=args.config)
if flow == "planner_generator":
res = run_planner_generator(p, client, runner, retriever, budget, tracer, pipeline_cfg,
syntax=syntax)
else:
res = run_graph(p, client, runner, retriever, budget, tracer, pipeline_cfg, syntax=syntax)
if args.check and res.get("status") != "budget_stop":
res["check"] = run_check(p, res, client, runner, retriever, tracer, pipeline_cfg, syntax,
check_b_max, use_rag)
row = {"problem_id": p["id"], "config": args.config, "profile": profile, "split": p.get("split"),
"area": p.get("area"), "difficulty": p.get("difficulty"), **res,
"llm": budget.summary(), "wall_s": round(time.perf_counter() - t0, 2)}
# Lean still usable after this problem? (instant if the REPL is alive; restarts it
# with the probes otherwise). If not, this row is marked: its failures may be Lean's.
lean_lost = runner is not None and not runner.ensure_available()
if lean_lost:
row["lean_down"] = True
tracer.event("problem_end", status=row["status"], solved=row.get("solved"),
claimed_solved=row.get("claimed_solved"), llm=row["llm"], wall_s=row["wall_s"],
**({"lean_down": True} if lean_lost else {}))
results.write(json.dumps(row, ensure_ascii=False) + "\n")
results.flush()
rows.append(row)
if args.echo:
stage = f" stage={row['stage']}" if row.get("stage") else ""
if "check" in row:
stage += (f" check={row['check']['status']}"
+ (f" check_calls={row['check']['llm']['llm_calls']}" if "llm" in row["check"] else ""))
print(f"{p['id']:<28} {row['status']:<17}{stage} calls={row['llm']['llm_calls']:<3} "
f"cost=${row['llm']['cost_usd']:.4f} length={row['llm']['length_finishes']} "
f"{row['wall_s']:.1f}s", flush=True)
if row["status"] == "budget_stop" or (row.get("check") or {}).get("status") == "budget_stop":
tracer.event("run_budget_stop", level="error", problem_id=p["id"], reason=spend.stopped,
not_run=len(problems) - len(rows))
break
if lean_lost: # neither back-end loads Mathlib any more: stop before more LLM calls
lean_down = f"after {p['id']}"
tracer.event("run_lean_unavailable", level="error", when=lean_down,
warnings=runner.warnings[-5:], not_run=len(problems) - len(rows))
LOG.error("Lean became unavailable after %s; the remaining %d problems were not run",
p["id"], len(problems) - len(rows))
break
finally:
summary = summarize(rows)
summary["spend"] = spend.info()
tracer.event("run_end", **summary)
if lean_down:
status = "lean_unavailable"
elif spend.stopped:
status = "budget_stop"
else:
status = "finished" if len(rows) == len(problems) else "interrupted"
write_run_config(run_dir, config(status=status, problems=[p["id"] for p in problems],
finished_utc=iso(utc_now()), summary=summary))
if runner:
runner.close()
tracer.close()
info = summary["spend"]
print(f"run cost: ${info['run_spent_usd']:.6f} in {info['run_paid_calls']} paid calls"
+ (f"; BUDGET STOP: {spend.stopped}" if spend.stopped else ""), flush=True)
if spend.configured and info["total_spent_usd"] is not None:
print(spend_line(info), flush=True)
shown = run_dir.relative_to(ROOT) if run_dir.is_relative_to(ROOT) else run_dir
print(json.dumps({"run_dir": shown.as_posix(), "status": status, **summary}, ensure_ascii=False))
return 2 if lean_down else 0
class _Tee:
"""Writes to the console and to runs/<run>/console.log (long runs are not piped through `tail`)."""
def __init__(self, stream, log):
self.stream, self.log = stream, log
def write(self, text: str) -> int:
self.log.write(text)
self.log.flush()
return self.stream.write(text)
def flush(self) -> None:
self.stream.flush()
self.log.flush()
def __getattr__(self, name):
return getattr(self.stream, name)
def copy_console(run_dir: Path) -> None:
log = (run_dir / "console.log").open("a", encoding="utf-8")
sys.stdout, sys.stderr = _Tee(sys.stdout, log), _Tee(sys.stderr, log)
if __name__ == "__main__":
raise SystemExit(main(on_run_dir=copy_console))