Download tests/test_graph.py from NicolasT08/formal-mas: direct link, hf CLI and curl.
- Browser
- Download file 26.6 kB
-
https://huggingface.co/NicolasT08/formal-mas/resolve/main/tests/test_graph.py
- Command line
-
hf download hf://NicolasT08/formal-mas/tests/test_graph.py
-
curl -L -o test_graph.py https://huggingface.co/NicolasT08/formal-mas/resolve/main/tests/test_graph.py
26.6 kB
| """The graph with fake LLM and Lean: retries, replans, ablations, and Proposition 1 under random | |
| behaviour of both (many seeds): it always ends, never passes b_max, respects Eq. 13.""" | |
| from __future__ import annotations | |
| import json | |
| import random | |
| import re | |
| from pathlib import Path | |
| import pytest | |
| from fake_llm import MODELS_CFG, FakeClock, FakeTransport, pipeline_cfg | |
| from formal_mas.agents.contracts import CriticFeedback, Critique, schemas | |
| from formal_mas.agents.prompting import render | |
| from formal_mas.config import ABLATIONS, apply_ablation, load_pipeline, validate_pipeline | |
| from formal_mas.lean.runner import LeanMessage, LeanResult | |
| from formal_mas.llm.client import LLMClient | |
| from formal_mas.orchestrator.graph import GRAPH, _recursion_limit, limits_from, run_graph | |
| from formal_mas.orchestrator.policy import Limits, structural_bound | |
| from formal_mas.tracing import Budget, Tracer | |
| from test_pipeline_linear import PROBLEM, FakeLean, FakeRetriever, answer, sketch | |
| ROOT = Path(__file__).resolve().parents[1] | |
| BASE = {"flow": "full", "planner": {"n_max": 3}, "generator": {"samples": 3}, "budget": {"b_max": 40}, | |
| "critic": {"k_max": 3, "r_max": 1}, "retrieval": {"enabled": True, "k_retry": 32}, | |
| "prepass": {"enabled": True, "tactics": ["norm_num", "linarith"], "heartbeats": 1000, "timeout_s": 5}} | |
| ONE = json.dumps({"subgoals": ["x = 3"], "closing": "exact h1"}) | |
| REFUTE = {**BASE, "planner": {"n_max": 3, "refute": {"tactics": ["linarith"], "heartbeats": 1000, "timeout_s": 5}}, | |
| "prepass": {"enabled": False}} | |
| def client_for(tmp_path, *script, json_repair=True, cache=False): | |
| clock = FakeClock() | |
| transport = FakeTransport(*script) | |
| tracer = Tracer() | |
| client = LLMClient("experiment", models_cfg=MODELS_CFG, pipeline_cfg=pipeline_cfg(cache=cache, json_repair=json_repair), | |
| tracer=tracer, transport=transport, cache_dir=tmp_path / "cache", clock=clock, sleep=clock.sleep, | |
| monotonic=clock.monotonic) | |
| return client, transport, tracer | |
| def run(tmp_path, lean, *script, cfg=BASE, retriever=None, b_max=None, **kw): | |
| client, transport, tracer = client_for(tmp_path, *script, **kw) | |
| budget = Budget(b_max or cfg["budget"]["b_max"]) | |
| out = run_graph(PROBLEM, client, lean, retriever if retriever is not None else Recorder(), budget, tracer, cfg) | |
| return out, budget, transport, tracer | |
| class Recorder(FakeRetriever): | |
| def __init__(self): | |
| self.queries, self.asked = [], [] | |
| def retrieve(self, query, k=None): | |
| self.queries.append((query, k)) | |
| return super().retrieve(query, k) | |
| def suggest_names(self, ident, n=None): | |
| self.asked.append(ident) | |
| return ["Nat.succ_le_iff"] | |
| class ScriptedLean(FakeLean): | |
| """Lemma `<name>_sg<i>`: the block `BAD:<text>` fails with Lean message <text>; the rest as FakeLean.""" | |
| def verify(self, code, theorem=None, timeout=None): | |
| m = re.search(r"BAD_(\w+)", code) | |
| if theorem and m: | |
| self.files.append((theorem, code)) | |
| text = {"tactic": "linarith failed", "name": "Unknown identifier `nat.foo`", "parse": "unexpected token 'x'; expected term", | |
| "slow": "(deterministic) timeout at `whnf`, maximum number of heartbeats (10) has been reached"}[m.group(1)] | |
| return LeanResult("finished", "repl", [LeanMessage("error", 100, 2, text)], theorem=theorem) | |
| return super().verify(code, theorem, timeout) | |
| def by_role(tactics): | |
| """Autoformalizer answers in order; planner and generator fixed.""" | |
| seq = iter(tactics) | |
| def reply(request): | |
| system = request["messages"][0]["content"] | |
| if "split a theorem" in system: | |
| return ONE | |
| if "turn a natural-language proof sketch" in system: | |
| return json.dumps({"tactics": next(seq, tactics[-1])}) | |
| return sketch() | |
| return reply | |
| def roles(transport): | |
| out = [] | |
| for _, req in transport.calls: | |
| system = req["messages"][0]["content"] | |
| out.append("plan" if "split a theorem" in system else "formal" if "turn a natural-language" in system | |
| else "critic" if "give brief, concrete advice" in system else "sketch") | |
| return out | |
| def events(tracer, name): | |
| return [e for e in tracer.events if e["event"] == name] | |
| # -- retries ---------------------------------------------------------------------------------------- | |
| def test_tactic_failure_retries_through_generate_with_feedback(tmp_path): | |
| out, budget, transport, tracer = run(tmp_path, ScriptedLean(), by_role(["simp -- BAD_tactic", "linarith"])) | |
| assert out["status"] == "solved" and out["error_classes"] == ["tactic_failure"] and out["replans"] == 0 | |
| assert budget.used == 1 + 3 + 1 + 3 + 1 | |
| assert roles(transport) == ["plan", "sketch", "sketch", "sketch", "formal", "sketch", "sketch", "sketch", "formal"] | |
| retry_prompt = transport.calls[5][1]["messages"][1]["content"] | |
| assert "linarith failed" in retry_prompt and "simp -- BAD_tactic" in retry_prompt | |
| assert "previous attempt" not in transport.calls[1][1]["messages"][1]["content"] # first attempt: no feedback | |
| assert events(tracer, "subgoal_solved")[-1]["attempts"] == 2 | |
| assert events(tracer, "critique")[0]["route"] == "GENERATE" | |
| def test_syntax_retries_through_formalize_keeping_the_sketch(tmp_path): | |
| out, budget, transport, tracer = run(tmp_path, ScriptedLean(), by_role(["sorry", "simp -- BAD_parse", "linarith"])) | |
| assert out["status"] == "solved" and out["error_classes"] == ["syntax", "syntax"] | |
| assert roles(transport) == ["plan", "sketch", "sketch", "sketch", "formal", "formal", "formal"] | |
| assert [c["route"] for c in out["critiques"]] == ["FORMALIZE", "FORMALIZE"] | |
| def test_missing_lemma_goes_to_retrieve_with_expanded_query_and_suggestions(tmp_path): | |
| retriever = Recorder() | |
| out, _, transport, _ = run(tmp_path, ScriptedLean(), by_role(["simp -- BAD_name", "linarith"]), retriever=retriever) | |
| assert out["status"] == "solved" and out["error_classes"] == ["missing_lemma"] | |
| assert retriever.asked == ["nat.foo"] and out["critiques"][0]["suggestions"] == ["Nat.succ_le_iff"] | |
| query, k = retriever.queries[-1] | |
| assert query.endswith("nat.foo") and k == 32 and retriever.queries[-2][1] is None | |
| assert any("Nat.succ_le_iff" in req["messages"][1]["content"] for _, req in transport.calls) | |
| assert roles(transport)[-4:] == ["sketch", "sketch", "sketch", "formal"] # RETRIEVE → GENERATE → FORMALIZE | |
| def test_retries_are_new_cache_entries(tmp_path): | |
| """The same failure twice must not replay the cached answer.""" | |
| out, budget, transport, _ = run(tmp_path, ScriptedLean(), by_role(["simp -- BAD_tactic", "simp -- BAD_tactic", "linarith"]), | |
| cache=True) | |
| assert out["status"] == "solved" and len(transport.calls) == budget.used == 1 + 3 * 4 | |
| # -- replan and abandon ------------------------------------------------------------------------------- | |
| def test_k_max_failures_replan_then_abandon(tmp_path): | |
| out, budget, transport, tracer = run(tmp_path, ScriptedLean(), by_role(["simp -- BAD_tactic"])) | |
| assert out["status"] == "failed" and out["replans"] == 1 and out["stage"] == "verify:sg1" | |
| assert [c["action"] for c in out["critiques"]] == ["retry", "retry", "replan", "retry", "retry", "abandon"] | |
| assert budget.used == 2 * (1 + 3 * 4) | |
| assert roles(transport).count("plan") == 2 and len(out["plans"]) == 2 | |
| replan_prompt = next(req for _, req in transport.calls[1:] if "split a theorem" in req["messages"][0]["content"]) | |
| assert "x = 3" in replan_prompt["messages"][1]["content"] and "propose a different" in replan_prompt["messages"][1]["content"] | |
| class Refuting(FakeLean): | |
| """The first `refuted` plans' _ref1 lemma compiles; the rest as FakeLean.""" | |
| def __init__(self, refuted=1): | |
| super().__init__() | |
| self.refuted = refuted | |
| def verify(self, code, theorem=None, timeout=None): | |
| if theorem is None and "_ref1" in code: | |
| self.files.append((theorem, code)) | |
| lines, msgs = code.split("\n"), [] | |
| ok = self.refuted > 0 | |
| self.refuted -= 1 | |
| for no, line in enumerate(lines, start=1): | |
| if line.startswith("theorem ") and not (ok and "_ref1" in line): | |
| msgs.append(LeanMessage("error", no + 2, 2, "failed")) | |
| return LeanResult("finished", "repl", msgs) | |
| return super().verify(code, theorem, timeout) | |
| def test_refuted_plan_replans_saying_which_subgoal(tmp_path): | |
| out, budget, transport, tracer = run(tmp_path, Refuting(), by_role(["linarith"]), cfg=REFUTE) | |
| assert out["status"] == "solved" and out["replans"] == 1 and out["error_classes"] == ["planning"] | |
| assert out["critiques"][0]["reason"] == "refuted:sg1" and out["critiques"][0]["action"] == "replan" | |
| second_plan = next(req for _, req in transport.calls[1:] if "split a theorem" in req["messages"][0]["content"]) | |
| assert "refuted:sg1" in second_plan["messages"][1]["content"] and "x = 3" in second_plan["messages"][1]["content"] | |
| def test_refuted_without_replans_left_goes_on_with_the_trivial_plan(tmp_path): | |
| out, _, _, _ = run(tmp_path, Refuting(refuted=5), by_role(["linarith"]), cfg=REFUTE) | |
| assert out["status"] == "solved" and out["replans"] == 1 | |
| assert out["plans"][-1]["plan"]["source"] == "trivial" # Eq. 12: r ≥ R_max, a_i < k_max → retry | |
| assert out["critiques"][-1]["action"] == "retry" | |
| def test_c4_flags_give_one_attempt_and_no_replan(tmp_path): | |
| cfg = {**BASE, "critic": {"k_max": 1, "r_max": 0}} | |
| out, budget, transport, _ = run(tmp_path, ScriptedLean(), by_role(["simp -- BAD_tactic"]), cfg=cfg) | |
| assert out["status"] == "failed" and budget.used == 1 + 3 + 1 and out["critiques"][0]["action"] == "abandon" | |
| def test_c3_flag_uses_no_lemmas_and_no_suggestions(tmp_path): | |
| retriever = Recorder() | |
| cfg = {**BASE, "retrieval": {"enabled": False}} | |
| out, _, transport, _ = run(tmp_path, ScriptedLean(), by_role(["simp -- BAD_name", "linarith"]), cfg=cfg, retriever=retriever) | |
| assert out["status"] == "solved" and retriever.queries == [] and retriever.asked == [] | |
| assert out["critiques"][0]["suggestions"] == [] | |
| assert all("(none retrieved)" in req["messages"][1]["content"] for _, req in transport.calls) | |
| def test_b_max_abandons_in_the_critic(tmp_path): | |
| out, budget, _, _ = run(tmp_path, ScriptedLean(), by_role(["simp -- BAD_tactic"]), b_max=5) | |
| assert out["status"] == "budget_exhausted" and budget.used == 5 and out["critiques"][-1]["action"] == "abandon" | |
| def test_assembly_failure_is_planning_and_cannot_retry(tmp_path): | |
| class BadAssembly(FakeLean): | |
| def verify(self, code, theorem=None, timeout=None): | |
| if theorem == "p" and "sorry" not in code: | |
| return LeanResult("finished", "repl", [LeanMessage("error", 9, 2, "type mismatch")], theorem=theorem) | |
| return super().verify(code, theorem, timeout) | |
| out, _, _, _ = run(tmp_path, BadAssembly(), by_role(["linarith"]), cfg={**BASE, "prepass": {"enabled": False}}) | |
| assert out["status"] == "failed" and out["stage"] == "assemble" and out["replans"] == 1 and not out["solved"] | |
| assert out["error_classes"] == ["planning", "planning"] | |
| # -- optional LLM feedback ------------------------------------------------------------------------------ | |
| def critic_reply(tactics): | |
| inner = by_role(tactics) | |
| def reply(request): | |
| if "give brief, concrete advice" in request["messages"][0]["content"]: | |
| return json.dumps({"feedback": "Try nlinarith with a square hint."}) | |
| return inner(request) | |
| return reply | |
| def test_llm_feedback_is_optional_and_never_changes_the_action(tmp_path): | |
| tactics = ["simp -- BAD_tactic", "simp -- BAD_tactic", "linarith"] | |
| cfg_on = {**BASE, "critic": {**BASE["critic"], "llm_feedback": True}} | |
| off, _, t_off, _ = run(tmp_path / "off", ScriptedLean(), critic_reply(tactics)) | |
| on, budget, t_on, _ = run(tmp_path / "on", ScriptedLean(), critic_reply(tactics), cfg=cfg_on) | |
| assert "critic" not in roles(t_off) and roles(t_on).count("critic") == 2 | |
| keys = ("error_class", "action", "route", "attempts", "replans") | |
| assert [[c[k] for k in keys] for c in off["critiques"]] == [[c[k] for k in keys] for c in on["critiques"]] | |
| assert budget.used == len(t_on.calls) # it counts for b_max | |
| assert any("Try nlinarith" in req["messages"][1]["content"] for _, req in t_on.calls) | |
| def test_llm_feedback_failure_falls_back_to_the_deterministic_text(tmp_path): | |
| def reply(request): | |
| if "give brief, concrete advice" in request["messages"][0]["content"]: | |
| return "not json" | |
| return by_role(["simp -- BAD_tactic", "linarith"])(request) | |
| cfg_on = {**BASE, "critic": {**BASE["critic"], "llm_feedback": True}} | |
| seq = iter(["simp -- BAD_tactic", "linarith"]) | |
| def scripted(request): | |
| system = request["messages"][0]["content"] | |
| if "turn a natural-language" in system: | |
| return json.dumps({"tactics": next(seq, "linarith")}) | |
| return reply(request) if "give brief" in system or "split a theorem" in system else sketch() | |
| out, _, _, tracer = run(tmp_path, ScriptedLean(), scripted, cfg=cfg_on) | |
| assert out["status"] == "solved" and out["critiques"][0]["llm_feedback"] is False | |
| assert events(tracer, "critic_llm_failed") | |
| # -- nodes never raise -------------------------------------------------------------------------------- | |
| def test_lean_exception_becomes_a_state(tmp_path): | |
| out, _, _, tracer = run(tmp_path, FakeLean(raise_exc=RuntimeError("REPL died")), answer()) | |
| assert out["status"] in ("failed", "error") and not out["solved"] | |
| assert events(tracer, "node_error") and events(tracer, "pipeline_end") | |
| def test_retriever_exception_becomes_a_state(tmp_path): | |
| class Bad(FakeRetriever): | |
| def retrieve(self, query, k=None): | |
| raise RuntimeError("index gone") | |
| out, _, _, tracer = run(tmp_path, FakeLean(), by_role(["linarith"]), retriever=Bad(), cfg={**BASE, "prepass": {"enabled": False}}) | |
| assert out["status"] in ("failed", "error") and not out["solved"] | |
| assert any(e["node"] in ("retrieve", "planner") for e in events(tracer, "node_error")) | |
| def test_bad_config_and_invalid_statement_become_a_state(tmp_path): | |
| out = run_graph(PROBLEM, None, FakeLean(), None, Budget(1), Tracer(), {"flow": "full"}) | |
| assert out["status"] == "error" and out["error_classes"] == [] | |
| out = run_graph({"id": "x", "statement": "def f := 1"}, None, FakeLean(), None, Budget(1), Tracer(), BASE) | |
| assert out["status"] == "invalid_statement" | |
| # -- Proposition 1: random LLM and Lean ----------------------------------------------------------------- | |
| class RandomLean: | |
| """Random verdicts with real-looking messages; never trusts the code it gets.""" | |
| MESSAGES = ["linarith failed", "unsolved goals\n⊢ x = 3", "Unknown identifier `foo.bar`", "unexpected token 'x'; expected term", | |
| "(deterministic) timeout at `whnf`, maximum number of heartbeats (1) has been reached", "Type mismatch"] | |
| def __init__(self, rng, accept): | |
| self.rng, self.accept, self.compiles = rng, accept, 0 | |
| def verify(self, code, theorem=None, timeout=None): | |
| self.compiles += 1 | |
| r = self.rng.random() | |
| if r < 0.03: | |
| return LeanResult(self.rng.choice(["timeout", "crash", "memory_limit"]), "repl", theorem=theorem) | |
| if r < 0.05: | |
| raise RuntimeError("REPL died") | |
| if theorem is None: | |
| msgs = [LeanMessage("error", no + 2, 2, "failed") for no, line in enumerate(code.split("\n"), start=1) | |
| if line.startswith("theorem ") and self.rng.random() > 0.08] | |
| return LeanResult("finished", "repl", msgs) | |
| if "sorry" in code and self.rng.random() < 0.95: | |
| return LeanResult("finished", "repl", [LeanMessage("warning", 8, 8, "declaration uses `sorry`")], theorem=theorem) | |
| if self.rng.random() < self.accept: | |
| return LeanResult("finished", "repl", axioms=["propext"], declarations=[theorem], theorem=theorem) | |
| line = self.rng.choice([1, 2, 100]) | |
| return LeanResult("finished", "repl", [LeanMessage("error", line, 0, self.rng.choice(self.MESSAGES))], theorem=theorem) | |
| class RandomRetriever(FakeRetriever): | |
| def suggest_names(self, ident, n=None): | |
| return ["Foo.bar"] | |
| def random_reply(rng, garbage): | |
| def reply(request): | |
| if rng.random() < garbage: | |
| return rng.choice(["not json", "{}", '{"tactics": 3}']) | |
| system = request["messages"][0]["content"] | |
| if "split a theorem" in system: | |
| n = rng.choice([0, 1, 1, 2, 3, 3, 5]) | |
| return json.dumps({"subgoals": [f"x = {i}" for i in range(n)], "closing": rng.choice(["linarith", "sorry", "omega"])}) | |
| if "turn a natural-language" in system: | |
| return json.dumps({"tactics": rng.choice(["linarith", "simp", "norm_num", "sorry", "omega", "#eval 1", "nlinarith [sq_nonneg x]"])}) | |
| if "give brief, concrete advice" in system: | |
| return json.dumps({"feedback": "advice"}) | |
| return sketch(rng.choice(["induction", "other", "case_analysis"])) | |
| return reply | |
| CONFIGS = { | |
| "C5": ({"k_max": 3, "r_max": 1}, 40), | |
| "C4": ({"k_max": 1, "r_max": 0}, 40), | |
| "tight": ({"k_max": 3, "r_max": 1}, 7), | |
| "wide": ({"k_max": 5, "r_max": 2}, 500), | |
| } | |
| STATUSES = {"solved", "failed", "budget_exhausted", "provider_error", "budget_stop", "invalid_statement", "error"} | |
| def trial(tmp_path, seed, critic, b_max, *, json_repair, llm_feedback, garbage=0.1, accept=0.5): | |
| rng = random.Random(seed) | |
| cfg = {**BASE, "critic": {**critic, "llm_feedback": llm_feedback}, "budget": {"b_max": b_max}} | |
| client, transport, tracer = client_for(tmp_path, random_reply(rng, garbage), json_repair=json_repair) | |
| budget = Budget(b_max) | |
| out = run_graph(PROBLEM, client, RandomLean(rng, accept), RandomRetriever(), budget, tracer, cfg) | |
| return out, budget, transport, tracer, cfg | |
| def test_always_ends_and_respects_eq_13(tmp_path, name): | |
| critic, b_max = CONFIGS[name] | |
| seen = set() | |
| for seed in range(150): | |
| for accept in (0.0, 0.6): | |
| out, budget, transport, tracer, cfg = trial(tmp_path, seed, critic, b_max, json_repair=False, llm_feedback=seed % 2 == 0, | |
| accept=accept) | |
| limits = limits_from(cfg) | |
| assert out["status"] in STATUSES, out | |
| assert not any("Recursion" in str(e.get("error")) for e in events(tracer, "node_error")), seed | |
| assert budget.used <= structural_bound(limits, n_max=3, samples=3), (seed, budget.used) | |
| assert len(transport.calls) <= budget.used <= b_max | |
| assert out["replans"] <= limits.r_max | |
| attempts_per = {} | |
| for c in out["critiques"]: | |
| if c["subgoal"] is not None: | |
| assert c["attempts"] <= limits.k_max | |
| attempts_per[c["replans"]] = attempts_per.get(c["replans"], 0) + 1 | |
| assert len(events(tracer, "pipeline_end")) == 1 | |
| if out["solved"]: | |
| assert out["final"]["accepted"] and out["proof"] | |
| seen.add(out["status"]) | |
| assert {"failed"} <= seen | |
| def test_json_repairs_never_pass_b_max(tmp_path, b_max): | |
| """A repair call counts for b, so Eq. 13's structural term does not cover it: B_max does.""" | |
| for seed in range(120): | |
| _, budget, transport, _, _ = trial(tmp_path, seed, {"k_max": 3, "r_max": 1}, b_max, json_repair=True, llm_feedback=True, | |
| garbage=0.4, accept=0.1) | |
| assert budget.used <= b_max and len(transport.calls) <= b_max | |
| def test_same_seed_gives_the_same_decisions(tmp_path): | |
| for seed in range(25): | |
| a = trial(tmp_path / "a", seed, {"k_max": 3, "r_max": 1}, 40, json_repair=True, llm_feedback=False) | |
| b = trial(tmp_path / "b", seed, {"k_max": 3, "r_max": 1}, 40, json_repair=True, llm_feedback=False) | |
| assert a[0]["critiques"] == b[0]["critiques"] and a[1].used == b[1].used and a[0]["status"] == b[0]["status"] | |
| # -- configurations ----------------------------------------------------------------------------------- | |
| def test_every_ablation_only_sets_flags_over_pipeline_yaml(): | |
| base = load_pipeline() | |
| assert (base["budget"]["b_max"], base["critic"]["k_max"], base["critic"]["r_max"], base["planner"]["n_max"], | |
| base["generator"]["samples"]) == (40, 3, 1, 3, 3) | |
| assert base["critic"]["llm_feedback"] is False | |
| cfgs = {name: apply_ablation(base, name) for name in ABLATIONS} | |
| assert cfgs["C1"]["flow"] == "baseline_a" and cfgs["C2"]["flow"] == "baseline_b" | |
| assert cfgs["C3"]["retrieval"]["enabled"] is False | |
| assert cfgs["C3"]["critic"] == {**base["critic"], "llm_feedback": True} | |
| assert (cfgs["C4"]["critic"]["k_max"], cfgs["C4"]["critic"]["r_max"]) == (1, 0) | |
| assert cfgs["C5"]["critic"] == {**base["critic"], "llm_feedback": True} | |
| assert {k: v for k, v in cfgs["C5"].items() if k != "critic"} == {k: v for k, v in base.items() if k != "critic"} | |
| assert cfgs["C4"]["critic"]["llm_feedback"] is False | |
| for name, cfg in cfgs.items(): | |
| assert cfg["budget"]["b_max"] == (18 if name == "C1" else 40), name # C1: 264/15 dev calls of C5 | |
| assert cfgs["C1"]["baselines"]["baseline_a"]["attempts"] == 18 | |
| assert structural_bound(limits_from(cfgs["C5"]), n_max=3, samples=3) == 40 | |
| def test_validate_pipeline_fails_on_load(): | |
| good = load_pipeline() | |
| validate_pipeline(good) | |
| for bad in ({**good, "flow": "x"}, {**good, "critic": {"k_max": 0, "r_max": 0}}, {k: v for k, v in good.items() if k != "critic"}, | |
| {**good, "prepass": {"enabled": True}}): | |
| with pytest.raises(ValueError): | |
| validate_pipeline(bad) | |
| def test_baselines_run_on_the_same_compiled_graph(tmp_path, flow, key): | |
| reply = (lambda r: json.dumps({"tactics": "linarith"})) if flow == "baseline_a" else \ | |
| (lambda r: json.dumps({"verdict": "correct", "solution": "s", "final_answer": "a"})) | |
| cfg = {**BASE, "flow": flow, "baselines": {flow: {"attempts": 2}}} | |
| client, _, tracer = client_for(tmp_path, reply) | |
| out = run_graph(PROBLEM, client, FakeLean(), None, Budget(40), tracer, cfg) | |
| assert key in out and out["status"] in ("solved", "claimed_solved", "failed"), out | |
| assert events(tracer, "pipeline_end") | |
| def test_there_is_one_compiled_graph(): | |
| from formal_mas.orchestrator import graph | |
| assert graph.GRAPH is GRAPH | |
| assert {"plan", "retrieve", "generate", "formalize", "verify", "critique", "assemble", "done", "fail", "baseline"} <= set(GRAPH.nodes) | |
| assert _recursion_limit(Limits(3, 1, 40), 3) > 2 * (2 + 3 * 15) | |
| # -- contracts ------------------------------------------------------------------------------------------ | |
| def test_critic_contracts_and_schemas_in_sync(): | |
| assert CriticFeedback(feedback="x").feedback == "x" | |
| with pytest.raises(ValueError): | |
| Critique(reason="r", error_class="other", message_class="syntax", action="retry", attempts=1, replans=0, calls=1) | |
| for name in ("CriticFeedback", "Critique"): | |
| assert json.loads((ROOT / "docs" / "schemas" / f"{name}.json").read_text(encoding="utf-8")) == schemas()[name] | |
| def test_prompts_are_unchanged_on_a_first_attempt(): | |
| messages = render("planner", informal="i", declaration="d", header_summary="h", lemmas="l", n_max=3, hypotheses="h1", feedback="") | |
| assert "\n\nPlan the proof." in messages[1]["content"] | |
| # -- regressions from the invariant review ------------------------------------------------------------- | |
| def test_a_replan_with_the_same_plan_does_not_replay_the_cache(tmp_path): | |
| out, budget, transport, _ = run(tmp_path, ScriptedLean(), by_role(["simp -- BAD_tactic"]), cache=True) | |
| # only the planner may repeat its own request if nothing changed; the feedback changes it too | |
| assert out["replans"] == 1 and budget.used == 2 * (1 + 3 * 4) and len(transport.calls) == budget.used | |
| assert budget.cached_calls == 0 | |
| def test_feedback_is_separated_from_the_next_instruction(tmp_path): | |
| _, _, transport, _ = run(tmp_path, ScriptedLean(), by_role(["simp -- BAD_tactic", "linarith"])) | |
| retry = transport.calls[5][1]["messages"][1]["content"] | |
| assert "same mistake.\n\nWrite the sketch." in retry | |
| out, _, transport, _ = run(tmp_path / "b", ScriptedLean(), by_role(["simp -- BAD_tactic"])) | |
| replan = next(r for _, r in transport.calls[1:] if "split a theorem" in r["messages"][0]["content"]) | |
| assert "x = 3\n\nPlan the proof." in replan["messages"][1]["content"] | |
| def test_k_max_distinct_strategies_is_a_planning_error(tmp_path): | |
| labels = iter(["induction"] * 3 + ["other"] * 3 + ["contradiction"] * 3) | |
| inner = by_role(["simp -- BAD_tactic"]) | |
| def reply(request): | |
| if "split a theorem" in request["messages"][0]["content"] or "turn a natural-language" in request["messages"][0]["content"]: | |
| return inner(request) | |
| return sketch(next(labels, "other")) | |
| out, _, _, _ = run(tmp_path, ScriptedLean(), reply) | |
| assert out["error_classes"][:3] == ["tactic_failure", "tactic_failure", "planning"] | |
| assert out["critiques"][2]["action"] == "replan" and out["critiques"][2]["message_class"] == "tactic_failure" | |
| def test_critic_advice_is_a_new_cache_entry_after_a_replan(tmp_path): | |
| cfg_on = {**BASE, "critic": {**BASE["critic"], "llm_feedback": True}} | |
| _, budget, transport, _ = run(tmp_path, ScriptedLean(), critic_reply(["simp -- BAD_tactic"]), cfg=cfg_on, cache=True) | |
| assert budget.cached_calls == 0 and len(transport.calls) == budget.used | |
| def test_same_strategy_k_max_times_keeps_its_class_and_still_replans(tmp_path): | |
| out, _, _, _ = run(tmp_path, ScriptedLean(), by_role(["simp -- BAD_tactic"])) | |
| assert out["error_classes"][:3] == ["tactic_failure"] * 3 and out["critiques"][2]["action"] == "replan" | |
| def test_critique_logs_the_b_that_pi_saw(tmp_path): | |
| cfg_on = {**BASE, "critic": {**BASE["critic"], "llm_feedback": True}} | |
| out, _, _, _ = run(tmp_path, ScriptedLean(), critic_reply(["simp -- BAD_tactic", "linarith"]), cfg=cfg_on) | |
| assert out["critiques"][0]["calls"] == 1 + 3 + 1 and out["critiques"][0]["llm_feedback"] is True | |