File size: 21,275 Bytes
dfe1826 | 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 | """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))
|