beampilot / scripts /make_evaluation_summary.py
Farshadm's picture
Deploy main ca11afa
0ffbb74 verified
Raw History Blame Contribute Delete
10.4 kB
"""Write docs/evaluation-summary.md from the v1 and v2 result files.
Every number in the summary is computed here from ``docs/results/*.jsonl``
(copies of ``runs/evaluation*/results.jsonl`` written by
``scripts/run_evaluation.py``); nothing is typed by hand. Run again after any
change to the result files::
.venv\\Scripts\\python.exe scripts\\make_evaluation_summary.py
"""
from __future__ import annotations
import json
import statistics
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any
REPO = Path(__file__).resolve().parents[1]
OUT = REPO / "docs" / "evaluation-summary.md"
VERSIONS = ("v1", "v2")
RESULT_FILES = {
v: REPO / "docs" / "results" / f"evaluation-{v}.jsonl" for v in VERSIONS
}
CONTENDERS = ("agent", "agent-noview", "scripted", "naive")
# Plans and the commits that fixed them before any run (see the results files).
PLANS = {
"v1": {
"plan": "evaluation-plan.md",
"results": "evaluation-results.md",
"commits": [
("d2f445e", "plan"),
("b3025e9", "deviations 1-7"),
("9ca92df", "deviation 8 and evaluation script"),
("cda0f73", "deviation 9"),
],
"code": "03fb2c1",
},
"v2": {
"plan": "evaluation-plan-v2.md",
"results": "evaluation-results-v2.md",
"commits": [("75fe382", "plan"), ("3241612", "deviations 1-2")],
"code": "3403a76",
},
}
GROUPS: dict[str, Callable[[dict[str, Any]], bool]] = {
"A": lambda r: r["group"] == "A",
"B": lambda r: r["group"] == "B",
"C2-C5": lambda r: r["group"] == "C" and not r["seen"],
"C1 (seen)": lambda r: r["group"] == "C" and r["seen"],
}
def load(path: Path) -> list[dict[str, Any]]:
"""Result rows of one evaluation (rows that were run and scored)."""
rows = [json.loads(line) for line in path.read_text("utf-8").splitlines()]
return [r for r in rows if "correct" in r]
def _pct(value: float | None) -> str:
return "–" if value is None else f"{value * 100:+.1f} %"
def _tally(rows: list[dict[str, Any]]) -> str:
return f"{sum(r['correct'] for r in rows)}/{len(rows)}" if rows else "–"
def correct_table(data: dict[str, list[dict[str, Any]]]) -> list[str]:
header = "| Contender | " + " | ".join(f"{g}: v1 · v2 · both" for g in GROUPS)
lines = [header + " |", "|---|" + "---|" * len(GROUPS)]
for contender in CONTENDERS:
cells = []
for pick in GROUPS.values():
per = [
[r for r in data[v] if r["contender"] == contender and pick(r)]
for v in VERSIONS
]
both = per[0] + per[1]
if not both:
cells.append("–")
continue
cells.append(f"{_tally(per[0])} · {_tally(per[1])} · **{_tally(both)}**")
lines.append(f"| {contender} | " + " | ".join(cells) + " |")
return lines
def effort_table(data: dict[str, list[dict[str, Any]]]) -> list[str]:
lines = [
"| Contender | Version | Runs | Mean positions | Mean model calls | "
"Stopped by a limit | Median / max \\|M² error\\| | Cost |",
"|---|---|---|---|---|---|---|---|",
]
for contender in CONTENDERS:
for version in VERSIONS:
rows = [r for r in data[version] if r["contender"] == contender]
if not rows:
continue
calls = [r["model_calls"] for r in rows if "model_calls" in r]
limited = [
r["case"] for r in rows if "limit" in (r.get("stop_reason") or "")
]
errors = sorted(
abs(r[k])
for r in rows
for k in ("m2_error_x", "m2_error_y")
if r.get(k) is not None
)
error = (
f"{statistics.median(errors) * 100:.1f} % / {errors[-1] * 100:.1f} %"
if errors
else "–"
)
mean_calls = f"{statistics.mean(calls):.1f}" if calls else "–"
stopped = (", ".join(limited) or "none") if calls else "–"
lines.append(
f"| {contender} | {version} | {len(rows)} | "
f"{statistics.mean(r['positions'] for r in rows):.1f} | "
f"{mean_calls} | {stopped} | {error} | "
f"${sum(r.get('cost_usd', 0.0) for r in rows):.2f} |"
)
return lines
def violations_table(data: dict[str, list[dict[str, Any]]]) -> list[str]:
cases = sorted({r["case"] for v in VERSIONS for r in data[v] if r["group"] == "B"})
lines = [
"| Contender | " + " | ".join(f"{c} v1 · v2" for c in cases) + " | Total |",
"|---|" + "---|" * (len(cases) + 1),
]
for contender in ("agent", "scripted", "naive"):
cells, total = [], 0
for case in cases:
counts = []
for version in VERSIONS:
row = next(
r
for r in data[version]
if r["case"] == case and r["contender"] == contender
)
counts.append(row["violations"])
total += row["violations"]
cells.append(f"{counts[0]} · {counts[1]}")
lines.append(f"| {contender} | " + " | ".join(cells) + f" | **{total}** |")
return lines
def failure_table(data: dict[str, list[dict[str, Any]]], case: str) -> list[str]:
lines = [
"| Version | Contender | Verdict (expected) | Correct | M² err x / y | "
"Views | Fault named |",
"|---|---|---|---|---|---|---|",
]
for version in VERSIONS:
for contender in ("agent", "agent-noview"):
row = next(
r
for r in data[version]
if r["case"] == case and r["contender"] == contender
)
named = "yes" if row["fault_noticed"] else "no"
lines.append(
f"| {version} | {contender} | {row['verdict']} ({row['expected']}) | "
f"{'yes' if row['correct'] else '**no**'} | "
f"{_pct(row['m2_error_x'])} / {_pct(row['m2_error_y'])} | "
f"{row.get('views_used', '–')} | {named} |"
)
return lines
def summary(data: dict[str, list[dict[str, Any]]]) -> str:
agent_runs = sum(
1 for v in VERSIONS for r in data[v] if r["contender"].startswith("agent")
)
cost = sum(r.get("cost_usd", 0.0) for v in VERSIONS for r in data[v])
plan_lines = []
for version in VERSIONS:
plan = PLANS[version]
commits = ", ".join(f"`{h}` ({what})" for h, what in plan["commits"])
plan_lines.append(
f"- **{version}:** [{plan['plan']}]({plan['plan']}), pre-registered in "
f"{commits}; run on `main` at `{plan['code']}`. Full results: "
f"[{plan['results']}]({plan['results']})."
)
lines = [
"# BeamPilot evaluation summary (v1 + v2)",
"",
"Generated by `scripts/make_evaluation_summary.py` from "
"`docs/results/evaluation-v1.jsonl` and `docs/results/evaluation-v2.jsonl`. "
"No number on this page is typed by hand.",
"",
"## Plans",
"",
*plan_lines,
"",
"Between v1 and v2 only two things changed: the model-call limit (40 → 60) "
"and a prompt asking for parallel tool calls. Seeds differ.",
"",
"## Read this first",
"",
"- **Small N.** Each case ran twice in total: once in v1 and once in v2, with "
"different seeds. A difference of one or two cases is noise, not a measured "
"effect.",
'- **C1 is "seen".** The prompt was changed after the agent failed the same '
"fault type during development, so C1 is never counted as held out. C2-C5 "
"were held out in v1 and seen in v2.",
"- **The scripted baseline cannot read operator notes, by design.** In "
"group B it runs without the note; its violations are counted below. Group B "
"is therefore not a fair contest.",
"- **The naive scan is a reference only** (10 fixed positions, 1 ms).",
"",
"## Correct verdicts",
"",
"Correct / run, per version and both together. Group C is run twice per "
"version for the agent: with `view_frame` (`agent`) and without "
"(`agent-noview`).",
"",
*correct_table(data),
"",
"## Effort, accuracy and cost",
"",
*effort_table(data),
"",
"## Constraint violations in group B",
"",
"Measurements that broke the operator note (B2: 1 if more than 12 positions "
"were used; B3 has no constraint).",
"",
*violations_table(data),
"",
"## Main failure modes",
"",
"**C1, ghost reflection at every position (seen): saw the ghost but kept "
"the points.** In v1 the agent with `view_frame` named the ghost and gave the "
"right verdict. In v2 it looked at frames only after coverage was met, "
"described the second spot, and still kept those points. Without "
"`view_frame` the agent read the constant `axes_not_aligned` flag as a "
"rotated beam. The wrong runs report a confident, very wrong M².",
"",
*failure_table(data, "C1"),
"",
"**C5, iris clipping the beam over 30-70 mm (no flag fires): never looked.** "
"In no run did the agent look at a frame. It accepted far points inside the "
"iris range and did not act on the rising fit residual, so the verdict was "
"right but M² missed the 3 % tolerance.",
"",
*failure_table(data, "C5"),
"",
f"Total: {agent_runs} agent runs, ${cost:.2f} estimated API cost.",
"",
]
return "\n".join(lines)
def main() -> int:
data = {v: load(RESULT_FILES[v]) for v in VERSIONS}
OUT.write_text(summary(data), encoding="utf-8", newline="\n")
print(f"wrote {OUT.relative_to(REPO)}")
return 0
if __name__ == "__main__":
sys.exit(main())