File size: 10,427 Bytes
0ffbb74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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())