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))