acumen 0.0.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- acumen/__init__.py +160 -0
- acumen/_skills/__init__.py +0 -0
- acumen/_skills/data/SKILL.md +109 -0
- acumen/_skills/data/references/authoring.md +91 -0
- acumen/_skills/data/references/benchmark.md +100 -0
- acumen/_skills/data/references/python-api.md +106 -0
- acumen/_skills/data/references/setup.md +94 -0
- acumen/_skills/data/references/ship.md +53 -0
- acumen/_skills/install.py +185 -0
- acumen/assets/banner.svg +501 -0
- acumen/assets/logo.svg +47 -0
- acumen/bench.py +202 -0
- acumen/cli.py +626 -0
- acumen/config.py +204 -0
- acumen/draft.py +202 -0
- acumen/env.py +602 -0
- acumen/grade.py +104 -0
- acumen/improve.py +596 -0
- acumen/logs.py +255 -0
- acumen/paths.py +164 -0
- acumen/procs.py +195 -0
- acumen/prompts.py +942 -0
- acumen/report.py +1858 -0
- acumen/runner.py +343 -0
- acumen/sandbox.py +153 -0
- acumen/scaffold.py +118 -0
- acumen/ship.py +298 -0
- acumen/skills.py +298 -0
- acumen/taskgen.py +424 -0
- acumen/tasks.py +144 -0
- acumen/transcript.py +77 -0
- acumen-0.0.1.dev0.dist-info/METADATA +168 -0
- acumen-0.0.1.dev0.dist-info/RECORD +36 -0
- acumen-0.0.1.dev0.dist-info/WHEEL +4 -0
- acumen-0.0.1.dev0.dist-info/entry_points.txt +3 -0
- acumen-0.0.1.dev0.dist-info/licenses/LICENSE +21 -0
acumen/report.py
ADDED
|
@@ -0,0 +1,1858 @@
|
|
|
1
|
+
"""Aggregate ``result.json`` files into a self-contained HTML report.
|
|
2
|
+
|
|
3
|
+
The report reads **only** ``result.json`` — never the transcripts. Each result is
|
|
4
|
+
the unit of record, so aggregation is just a walk of the run tree, a load per leaf, and a
|
|
5
|
+
few group-bys. Figures are matplotlib rendered to PNG and inlined as base64 data URIs, so
|
|
6
|
+
``report.html`` opens standalone with no sidecar files and no new dependencies.
|
|
7
|
+
|
|
8
|
+
The report is regenerated (overwritten) at each skill version — it always reflects every
|
|
9
|
+
run currently on disk across every arm.
|
|
10
|
+
|
|
11
|
+
The visualisations show **test-split** performance — the held-out measure of whether a
|
|
12
|
+
skill helps. The full per-run table below them still lists both splits, so train runs
|
|
13
|
+
remain inspectable.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import difflib
|
|
20
|
+
import html
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Mapping, Sequence
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import matplotlib
|
|
31
|
+
|
|
32
|
+
matplotlib.use("Agg") # headless: we never open a window, we render straight to PNG bytes
|
|
33
|
+
|
|
34
|
+
import matplotlib.pyplot as plt
|
|
35
|
+
import matplotlib.ticker as mticker
|
|
36
|
+
import numpy as np
|
|
37
|
+
import pandas as pd
|
|
38
|
+
from matplotlib.colors import is_color_like
|
|
39
|
+
from matplotlib.lines import Line2D
|
|
40
|
+
from matplotlib.patches import Patch
|
|
41
|
+
|
|
42
|
+
from acumen.paths import NOSKILL_ARM, RESULT_FILE, TRANSCRIPT_HTML, skill_from_arm
|
|
43
|
+
from acumen.skills import SkillError, read_meta, skill_content, skill_dir
|
|
44
|
+
from acumen.tasks import Task
|
|
45
|
+
|
|
46
|
+
# ── Palette ──────────────────────────────────────────────────────────────────────────
|
|
47
|
+
# A warm-neutral scheme fixed by the maintainer. The page and the plot area are both white,
|
|
48
|
+
# so a figure sits on the page with no visible frame around it and the ink carries the
|
|
49
|
+
# structure. Bars are coloured by model (see the sequential ramp below), with a grey bar
|
|
50
|
+
# pooling every model; train/test is a texture, not a colour.
|
|
51
|
+
INK = "#1c1813" # axes, text, ticks
|
|
52
|
+
PAGE = "#ffffff" # page + figure background
|
|
53
|
+
PLOT_BG = "#ffffff" # the plot area itself; also the hairline between adjacent bars
|
|
54
|
+
SURFACE = "#f7f3ec" # the one tinted surface — the inline skill diffs, so code reads as a block
|
|
55
|
+
BAR = "#565149" # a neutral tone, used where model hue does not apply
|
|
56
|
+
ACCENT = "#b2ac9e" # the light warm tone, for table tints and notes
|
|
57
|
+
|
|
58
|
+
# The model is the hue: a single-hue ramp keyed to model "potency". The most capable model
|
|
59
|
+
# (Fable) is the DARKEST / most saturated — the Claude orange — and each weaker tier is a
|
|
60
|
+
# lighter tint of that same hue. The mapping is fixed by *tier* (parsed from the model id),
|
|
61
|
+
# so a given tier always reads the same colour regardless of which — or how many — models a
|
|
62
|
+
# pass happens to include. This is only the *default*: a caller can recolour any model by id
|
|
63
|
+
# (see :func:`resolve_palette`), while the page and plot surfaces above stay fixed.
|
|
64
|
+
_MODEL_ORDER = ("fable", "opus", "sonnet", "haiku")
|
|
65
|
+
_MODEL_COLOR = {
|
|
66
|
+
"fable": "#d86f53", # Claude orange — most potent, darkest/most saturated
|
|
67
|
+
"opus": "#e18c74", # a lighter tint
|
|
68
|
+
"sonnet": "#e8a794", # lighter still
|
|
69
|
+
"haiku": "#eec0b0", # lightest — least potent
|
|
70
|
+
}
|
|
71
|
+
_OTHER_MODEL_COLOR = "#8a8378" # neutral fallback for an unrecognised model id
|
|
72
|
+
|
|
73
|
+
# Alongside the per-model bars, each cell carries one pooled bar over every model at once —
|
|
74
|
+
# the overall position of the models being evaluated. It is deliberately outside the model
|
|
75
|
+
# ramp: a neutral grey, so it reads as a summary of the hues rather than another hue.
|
|
76
|
+
_ALL_MODELS = "\x00all-models" # sentinel model id; not a value any real id can take
|
|
77
|
+
_ALL_MODELS_LABEL = "all models"
|
|
78
|
+
_ALL_MODELS_COLOR = "#9b968d"
|
|
79
|
+
|
|
80
|
+
# Train vs test is a *texture*, not a colour — so it never competes with the model hue.
|
|
81
|
+
_SPLIT_ORDER = ("train", "test")
|
|
82
|
+
_SPLIT_HATCH = {"train": "////", "test": ""}
|
|
83
|
+
|
|
84
|
+
#: rcParams applied around every figure via :func:`matplotlib.pyplot.rc_context`, so the
|
|
85
|
+
#: report's styling never leaks into a caller's global matplotlib state.
|
|
86
|
+
_RC = {
|
|
87
|
+
"figure.facecolor": PAGE,
|
|
88
|
+
"savefig.facecolor": PAGE,
|
|
89
|
+
"axes.facecolor": PLOT_BG,
|
|
90
|
+
"axes.edgecolor": INK,
|
|
91
|
+
"axes.labelcolor": INK,
|
|
92
|
+
"axes.titlecolor": INK,
|
|
93
|
+
"axes.titlesize": 11,
|
|
94
|
+
"axes.titleweight": "bold",
|
|
95
|
+
"axes.spines.top": False,
|
|
96
|
+
"axes.spines.right": False,
|
|
97
|
+
"text.color": INK,
|
|
98
|
+
"xtick.color": INK,
|
|
99
|
+
"ytick.color": INK,
|
|
100
|
+
"xtick.labelcolor": INK,
|
|
101
|
+
"ytick.labelcolor": INK,
|
|
102
|
+
"font.size": 10,
|
|
103
|
+
"legend.fontsize": 9,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#: The split every figure reports on. Train runs feed the improver; the report measures
|
|
107
|
+
#: held-out (test) performance.
|
|
108
|
+
_REPORTED_SPLIT = "test"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
#: Brand assets bundled with the package (see ``src/acumen/assets``). Inlined as ``data:``
|
|
112
|
+
#: URIs so ``report.html`` stays self-contained — the favicon and the sidebar banner
|
|
113
|
+
#: travel inside the HTML, with no sidecar image files.
|
|
114
|
+
_ASSETS = Path(__file__).parent / "assets"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _asset_data_uri(name: str) -> str:
|
|
118
|
+
"""Base64 ``data:`` URI for a bundled SVG asset, for inline ``src``/``href`` use."""
|
|
119
|
+
encoded = base64.b64encode((_ASSETS / name).read_bytes()).decode("ascii")
|
|
120
|
+
return f"data:image/svg+xml;base64,{encoded}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ReportError(ValueError):
|
|
124
|
+
"""Raised when there is nothing to report on, or the run tree is unreadable."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def arm_label(arm: str) -> str:
|
|
128
|
+
"""Human label for an arm — ``"noskill"`` or a skill version like ``"v1"``.
|
|
129
|
+
|
|
130
|
+
This is the machine-ish form used in the data table and CSV; the figures use the
|
|
131
|
+
prettier :func:`_skill_label`.
|
|
132
|
+
"""
|
|
133
|
+
return NOSKILL_ARM if arm == NOSKILL_ARM else (skill_from_arm(arm) or arm)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _skill_label(arm: str) -> str:
|
|
137
|
+
"""Figure label for an arm — ``"No skill"``, ``"Skill v1"``, ``"Skill v2"``, …"""
|
|
138
|
+
if arm == NOSKILL_ARM:
|
|
139
|
+
return "No skill"
|
|
140
|
+
version = skill_from_arm(arm)
|
|
141
|
+
return f"Skill {version}" if version else arm
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _arm_marker(arm: str) -> str | tuple[int, int, int]:
|
|
145
|
+
"""Scatter marker for an arm — an ✕ for the baseline, then a widening polygon per version.
|
|
146
|
+
|
|
147
|
+
The trade-off figure spends hue on the *model*, the same as every other figure here, so
|
|
148
|
+
the arm needs a second channel: shape. Arms are ordinal, and most shape sets are not, so
|
|
149
|
+
the versions walk a polygon ladder — v1 a triangle, v2 a square, v3 a pentagon — where the
|
|
150
|
+
side count rises with the version and the progression reads in order. matplotlib takes the
|
|
151
|
+
``(numsides, style, angle)`` form, so the ladder is arithmetic rather than a lookup table
|
|
152
|
+
that eventually runs out of shapes.
|
|
153
|
+
|
|
154
|
+
The baseline sits deliberately off the ladder: it is not a version of anything, and an ✕
|
|
155
|
+
says so at a glance. Past roughly v6 the polygons start converging on a circle; there the
|
|
156
|
+
legend and the labelled pooled marks carry the identity instead.
|
|
157
|
+
|
|
158
|
+
Each polygon is drawn point-up, so v1 reads as a triangle and v2 as a diamond rather than
|
|
159
|
+
a square — the side count is what carries the order, not the orientation.
|
|
160
|
+
"""
|
|
161
|
+
if arm == NOSKILL_ARM:
|
|
162
|
+
return "X"
|
|
163
|
+
version = skill_from_arm(arm)
|
|
164
|
+
return (int(version[1:]) + 2, 0, 0) if version else "o"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _arm_sort_key(arm: str) -> int:
|
|
168
|
+
"""Order arms as noskill, v1, v2, … — the order a reader expects to compare in."""
|
|
169
|
+
if arm == NOSKILL_ARM:
|
|
170
|
+
return -1
|
|
171
|
+
version = skill_from_arm(arm)
|
|
172
|
+
return int(version[1:]) if version else 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _fmt_tokens(value: float) -> str:
|
|
176
|
+
"""Compact human token count, e.g. ``118k`` / ``1.2M``."""
|
|
177
|
+
if value >= 1e6:
|
|
178
|
+
return f"{value / 1e6:.1f}M"
|
|
179
|
+
if value >= 1e3:
|
|
180
|
+
return f"{value / 1e3:.0f}k"
|
|
181
|
+
return f"{value:.0f}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _fmt_seconds(value: float) -> str:
|
|
185
|
+
"""Compact human duration, e.g. ``45s`` / ``2.5m`` / ``1.1h``."""
|
|
186
|
+
if value >= 3600:
|
|
187
|
+
return f"{value / 3600:.1f}h"
|
|
188
|
+
if value >= 60:
|
|
189
|
+
return f"{value / 60:.1f}m"
|
|
190
|
+
return f"{value:.0f}s"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_results(runs_root: Path) -> pd.DataFrame:
|
|
194
|
+
"""Load every ``result.json`` under ``runs_root`` into a DataFrame.
|
|
195
|
+
|
|
196
|
+
Parameters
|
|
197
|
+
----------
|
|
198
|
+
runs_root
|
|
199
|
+
The ``runs/`` root directory.
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
One row per completed run. In addition to the ``result.json`` fields, each row carries
|
|
204
|
+
``total_tokens`` and — for the runs table — ``result_path`` and ``transcript_path``
|
|
205
|
+
(the sibling ``transcript.html``, whether or not it exists).
|
|
206
|
+
|
|
207
|
+
Raises
|
|
208
|
+
------
|
|
209
|
+
ReportError
|
|
210
|
+
If ``runs_root`` is missing or holds no readable results.
|
|
211
|
+
"""
|
|
212
|
+
if not runs_root.is_dir():
|
|
213
|
+
raise ReportError(f"no runs directory: {runs_root}")
|
|
214
|
+
|
|
215
|
+
rows: list[dict] = []
|
|
216
|
+
for result_path in sorted(runs_root.rglob(RESULT_FILE)):
|
|
217
|
+
try:
|
|
218
|
+
data = json.loads(result_path.read_text())
|
|
219
|
+
except (OSError, json.JSONDecodeError) as err:
|
|
220
|
+
raise ReportError(f"cannot read {result_path}: {err}") from err
|
|
221
|
+
data["result_path"] = result_path.resolve()
|
|
222
|
+
data["transcript_path"] = (result_path.parent / TRANSCRIPT_HTML).resolve()
|
|
223
|
+
rows.append(data)
|
|
224
|
+
|
|
225
|
+
if not rows:
|
|
226
|
+
raise ReportError(f"no {RESULT_FILE} files under {runs_root} — run `acumen bench` first")
|
|
227
|
+
|
|
228
|
+
df = pd.DataFrame(rows)
|
|
229
|
+
df["total_tokens"] = df["input_tokens"] + df["output_tokens"]
|
|
230
|
+
df["arm_label"] = df["arm"].map(arm_label)
|
|
231
|
+
df["_arm_order"] = df["arm"].map(_arm_sort_key)
|
|
232
|
+
df = df.sort_values(["_arm_order", "split", "task_id", "model", "rep"]).reset_index(drop=True)
|
|
233
|
+
return df
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _arms_in_order(df: pd.DataFrame) -> list[str]:
|
|
237
|
+
"""Arms present in ``df``, noskill first then ascending skill versions."""
|
|
238
|
+
return sorted(df["arm"].unique(), key=_arm_sort_key)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _rate_and_error(successes: pd.Series) -> tuple[float, float]:
|
|
242
|
+
"""Success rate and its binomial standard error for a group of boolean runs."""
|
|
243
|
+
n = len(successes)
|
|
244
|
+
if n == 0:
|
|
245
|
+
return math.nan, 0.0
|
|
246
|
+
p = float(successes.mean())
|
|
247
|
+
return p, math.sqrt(p * (1.0 - p) / n)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _loaded_flags(df: pd.DataFrame) -> pd.Series:
|
|
251
|
+
"""Per-run "loaded the skill under test" booleans, one per row of ``df``.
|
|
252
|
+
|
|
253
|
+
A run whose transcript could not be read leaves ``skill_loaded`` unset — undetermined,
|
|
254
|
+
which is not evidence the skill loaded, so it reads ``False`` here and counts against
|
|
255
|
+
the load rate. Results predating the field are treated the same way.
|
|
256
|
+
"""
|
|
257
|
+
if "skill_loaded" not in df.columns:
|
|
258
|
+
return pd.Series(False, index=df.index, dtype=bool)
|
|
259
|
+
return df["skill_loaded"].fillna(False).astype(bool)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def arm_metrics(df: pd.DataFrame) -> pd.DataFrame:
|
|
263
|
+
"""Per-arm success rate and resource totals for the runs in ``df``.
|
|
264
|
+
|
|
265
|
+
The caller filters ``df`` first — to the test split, and optionally to one task. Each
|
|
266
|
+
row is one arm, in report order.
|
|
267
|
+
|
|
268
|
+
The resource columns here are **totals** — the budget view, "what did this arm cost to
|
|
269
|
+
run" — which is deliberately not what the figures show: those are per-run means (see
|
|
270
|
+
:func:`metrics_figure`). Divide by ``n`` to get from one to the other.
|
|
271
|
+
|
|
272
|
+
Returns
|
|
273
|
+
-------
|
|
274
|
+
Columns ``arm``, ``arm_label``, ``rate``, ``stderr``, ``tokens``, ``cost``, ``time``,
|
|
275
|
+
``n``.
|
|
276
|
+
"""
|
|
277
|
+
rows = []
|
|
278
|
+
for arm in _arms_in_order(df):
|
|
279
|
+
group = df[df["arm"] == arm]
|
|
280
|
+
rate, stderr = _rate_and_error(group["success"])
|
|
281
|
+
rows.append(
|
|
282
|
+
{
|
|
283
|
+
"arm": arm,
|
|
284
|
+
"arm_label": arm_label(arm),
|
|
285
|
+
"rate": rate,
|
|
286
|
+
"stderr": stderr,
|
|
287
|
+
"tokens": float(group["total_tokens"].sum()),
|
|
288
|
+
"cost": float(group["cost_usd"].sum()),
|
|
289
|
+
"time": float(group["duration_s"].sum()),
|
|
290
|
+
"n": len(group),
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
return pd.DataFrame(rows)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ── Figures ──────────────────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
#: The metric columns: (title, key, x-axis formatter, bar-label fn). The key selects the
|
|
300
|
+
#: value; the proportion keys below are special-cased for binomial error bars.
|
|
301
|
+
#:
|
|
302
|
+
#: Every column is a **per-run mean**, so all five are on one footing: a bar answers "what
|
|
303
|
+
#: does one run of this arm look like?", and none of them moves just because an arm happens
|
|
304
|
+
#: to have been run more times. Cost carries three decimals — the same precision as the runs
|
|
305
|
+
#: table, which is now the same unit — because a per-run mean can sit well under a cent.
|
|
306
|
+
_COLUMNS = [
|
|
307
|
+
("Success rate", "rate", mticker.PercentFormatter(1.0), lambda v: f"{v:.0%}"),
|
|
308
|
+
("Skill loaded", "loaded", mticker.PercentFormatter(1.0), lambda v: f"{v:.0%}"),
|
|
309
|
+
("Tokens / run", "tokens", mticker.FuncFormatter(lambda v, _p: _fmt_tokens(v)), _fmt_tokens),
|
|
310
|
+
("Cost / run", "cost", mticker.FuncFormatter(lambda v, _p: f"${v:.3f}"), lambda v: f"${v:.3f}"),
|
|
311
|
+
("Time / run", "time", mticker.FuncFormatter(lambda v, _p: _fmt_seconds(v)), _fmt_seconds),
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
#: Metric key → the boolean column it averages. A proportion is the mean of a 0/1 column, so
|
|
315
|
+
#: it is a per-run mean like the rest; it differs only in being drawn on a fixed 0–1 axis.
|
|
316
|
+
_PROPORTION_COLUMN = {"rate": "success", "loaded": "skill_loaded"}
|
|
317
|
+
|
|
318
|
+
#: Metric key → the resource column it averages (the proportions are handled separately).
|
|
319
|
+
_MEAN_COLUMN = {"tokens": "total_tokens", "cost": "cost_usd", "time": "duration_s"}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _model_tier(model: str) -> str:
|
|
323
|
+
"""Map a model id (e.g. ``claude-opus-5``) onto its family, or ``"other"``."""
|
|
324
|
+
lower = model.lower()
|
|
325
|
+
for tier in _MODEL_ORDER:
|
|
326
|
+
if tier in lower:
|
|
327
|
+
return tier
|
|
328
|
+
return "other"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _model_color(model: str) -> str:
|
|
332
|
+
"""The sequential ramp colour for a model, by tier."""
|
|
333
|
+
return _MODEL_COLOR.get(_model_tier(model), _OTHER_MODEL_COLOR)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
#: A trailing snapshot date on a model id, e.g. the ``-20251001`` on
|
|
337
|
+
#: ``claude-haiku-4-5-20251001``. Some ids pin a dated snapshot and some don't
|
|
338
|
+
#: (``claude-opus-5``); we strip it for display so the legend reads consistently.
|
|
339
|
+
_MODEL_DATE_RE = re.compile(r"-\d{6,8}$")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _model_label(model: str) -> str:
|
|
343
|
+
"""Display label for a model id, with any trailing snapshot date removed.
|
|
344
|
+
|
|
345
|
+
``claude-haiku-4-5-20251001`` -> ``claude-haiku-4-5``; ``claude-opus-5`` is unchanged.
|
|
346
|
+
The raw id is kept everywhere it is data (the runs table, the CSV) — this only tidies
|
|
347
|
+
what the figures show.
|
|
348
|
+
"""
|
|
349
|
+
return _MODEL_DATE_RE.sub("", model)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def resolve_palette(models: Sequence[str], overrides: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
353
|
+
"""Bar colour per model id — the tier defaults, with ``overrides`` merged over them.
|
|
354
|
+
|
|
355
|
+
Parameters
|
|
356
|
+
----------
|
|
357
|
+
models
|
|
358
|
+
Every model id the report covers. The result has an entry for each of them, so a
|
|
359
|
+
figure never has to fall back mid-draw.
|
|
360
|
+
overrides
|
|
361
|
+
Colours keyed by model id, e.g. ``{"claude-opus-5": "#3b7ea1"}``. A key may also be
|
|
362
|
+
the display form with the snapshot date stripped (``claude-haiku-4-5`` for
|
|
363
|
+
``claude-haiku-4-5-20251001``), since that is what the legend shows. Any matplotlib
|
|
364
|
+
colour spec is accepted — hex, a named colour, an ``rgb``/``rgba`` tuple.
|
|
365
|
+
|
|
366
|
+
Raises
|
|
367
|
+
------
|
|
368
|
+
ReportError
|
|
369
|
+
If a colour is unparseable, or a key matches no model in ``models``. Both are
|
|
370
|
+
caught up front so a typo shows as a clear message rather than a miscoloured
|
|
371
|
+
figure or a matplotlib error part-way through rendering.
|
|
372
|
+
"""
|
|
373
|
+
colors = {model: _model_color(model) for model in models}
|
|
374
|
+
if not overrides:
|
|
375
|
+
return colors
|
|
376
|
+
|
|
377
|
+
unparseable = sorted(f"{key}={value!r}" for key, value in overrides.items() if not is_color_like(value))
|
|
378
|
+
if unparseable:
|
|
379
|
+
raise ReportError(f"palette: not a colour: {', '.join(unparseable)}")
|
|
380
|
+
|
|
381
|
+
by_label: dict[str, list[str]] = {}
|
|
382
|
+
for model in models:
|
|
383
|
+
by_label.setdefault(_model_label(model), []).append(model)
|
|
384
|
+
unknown = []
|
|
385
|
+
for key, value in overrides.items():
|
|
386
|
+
if key in colors:
|
|
387
|
+
colors[key] = value
|
|
388
|
+
elif key in by_label:
|
|
389
|
+
for model in by_label[key]:
|
|
390
|
+
colors[model] = value
|
|
391
|
+
else:
|
|
392
|
+
unknown.append(key)
|
|
393
|
+
if unknown:
|
|
394
|
+
known = ", ".join(sorted(models))
|
|
395
|
+
raise ReportError(f"palette: no such model: {', '.join(sorted(unknown))} (models in these runs: {known})")
|
|
396
|
+
return colors
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _models_in_order(df: pd.DataFrame) -> list[str]:
|
|
400
|
+
"""Models present, most-potent first (fable, opus, sonnet, haiku), then any others."""
|
|
401
|
+
|
|
402
|
+
def key(model: str) -> tuple[int, str]:
|
|
403
|
+
tier = _model_tier(model)
|
|
404
|
+
return (_MODEL_ORDER.index(tier) if tier in _MODEL_ORDER else len(_MODEL_ORDER), model)
|
|
405
|
+
|
|
406
|
+
return sorted(df["model"].unique(), key=key)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _bar_rows(models: Sequence[str]) -> list[str]:
|
|
410
|
+
"""The bar slots in a cell: one per model, plus the pooled row when there are several.
|
|
411
|
+
|
|
412
|
+
With a single model the pooled bar would simply restate it, so it is left out.
|
|
413
|
+
"""
|
|
414
|
+
return [*models, _ALL_MODELS] if len(models) > 1 else list(models)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _row_label(model: str) -> str:
|
|
418
|
+
"""Legend label for a bar slot — a model id, or the pooled row's name."""
|
|
419
|
+
return _ALL_MODELS_LABEL if model == _ALL_MODELS else _model_label(model)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _cell_value(df: pd.DataFrame, arm: str, model: str, split: str, key: str) -> tuple[float, float, bool]:
|
|
423
|
+
"""One (arm, model, split) cell's metric value, its error bar, and whether it has runs.
|
|
424
|
+
|
|
425
|
+
Every metric is a **mean over runs**, with the standard error of that mean as the error
|
|
426
|
+
bar — so all five columns are read the same way and none of them is inflated by an arm
|
|
427
|
+
simply having more runs behind it. For the proportions (``rate``, ``loaded``) that mean
|
|
428
|
+
is the fraction of runs and the error is the binomial standard error; for the resources
|
|
429
|
+
it is the per-run average and ``std / sqrt(n)`` (0 with <2 runs, which have no spread to
|
|
430
|
+
report). A proportion is just the mean of a 0/1 column, so the two cases agree.
|
|
431
|
+
|
|
432
|
+
``model`` may be the :data:`_ALL_MODELS` sentinel, which pools every model in the arm.
|
|
433
|
+
Pooling is over *runs*, not over the per-model bars: an under-sampled model must not
|
|
434
|
+
weigh as much as a well-sampled one. So the grey bar sits among the model bars rather
|
|
435
|
+
than above them — it is where an average run lands, whichever model drew it.
|
|
436
|
+
|
|
437
|
+
The third element distinguishes a genuine zero (e.g. a 0% success rate) from a cell with
|
|
438
|
+
no runs at all: a model not run in this arm reserves its slot but draws no bar.
|
|
439
|
+
"""
|
|
440
|
+
rows = (df["arm"] == arm) & (df["split"] == split)
|
|
441
|
+
group = df[rows if model == _ALL_MODELS else rows & (df["model"] == model)]
|
|
442
|
+
if group.empty:
|
|
443
|
+
return 0.0, 0.0, False
|
|
444
|
+
if key in _PROPORTION_COLUMN:
|
|
445
|
+
flags = _loaded_flags(group) if key == "loaded" else group[_PROPORTION_COLUMN[key]]
|
|
446
|
+
rate, error = _rate_and_error(flags)
|
|
447
|
+
return rate, error, True
|
|
448
|
+
values = group[_MEAN_COLUMN[key]]
|
|
449
|
+
mean = float(values.mean())
|
|
450
|
+
error = float(values.std(ddof=1)) / math.sqrt(len(values)) if len(values) > 1 else 0.0
|
|
451
|
+
return mean, error, True
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _draw_cell(
|
|
455
|
+
ax: plt.Axes,
|
|
456
|
+
df: pd.DataFrame,
|
|
457
|
+
arm: str,
|
|
458
|
+
rows: list[str],
|
|
459
|
+
splits: list[str],
|
|
460
|
+
key: str,
|
|
461
|
+
*,
|
|
462
|
+
value_label,
|
|
463
|
+
colors: Mapping[str, str],
|
|
464
|
+
) -> None:
|
|
465
|
+
"""Draw one grid cell — one fixed slot per bar row (colour), grouped by split (texture).
|
|
466
|
+
|
|
467
|
+
``rows`` is the models plus, last, the pooled :data:`_ALL_MODELS` row. Every row gets a
|
|
468
|
+
slot at the same y-position in every cell, so bars line up across skills. A model with no
|
|
469
|
+
runs in this arm reserves its slot but draws nothing — no bar, no error bar, no label —
|
|
470
|
+
so a stretched neighbour can't be mistaken for it.
|
|
471
|
+
"""
|
|
472
|
+
n_models = len(rows)
|
|
473
|
+
n_splits = len(splits)
|
|
474
|
+
group_h = 0.8
|
|
475
|
+
bar_h = group_h / n_splits
|
|
476
|
+
nan = float("nan")
|
|
477
|
+
for si, split in enumerate(splits):
|
|
478
|
+
offset = (si - (n_splits - 1) / 2) * bar_h
|
|
479
|
+
cells = [_cell_value(df, arm, model, split, key) for model in rows]
|
|
480
|
+
# A NaN width draws no bar and no error bar; the y-slot is still occupied.
|
|
481
|
+
bars = ax.barh(
|
|
482
|
+
[m + offset for m in range(n_models)],
|
|
483
|
+
[value if present else nan for value, _err, present in cells],
|
|
484
|
+
bar_h * 0.86,
|
|
485
|
+
color=[colors[model] for model in rows],
|
|
486
|
+
hatch=_SPLIT_HATCH[split] if n_splits > 1 else "",
|
|
487
|
+
edgecolor=PLOT_BG,
|
|
488
|
+
linewidth=0.6,
|
|
489
|
+
xerr=[err if present else nan for _value, err, present in cells],
|
|
490
|
+
capsize=2,
|
|
491
|
+
error_kw={"ecolor": INK, "elinewidth": 1},
|
|
492
|
+
)
|
|
493
|
+
labels = [value_label(value) if present else "" for value, _err, present in cells]
|
|
494
|
+
ax.bar_label(bars, labels=labels, padding=2, fontsize=6, color=INK)
|
|
495
|
+
ax.set_yticks([])
|
|
496
|
+
# One slot per model, tight even padding — half a slot each side, no wasted band.
|
|
497
|
+
ax.set_ylim(-0.5, n_models - 0.5)
|
|
498
|
+
ax.invert_yaxis() # first model (most potent) on top
|
|
499
|
+
ax.grid(axis="x", color=INK, alpha=0.14, linewidth=0.8)
|
|
500
|
+
ax.set_axisbelow(True)
|
|
501
|
+
ax.tick_params(length=0)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def metrics_figure(df: pd.DataFrame, *, split_hue: bool, colors: Mapping[str, str] | None = None) -> plt.Figure:
|
|
505
|
+
"""The metrics grid: one subplot row per skill, one column per metric.
|
|
506
|
+
|
|
507
|
+
Every bar is a mean over runs — success rate, skill-load rate, then tokens, cost and
|
|
508
|
+
time per run — each with the standard error of that mean. Reporting the resources per
|
|
509
|
+
run rather than as totals keeps them comparable with the two rates and with each other:
|
|
510
|
+
an arm covering more tasks or more reps no longer looks more expensive for it. Columns
|
|
511
|
+
share an x-axis so skills are comparable within a metric. The load rate is the share of
|
|
512
|
+
runs that loaded the skill under test — the baseline arm is drawn too, where a bar above
|
|
513
|
+
zero means the baseline is not clean.
|
|
514
|
+
|
|
515
|
+
Inside each cell, bars are coloured by model, with a final grey bar pooling every model
|
|
516
|
+
at once — where an average run lands overall. When ``split_hue`` is set, train and test
|
|
517
|
+
are shown as two textures (test solid, train hatched) rather than two colours, leaving
|
|
518
|
+
the model hue intact.
|
|
519
|
+
|
|
520
|
+
Parameters
|
|
521
|
+
----------
|
|
522
|
+
df
|
|
523
|
+
Results to plot — the full frame, or one task's slice.
|
|
524
|
+
split_hue
|
|
525
|
+
``True`` for the per-task view (train + test as texture); ``False`` for the
|
|
526
|
+
overview (the reported test split only).
|
|
527
|
+
colors
|
|
528
|
+
Bar colour per model id, as returned by :func:`resolve_palette`. It may cover more
|
|
529
|
+
models than ``df`` holds — one map resolved over the whole report is reused for
|
|
530
|
+
every per-task figure, so a model keeps its colour across them. Models missing
|
|
531
|
+
from it fall back to their tier default; ``None`` uses the defaults throughout.
|
|
532
|
+
"""
|
|
533
|
+
arms = _arms_in_order(df)
|
|
534
|
+
models = _models_in_order(df)
|
|
535
|
+
rows = _bar_rows(models)
|
|
536
|
+
resolved = colors or {}
|
|
537
|
+
colors = {model: resolved.get(model, _model_color(model)) for model in models}
|
|
538
|
+
colors[_ALL_MODELS] = _ALL_MODELS_COLOR
|
|
539
|
+
splits = list(_SPLIT_ORDER) if split_hue else [_REPORTED_SPLIT]
|
|
540
|
+
# Every row gets a fixed slot in every arm's cell, so bars align across skills and a
|
|
541
|
+
# model that wasn't run leaves a reserved gap rather than letting its neighbour stretch.
|
|
542
|
+
n_rows = max(1, len(arms))
|
|
543
|
+
max_bars = max(1, len(rows) * len(splits))
|
|
544
|
+
with plt.rc_context(_RC):
|
|
545
|
+
# Height scales with the number of bars per cell; the small constant covers the
|
|
546
|
+
# per-row breathing room, not a fixed block, so few-model plots stay compact.
|
|
547
|
+
row_h = 0.26 * max_bars + 0.22
|
|
548
|
+
fig, axes = plt.subplots(
|
|
549
|
+
n_rows, len(_COLUMNS), sharex="col", squeeze=False, figsize=(10.0, n_rows * row_h + 0.6)
|
|
550
|
+
)
|
|
551
|
+
for c, (title, key, formatter, value_label) in enumerate(_COLUMNS):
|
|
552
|
+
for r, arm in enumerate(arms):
|
|
553
|
+
ax = axes[r][c]
|
|
554
|
+
_draw_cell(ax, df, arm, rows, splits, key, value_label=value_label, colors=colors)
|
|
555
|
+
ax.xaxis.set_major_formatter(formatter)
|
|
556
|
+
ax.xaxis.set_major_locator(mticker.MaxNLocator(3))
|
|
557
|
+
if r == 0:
|
|
558
|
+
ax.set_title(title, fontsize=10)
|
|
559
|
+
if c == 0:
|
|
560
|
+
ax.set_ylabel(_skill_label(arm), rotation=0, ha="right", va="center", fontweight="bold")
|
|
561
|
+
# sharex="col" ties the whole column together, so one xlim covers every row.
|
|
562
|
+
if key in _PROPORTION_COLUMN:
|
|
563
|
+
axes[-1][c].set_xlim(0, 1.28)
|
|
564
|
+
else:
|
|
565
|
+
reach = max(
|
|
566
|
+
(
|
|
567
|
+
v + e
|
|
568
|
+
for arm in arms
|
|
569
|
+
for m in rows
|
|
570
|
+
for s in splits
|
|
571
|
+
for v, e, present in [_cell_value(df, arm, m, s, key)]
|
|
572
|
+
if present
|
|
573
|
+
),
|
|
574
|
+
default=1.0,
|
|
575
|
+
)
|
|
576
|
+
axes[-1][c].set_xlim(0, reach * 1.35 if reach else 1)
|
|
577
|
+
|
|
578
|
+
model_labels = [_row_label(m) for m in rows]
|
|
579
|
+
model_handles = [
|
|
580
|
+
Patch(facecolor=colors[m], edgecolor=PLOT_BG, label=lab) for m, lab in zip(rows, model_labels, strict=True)
|
|
581
|
+
]
|
|
582
|
+
fig.legend(
|
|
583
|
+
model_handles, model_labels, title="model", frameon=False, loc="upper left", bbox_to_anchor=(1.0, 0.98)
|
|
584
|
+
)
|
|
585
|
+
if split_hue:
|
|
586
|
+
split_handles = [Patch(facecolor="#d8d2c6", edgecolor=INK, hatch=_SPLIT_HATCH[s], label=s) for s in splits]
|
|
587
|
+
fig.legend(split_handles, splits, title="split", frameon=False, loc="upper left", bbox_to_anchor=(1.0, 0.5))
|
|
588
|
+
fig.supylabel("Skills", fontweight="bold")
|
|
589
|
+
fig.tight_layout()
|
|
590
|
+
return fig
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _pareto_front(points: Sequence[tuple[float, float]]) -> list[tuple[float, float]]:
|
|
594
|
+
"""The non-dominated ``(cost, rate)`` points, cheapest first.
|
|
595
|
+
|
|
596
|
+
A point is dominated when another costs no more *and* succeeds no less — nothing would make
|
|
597
|
+
you pick it. What survives is the frontier: the best rate available at each price.
|
|
598
|
+
|
|
599
|
+
Sorting by cost ascending and rate descending lets one pass do it. Walking that order, a
|
|
600
|
+
point is on the frontier exactly when it beats the best rate seen so far, since everything
|
|
601
|
+
already passed costs no more. The tie-break on rate matters: at equal cost the better rate
|
|
602
|
+
comes first and knocks out its twin.
|
|
603
|
+
"""
|
|
604
|
+
front: list[tuple[float, float]] = []
|
|
605
|
+
best = -math.inf
|
|
606
|
+
for cost, rate in sorted(points, key=lambda p: (p[0], -p[1])):
|
|
607
|
+
if rate > best:
|
|
608
|
+
front.append((cost, rate))
|
|
609
|
+
best = rate
|
|
610
|
+
return front
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _pareto_steps(front: Sequence[tuple[float, float]], y_min: float) -> tuple[list[float], list[float]]:
|
|
614
|
+
"""The frontier as a staircase: x and y for a path tracing the edge of what was achieved.
|
|
615
|
+
|
|
616
|
+
Between two frontier points the best rate available is the cheaper one's, so the path holds
|
|
617
|
+
that rate until the price of the next one is reached and then steps up — a curve through the
|
|
618
|
+
points would claim results between them that nobody measured. The path drops to the floor at
|
|
619
|
+
the cheapest point, closing the region off: to its left, nothing was achieved at any rate.
|
|
620
|
+
"""
|
|
621
|
+
xs: list[float] = []
|
|
622
|
+
ys: list[float] = []
|
|
623
|
+
held = y_min # the rate carried forward from the previous step, or the floor at the first
|
|
624
|
+
for cost, rate in front:
|
|
625
|
+
xs += [cost, cost]
|
|
626
|
+
ys += [held, rate]
|
|
627
|
+
held = rate
|
|
628
|
+
return xs, ys
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def tradeoff_figure(df: pd.DataFrame, *, colors: Mapping[str, str] | None = None) -> plt.Figure:
|
|
632
|
+
"""Cost per run against success rate — where each skill version buys what, and at what price.
|
|
633
|
+
|
|
634
|
+
The metrics grid reports every measure on its own axis, which answers "how much?" but never
|
|
635
|
+
"was it worth it?". This plots the two headline measures against each other, so the reader
|
|
636
|
+
can see whether a version bought accuracy, saved money, or both. Up and to the left is better.
|
|
637
|
+
|
|
638
|
+
Each model gets its own mark per arm, in the model's colour; each arm also gets a larger grey
|
|
639
|
+
mark pooling every model, carrying standard errors on both axes — the same statistic the
|
|
640
|
+
grid's grey bar reports. Only the pooled marks are labelled; labelling every point would
|
|
641
|
+
collide. Hue stays the *model*, as in every other figure here, so the arm rides a second
|
|
642
|
+
channel: marker shape (see :func:`_arm_marker`). Identity therefore never rests on colour alone.
|
|
643
|
+
|
|
644
|
+
A staircase traces the **Pareto frontier** over every mark shown — the combinations nothing
|
|
645
|
+
else beats on both counts at once. Anything below and to the right of it is dominated:
|
|
646
|
+
something on the line costs less *and* succeeds more, so there is no reason to choose it.
|
|
647
|
+
The frontier is drawn over the pooled marks as well as the per-model ones, so it really is
|
|
648
|
+
the outer edge of the whole plot and no mark can float above it.
|
|
649
|
+
|
|
650
|
+
Cost is anchored at zero, but the rate axis starts just below the lowest mark rather than at
|
|
651
|
+
zero. Anchoring it too would strand every point in the top third of an otherwise empty panel,
|
|
652
|
+
close enough together to hide the differences the figure exists to show. That is safe here in
|
|
653
|
+
a way it would not be on the grid's bars: position carries the value, not the length of
|
|
654
|
+
anything, the axis is labelled in percent throughout, and the pooled marks show their error
|
|
655
|
+
bars — so a small gap cannot read as a large one.
|
|
656
|
+
|
|
657
|
+
Parameters
|
|
658
|
+
----------
|
|
659
|
+
df
|
|
660
|
+
Results from :func:`load_results`. Only the reported (test) split is plotted.
|
|
661
|
+
colors
|
|
662
|
+
Mark colour per model id, as returned by :func:`resolve_palette`; models missing from it
|
|
663
|
+
fall back to their tier default, and ``None`` uses the defaults throughout.
|
|
664
|
+
"""
|
|
665
|
+
arms = _arms_in_order(df)
|
|
666
|
+
models = _models_in_order(df)
|
|
667
|
+
resolved = colors or {}
|
|
668
|
+
colors = {model: resolved.get(model, _model_color(model)) for model in models}
|
|
669
|
+
colors[_ALL_MODELS] = _ALL_MODELS_COLOR
|
|
670
|
+
|
|
671
|
+
# (arm, model, cost, cost_err, rate, rate_err) for every cell with runs behind it. Both
|
|
672
|
+
# coordinates come from _cell_value, so these marks cannot drift from the grid's bars.
|
|
673
|
+
points = []
|
|
674
|
+
for arm in arms:
|
|
675
|
+
for model in [*models, _ALL_MODELS]:
|
|
676
|
+
cost, cost_err, present = _cell_value(df, arm, model, _REPORTED_SPLIT, "cost")
|
|
677
|
+
if not present:
|
|
678
|
+
continue
|
|
679
|
+
rate, rate_err, _ = _cell_value(df, arm, model, _REPORTED_SPLIT, "rate")
|
|
680
|
+
points.append((arm, model, cost, cost_err, rate, rate_err))
|
|
681
|
+
|
|
682
|
+
x_max = max((cost + err for _a, _m, cost, err, _r, _re in points), default=0.0) * 1.15 or 1.0
|
|
683
|
+
# The pooled marks carry error bars and the per-model ones do not, so only the pooled ones
|
|
684
|
+
# reach below their own value. Round down to a tenth so the ticks land on whole percents.
|
|
685
|
+
floors = [rate - err if model == _ALL_MODELS else rate for _a, model, _c, _ce, rate, err in points]
|
|
686
|
+
y_min = max(0.0, math.floor((min(floors, default=0.0) - 0.02) * 10) / 10)
|
|
687
|
+
y_max = 1.04 # a little air over a perfect score, without a tick past 100%
|
|
688
|
+
front = _pareto_front([(cost, rate) for _a, _m, cost, _e, rate, _re in points])
|
|
689
|
+
|
|
690
|
+
with plt.rc_context(_RC):
|
|
691
|
+
fig, ax = plt.subplots(figsize=(5.0, 3.5))
|
|
692
|
+
ax.set_xlim(0, x_max)
|
|
693
|
+
ax.set_ylim(y_min, y_max)
|
|
694
|
+
steps_x, steps_y = _pareto_steps(front, y_min)
|
|
695
|
+
ax.plot(steps_x, steps_y, color=INK, alpha=0.38, linewidth=1.1, zorder=2)
|
|
696
|
+
if front:
|
|
697
|
+
# Named at the foot of its first riser — the cheap-and-poor corner, which by
|
|
698
|
+
# construction has nothing plotted in it.
|
|
699
|
+
ax.annotate(
|
|
700
|
+
"Pareto frontier",
|
|
701
|
+
(front[0][0], y_min),
|
|
702
|
+
textcoords="offset points",
|
|
703
|
+
xytext=(5, 4),
|
|
704
|
+
ha="left",
|
|
705
|
+
va="bottom",
|
|
706
|
+
fontsize=7,
|
|
707
|
+
color=INK,
|
|
708
|
+
alpha=0.6,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
for arm, model, cost, _err, rate, _rate_err in points:
|
|
712
|
+
if model == _ALL_MODELS:
|
|
713
|
+
continue
|
|
714
|
+
ax.plot(
|
|
715
|
+
cost,
|
|
716
|
+
rate,
|
|
717
|
+
marker=_arm_marker(arm),
|
|
718
|
+
color=colors[model],
|
|
719
|
+
markersize=7,
|
|
720
|
+
# An ink edge, not the page colour: the lighter tiers of the model ramp are pale
|
|
721
|
+
# enough that a white outline on a white page dissolves the mark's own shape,
|
|
722
|
+
# which is the channel carrying the arm.
|
|
723
|
+
markeredgecolor=INK,
|
|
724
|
+
markeredgewidth=0.7,
|
|
725
|
+
linestyle="none",
|
|
726
|
+
zorder=3,
|
|
727
|
+
)
|
|
728
|
+
# The pooled marks go on last so they sit above the per-model cloud they summarise.
|
|
729
|
+
for arm, model, cost, cost_err, rate, rate_err in points:
|
|
730
|
+
if model != _ALL_MODELS:
|
|
731
|
+
continue
|
|
732
|
+
ax.errorbar(
|
|
733
|
+
cost,
|
|
734
|
+
rate,
|
|
735
|
+
xerr=cost_err,
|
|
736
|
+
yerr=rate_err,
|
|
737
|
+
marker=_arm_marker(arm),
|
|
738
|
+
color=_ALL_MODELS_COLOR,
|
|
739
|
+
markersize=10,
|
|
740
|
+
markeredgecolor=INK,
|
|
741
|
+
markeredgewidth=0.9,
|
|
742
|
+
linestyle="none",
|
|
743
|
+
capsize=3,
|
|
744
|
+
ecolor=INK,
|
|
745
|
+
elinewidth=1,
|
|
746
|
+
zorder=4,
|
|
747
|
+
)
|
|
748
|
+
ax.annotate(
|
|
749
|
+
_skill_label(arm),
|
|
750
|
+
(cost, rate),
|
|
751
|
+
textcoords="offset points",
|
|
752
|
+
xytext=(10, 6),
|
|
753
|
+
fontsize=8,
|
|
754
|
+
fontweight="bold",
|
|
755
|
+
color=INK,
|
|
756
|
+
zorder=5,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
ax.yaxis.set_major_locator(mticker.MultipleLocator(0.1))
|
|
760
|
+
ax.yaxis.set_major_formatter(mticker.PercentFormatter(1.0, decimals=0))
|
|
761
|
+
ax.xaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _p: f"${v:.3f}"))
|
|
762
|
+
ax.set_xlabel("Cost / run")
|
|
763
|
+
ax.set_ylabel("Success rate")
|
|
764
|
+
ax.grid(color=INK, alpha=0.14, linewidth=0.8)
|
|
765
|
+
ax.set_axisbelow(True)
|
|
766
|
+
# The ticks have no length, so by default the labels sit tight against the spines and
|
|
767
|
+
# the pair nearest the origin collide: the first cost label is centred on the corner,
|
|
768
|
+
# which puts half of it under the rate label sitting at the same height. Dropping the
|
|
769
|
+
# cost labels clears the rate label by height instead, which holds however wide either
|
|
770
|
+
# label runs — unlike nudging them sideways, which depends on the numbers' width.
|
|
771
|
+
ax.tick_params(length=0, pad=4)
|
|
772
|
+
ax.tick_params(axis="x", pad=9)
|
|
773
|
+
|
|
774
|
+
rows = _bar_rows(models)
|
|
775
|
+
model_handles = [
|
|
776
|
+
Line2D(
|
|
777
|
+
[],
|
|
778
|
+
[],
|
|
779
|
+
linestyle="none",
|
|
780
|
+
marker="o",
|
|
781
|
+
color=colors[m],
|
|
782
|
+
markersize=7,
|
|
783
|
+
markeredgecolor=INK,
|
|
784
|
+
label=_row_label(m),
|
|
785
|
+
)
|
|
786
|
+
for m in rows
|
|
787
|
+
]
|
|
788
|
+
fig.legend(
|
|
789
|
+
model_handles,
|
|
790
|
+
[_row_label(m) for m in rows],
|
|
791
|
+
title="model",
|
|
792
|
+
frameon=False,
|
|
793
|
+
loc="upper left",
|
|
794
|
+
bbox_to_anchor=(1.0, 1.0),
|
|
795
|
+
fontsize=8,
|
|
796
|
+
title_fontsize=9,
|
|
797
|
+
labelspacing=0.35,
|
|
798
|
+
handletextpad=0.4,
|
|
799
|
+
borderaxespad=0.0,
|
|
800
|
+
)
|
|
801
|
+
# Shape is the channel on show here, so these proxies are drawn as outlines only: any
|
|
802
|
+
# fill would read as a model colour, and there is no model to name in a key about shape.
|
|
803
|
+
arm_handles = [
|
|
804
|
+
Line2D(
|
|
805
|
+
[],
|
|
806
|
+
[],
|
|
807
|
+
linestyle="none",
|
|
808
|
+
marker=_arm_marker(a),
|
|
809
|
+
markerfacecolor="none",
|
|
810
|
+
markersize=7,
|
|
811
|
+
markeredgecolor=INK,
|
|
812
|
+
markeredgewidth=1.1,
|
|
813
|
+
label=_skill_label(a),
|
|
814
|
+
)
|
|
815
|
+
for a in arms
|
|
816
|
+
]
|
|
817
|
+
fig.legend(
|
|
818
|
+
arm_handles,
|
|
819
|
+
[_skill_label(a) for a in arms],
|
|
820
|
+
title="skill",
|
|
821
|
+
frameon=False,
|
|
822
|
+
loc="upper left",
|
|
823
|
+
bbox_to_anchor=(1.0, 0.52),
|
|
824
|
+
fontsize=8,
|
|
825
|
+
title_fontsize=9,
|
|
826
|
+
labelspacing=0.35,
|
|
827
|
+
handletextpad=0.4,
|
|
828
|
+
borderaxespad=0.0,
|
|
829
|
+
)
|
|
830
|
+
fig.tight_layout()
|
|
831
|
+
return fig
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
#: Pixel width every figure is rendered to, whatever its width in inches. Roughly twice the
|
|
835
|
+
#: widest the text column ever gets, so the marks stay sharp on high-density screens and after
|
|
836
|
+
#: the browser scales the image down to the column.
|
|
837
|
+
_FIGURE_WIDTH_PX = 1800
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def figure_data_uri(fig: plt.Figure) -> str:
|
|
841
|
+
"""Render a figure to a base64 PNG ``data:`` URI, then close it.
|
|
842
|
+
|
|
843
|
+
The dpi is derived from the figure's own width rather than fixed, so every figure lands at
|
|
844
|
+
:data:`_FIGURE_WIDTH_PX` whatever its size in inches. The page scales images to fit one text
|
|
845
|
+
column, so a fixed dpi renders the narrow figures at fewer pixels than the wide ones and
|
|
846
|
+
leaves the browser to upscale them — which is visible as soft, pixelated marks next to a
|
|
847
|
+
crisp one on the same page.
|
|
848
|
+
|
|
849
|
+
The figure background is saved **transparent** (``facecolor="none"``) so the margins
|
|
850
|
+
between subplots pick up the page colour in the HTML but export blank — the axes
|
|
851
|
+
interiors keep their own opaque fill. ``transparent=True`` is deliberately *not* used:
|
|
852
|
+
it would also blank the axes patches.
|
|
853
|
+
"""
|
|
854
|
+
buffer = io.BytesIO()
|
|
855
|
+
dpi = _FIGURE_WIDTH_PX / fig.get_figwidth()
|
|
856
|
+
fig.savefig(buffer, format="png", dpi=dpi, bbox_inches="tight", facecolor="none")
|
|
857
|
+
plt.close(fig)
|
|
858
|
+
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
859
|
+
return f"data:image/png;base64,{encoded}"
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
# ── Significance ─────────────────────────────────────────────────────────────────────
|
|
863
|
+
|
|
864
|
+
#: Resamples behind every p-value and frontier share. Large enough that the resolution floor
|
|
865
|
+
#: (1/resamples) sits far below any threshold anyone reads off the table.
|
|
866
|
+
_BOOTSTRAP_RESAMPLES = 20_000
|
|
867
|
+
|
|
868
|
+
#: Fixed, so regenerating a report never moves its p-values. A conclusion that changed because
|
|
869
|
+
#: the report was rebuilt would be worse than no conclusion.
|
|
870
|
+
_BOOTSTRAP_SEED = 0
|
|
871
|
+
|
|
872
|
+
#: What a resample draws. Runs on the same task are correlated — some tasks are simply harder —
|
|
873
|
+
#: so the task is the independent unit, not the run. Treating all runs as independent would be
|
|
874
|
+
#: pseudoreplication and would understate every standard error. Models are deliberately *not*
|
|
875
|
+
#: part of this: they are a fixed set that was chosen, not a sample to generalise over.
|
|
876
|
+
_CLUSTER_COLUMN = "task_id"
|
|
877
|
+
|
|
878
|
+
#: Below this many tasks the resamples repeat too often to say anything — with one task every
|
|
879
|
+
#: resample is identical and p collapses to 0 or 1. The section says so rather than printing
|
|
880
|
+
#: numbers it cannot support.
|
|
881
|
+
_MIN_CLUSTERS = 3
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _holm(pvalues: Sequence[float]) -> list[float]:
|
|
885
|
+
"""Holm–Bonferroni adjusted p-values, returned in the order given.
|
|
886
|
+
|
|
887
|
+
Controls the probability of *any* false claim across the family — the right target when the
|
|
888
|
+
family feeds a single decision (ship this version or not), rather than the false-discovery
|
|
889
|
+
rate, which earns its keep over hundreds of tests. Holm is also valid under arbitrary
|
|
890
|
+
dependence, which matters here: every comparison reuses the baseline arm and the same
|
|
891
|
+
bootstrap draws, so the tests are far from independent.
|
|
892
|
+
|
|
893
|
+
The running maximum enforces monotonicity — a smaller raw p can never adjust to a larger
|
|
894
|
+
value than one above it in the ordering.
|
|
895
|
+
"""
|
|
896
|
+
order = sorted(range(len(pvalues)), key=lambda i: pvalues[i])
|
|
897
|
+
adjusted = [0.0] * len(pvalues)
|
|
898
|
+
running = 0.0
|
|
899
|
+
for rank, index in enumerate(order):
|
|
900
|
+
running = max(running, min(1.0, pvalues[index] * (len(pvalues) - rank)))
|
|
901
|
+
adjusted[index] = running
|
|
902
|
+
return adjusted
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _cluster_totals(df: pd.DataFrame, arms: Sequence[str]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
906
|
+
"""Summed cost, successes and run counts per (task, arm) over the reported split.
|
|
907
|
+
|
|
908
|
+
These are the sufficient statistics for the bootstrap: both metrics are ratios of sums, so a
|
|
909
|
+
resample never has to touch an individual run again.
|
|
910
|
+
"""
|
|
911
|
+
test = df[df["split"] == _REPORTED_SPLIT]
|
|
912
|
+
clusters = sorted(test[_CLUSTER_COLUMN].unique())
|
|
913
|
+
shape = (len(clusters), len(arms))
|
|
914
|
+
cost, successes, runs = np.zeros(shape), np.zeros(shape), np.zeros(shape)
|
|
915
|
+
for ci, cluster in enumerate(clusters):
|
|
916
|
+
for ai, arm in enumerate(arms):
|
|
917
|
+
rows = test[(test[_CLUSTER_COLUMN] == cluster) & (test["arm"] == arm)]
|
|
918
|
+
cost[ci, ai] = rows["cost_usd"].sum()
|
|
919
|
+
successes[ci, ai] = rows["success"].sum()
|
|
920
|
+
runs[ci, ai] = len(rows)
|
|
921
|
+
return cost, successes, runs
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _bootstrap_arms(
|
|
925
|
+
cost: np.ndarray, successes: np.ndarray, runs: np.ndarray, *, resamples: int, seed: int
|
|
926
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
927
|
+
"""Resample whole tasks and recompute every arm's rate and cost per run.
|
|
928
|
+
|
|
929
|
+
Drawing ``n`` tasks with replacement *is* a multinomial over the tasks, so the cluster counts
|
|
930
|
+
come from one call and the whole bootstrap collapses to two matrix products — every arm and
|
|
931
|
+
every resample at once. Each resample reuses the same drawn tasks for every arm, which is
|
|
932
|
+
what makes the comparison paired: task difficulty cancels instead of adding noise.
|
|
933
|
+
"""
|
|
934
|
+
n_clusters = len(cost)
|
|
935
|
+
rng = np.random.default_rng(seed)
|
|
936
|
+
counts = rng.multinomial(n_clusters, np.full(n_clusters, 1 / n_clusters), size=resamples).astype(float)
|
|
937
|
+
drawn = counts @ runs
|
|
938
|
+
# An arm absent from every drawn task divides by zero; the NaN is dropped downstream rather
|
|
939
|
+
# than being counted as evidence either way.
|
|
940
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
941
|
+
return (counts @ successes) / drawn, (counts @ cost) / drawn
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _frontier_probability(rate: np.ndarray, cost_per_run: np.ndarray) -> np.ndarray:
|
|
945
|
+
"""Share of resamples in which each arm is non-dominated.
|
|
946
|
+
|
|
947
|
+
The same rule as :func:`_pareto_front` — cheaper-or-equal and better-or-equal, strictly so on
|
|
948
|
+
one axis — broadcast over every pair of arms and every resample at once, so the column and
|
|
949
|
+
the plot's staircase cannot disagree. Read it as robustness, not as a test: it says how often
|
|
950
|
+
an arm survives a fresh draw of tasks, and has no null hypothesis to correct for.
|
|
951
|
+
"""
|
|
952
|
+
cost, other_cost = cost_per_run[:, :, None], cost_per_run[:, None, :]
|
|
953
|
+
own, other = rate[:, :, None], rate[:, None, :]
|
|
954
|
+
dominated = (other_cost <= cost) & (other >= own) & ((other_cost < cost) | (other > own))
|
|
955
|
+
return (~dominated.any(axis=2)).mean(axis=0)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _dominance_p(
|
|
959
|
+
rate: np.ndarray, cost_per_run: np.ndarray, challenger: int, reference: int
|
|
960
|
+
) -> tuple[float, float, float]:
|
|
961
|
+
"""One-sided p per axis and their max: does ``challenger`` beat ``reference`` on *both*?
|
|
962
|
+
|
|
963
|
+
An intersection–union test. Dominance is a conjunction — cheaper *and* more accurate — so the
|
|
964
|
+
evidence for it is only as strong as its weakest half, and the max is the p-value. That the
|
|
965
|
+
two axes need no multiplicity correction is a property of the conjunction, not an oversight:
|
|
966
|
+
getting lucky on one axis buys nothing when the other must clear as well.
|
|
967
|
+
|
|
968
|
+
This is what stops a version buying its way in on price. An arm that is dramatically cheaper
|
|
969
|
+
but no more accurate scores overwhelming evidence on cost, none on rate, and fails.
|
|
970
|
+
"""
|
|
971
|
+
d_rate = rate[:, challenger] - rate[:, reference]
|
|
972
|
+
d_cost = cost_per_run[:, challenger] - cost_per_run[:, reference]
|
|
973
|
+
usable = np.isfinite(d_rate) & np.isfinite(d_cost)
|
|
974
|
+
d_rate, d_cost = d_rate[usable], d_cost[usable]
|
|
975
|
+
if not len(d_rate):
|
|
976
|
+
return 1.0, 1.0, 1.0
|
|
977
|
+
floor = 1 / len(d_rate) # the bootstrap cannot resolve below one resample
|
|
978
|
+
p_rate = max(float((d_rate <= 0).mean()), floor)
|
|
979
|
+
p_cost = max(float((d_cost >= 0).mean()), floor)
|
|
980
|
+
return p_rate, p_cost, max(p_rate, p_cost)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
@dataclass(frozen=True)
|
|
984
|
+
class SkillTests:
|
|
985
|
+
"""Whether the arms differ by more than noise, and how robustly.
|
|
986
|
+
|
|
987
|
+
Attributes
|
|
988
|
+
----------
|
|
989
|
+
arms
|
|
990
|
+
One row per arm: its success rate, cost per run, and the share of resamples in which it
|
|
991
|
+
sits on the Pareto frontier.
|
|
992
|
+
comparisons
|
|
993
|
+
One row per claim "this version dominates the baseline", with the per-axis deltas, the
|
|
994
|
+
intersection–union p, and the Holm-adjusted p across them.
|
|
995
|
+
baseline
|
|
996
|
+
The arm everything is measured against — the noskill arm when there is one.
|
|
997
|
+
n_clusters
|
|
998
|
+
Tasks behind the resampling — the real sample size for every p-value here.
|
|
999
|
+
"""
|
|
1000
|
+
|
|
1001
|
+
arms: pd.DataFrame
|
|
1002
|
+
comparisons: pd.DataFrame
|
|
1003
|
+
baseline: str
|
|
1004
|
+
n_clusters: int
|
|
1005
|
+
|
|
1006
|
+
@property
|
|
1007
|
+
def usable(self) -> bool:
|
|
1008
|
+
"""Whether there were enough tasks, and enough arms, to test anything."""
|
|
1009
|
+
return self.n_clusters >= _MIN_CLUSTERS and not self.comparisons.empty
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def skill_tests(df: pd.DataFrame, *, resamples: int = _BOOTSTRAP_RESAMPLES, seed: int = _BOOTSTRAP_SEED) -> SkillTests:
|
|
1013
|
+
"""Test which skill versions beat which, on cost and success rate jointly.
|
|
1014
|
+
|
|
1015
|
+
Deliberately reports no single combined score. Folding rate and cost into one number picks an
|
|
1016
|
+
exchange rate between them on the reader's behalf, and that choice decides the answer: a
|
|
1017
|
+
version that is much cheaper while being *worse* at the task can come out ahead. The
|
|
1018
|
+
dominance test in :func:`_dominance_p` requires both axes to improve, so it cannot be bought
|
|
1019
|
+
off that way, and :func:`_frontier_probability` ranks the arms without needing a threshold.
|
|
1020
|
+
|
|
1021
|
+
Only the versus-baseline claims are tested — the question the benchmark exists to answer, and
|
|
1022
|
+
the family the Holm adjustment covers. Testing every version against every other as well
|
|
1023
|
+
would spend the correction budget on claims nobody makes (whether the *baseline* dominates a
|
|
1024
|
+
skill) and can bury the real result: on a four-arm report it turns a Holm p of 0.03 into 0.15.
|
|
1025
|
+
|
|
1026
|
+
Parameters
|
|
1027
|
+
----------
|
|
1028
|
+
df
|
|
1029
|
+
Results from :func:`load_results`. Only the reported (test) split is used.
|
|
1030
|
+
resamples, seed
|
|
1031
|
+
Bootstrap size and its seed. The default seed is fixed so a rebuilt report reproduces
|
|
1032
|
+
its own p-values exactly.
|
|
1033
|
+
"""
|
|
1034
|
+
arms = _arms_in_order(df)
|
|
1035
|
+
cost, successes, runs = _cluster_totals(df, arms)
|
|
1036
|
+
n_clusters = len(cost)
|
|
1037
|
+
baseline = arms[0] if arms else NOSKILL_ARM # noskill sorts first when it is present
|
|
1038
|
+
if n_clusters < _MIN_CLUSTERS or len(arms) < 2:
|
|
1039
|
+
empty = pd.DataFrame(columns=["challenger", "reference", "d_rate", "d_cost", "p", "p_adjusted"])
|
|
1040
|
+
return SkillTests(
|
|
1041
|
+
arms=pd.DataFrame(columns=["arm", "rate", "cost", "frontier"]),
|
|
1042
|
+
comparisons=empty,
|
|
1043
|
+
baseline=baseline,
|
|
1044
|
+
n_clusters=n_clusters,
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
rate, cost_per_run = _bootstrap_arms(cost, successes, runs, resamples=resamples, seed=seed)
|
|
1048
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
1049
|
+
observed_rate = successes.sum(axis=0) / runs.sum(axis=0)
|
|
1050
|
+
observed_cost = cost.sum(axis=0) / runs.sum(axis=0)
|
|
1051
|
+
arm_rows = pd.DataFrame(
|
|
1052
|
+
{
|
|
1053
|
+
"arm": arms,
|
|
1054
|
+
"rate": observed_rate,
|
|
1055
|
+
"cost": observed_cost,
|
|
1056
|
+
"frontier": _frontier_probability(rate, cost_per_run),
|
|
1057
|
+
}
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
rows = []
|
|
1061
|
+
for challenger in range(1, len(arms)):
|
|
1062
|
+
p_rate, p_cost, p = _dominance_p(rate, cost_per_run, challenger, 0)
|
|
1063
|
+
rows.append(
|
|
1064
|
+
{
|
|
1065
|
+
"challenger": arms[challenger],
|
|
1066
|
+
"reference": baseline,
|
|
1067
|
+
"d_rate": observed_rate[challenger] - observed_rate[0],
|
|
1068
|
+
"d_cost": observed_cost[challenger] - observed_cost[0],
|
|
1069
|
+
"p_rate": p_rate,
|
|
1070
|
+
"p_cost": p_cost,
|
|
1071
|
+
"p": p,
|
|
1072
|
+
}
|
|
1073
|
+
)
|
|
1074
|
+
comparisons = pd.DataFrame(rows)
|
|
1075
|
+
comparisons["p_adjusted"] = _holm(comparisons["p"].tolist())
|
|
1076
|
+
return SkillTests(arms=arm_rows, comparisons=comparisons, baseline=baseline, n_clusters=n_clusters)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _fmt_delta_rate(value: float) -> str:
|
|
1080
|
+
"""A change in success rate, in percentage *points* — 77.8% to 73.3% is -4.4pp, not -4.4%."""
|
|
1081
|
+
return f"{value * 100:+.1f}pp"
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _fmt_delta_cost(value: float) -> str:
|
|
1085
|
+
"""A change in cost per run, with the sign outside the currency: ``-$0.083``."""
|
|
1086
|
+
return f"{'+' if value >= 0 else '-'}${abs(value):.3f}"
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _fmt_p(p: float) -> str:
|
|
1090
|
+
"""A p-value at the precision the bootstrap can actually resolve."""
|
|
1091
|
+
if not math.isfinite(p):
|
|
1092
|
+
return "—"
|
|
1093
|
+
return "<0.001" if p < 0.001 else f"{p:.3f}"
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
#: The table's columns: (header, field, higher-is-better, formatter). The direction is per column
|
|
1097
|
+
#: because "best" does not point one way here — the strongest arm has the *highest* success rate
|
|
1098
|
+
#: and the *lowest* cost, and a reader scanning the bold cells for the winner should not have to
|
|
1099
|
+
#: keep track of which column runs which way. The first few describe an arm on its own; the rest
|
|
1100
|
+
#: compare it with the baseline.
|
|
1101
|
+
_TEST_COLUMNS = (
|
|
1102
|
+
("Success rate", "rate", True, lambda v: f"{v:.1%}"),
|
|
1103
|
+
("Cost / run", "cost", False, lambda v: f"${v:.3f}"),
|
|
1104
|
+
("On frontier", "frontier", True, lambda v: f"{v:.1%}"),
|
|
1105
|
+
("Δ rate", "d_rate", True, _fmt_delta_rate),
|
|
1106
|
+
("Δ cost", "d_cost", False, _fmt_delta_cost),
|
|
1107
|
+
("p", "p", False, _fmt_p),
|
|
1108
|
+
("Holm p", "p_adjusted", False, _fmt_p),
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
#: How many of the above describe the arm itself; the remainder are the comparison, and sit under
|
|
1112
|
+
#: a shared header so it is clear they all refer to the baseline rather than to each other.
|
|
1113
|
+
_ARM_COLUMNS = 3
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _best_cells(values: Sequence[float | None], *, highest: bool) -> set[int]:
|
|
1117
|
+
"""Row indices holding the best value in one column, by that column's own direction.
|
|
1118
|
+
|
|
1119
|
+
A lone value has beaten nothing, so a column with fewer than two populated cells gets no
|
|
1120
|
+
emphasis at all. Ties are all marked rather than arbitrarily crowning whichever came first.
|
|
1121
|
+
"""
|
|
1122
|
+
present = [v for v in values if v is not None and math.isfinite(v)]
|
|
1123
|
+
if len(present) < 2:
|
|
1124
|
+
return set()
|
|
1125
|
+
target = max(present) if highest else min(present)
|
|
1126
|
+
return {i for i, v in enumerate(values) if v is not None and v == target}
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _tests_table_html(tests: SkillTests) -> str:
|
|
1130
|
+
"""The significance table: one row per arm, with the best value in each column set bold."""
|
|
1131
|
+
if not tests.usable:
|
|
1132
|
+
return (
|
|
1133
|
+
f'<div class="note">Not enough to test: a paired bootstrap needs at least {_MIN_CLUSTERS} '
|
|
1134
|
+
f"tasks and two arms to compare (this report has {tests.n_clusters}). The figures above "
|
|
1135
|
+
f"still describe what was measured.</div>"
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
by_arm = {row.challenger: row for row in tests.comparisons.itertuples()}
|
|
1139
|
+
compared = [field for _title, field, _highest, _fmt in _TEST_COLUMNS[_ARM_COLUMNS:]]
|
|
1140
|
+
records = []
|
|
1141
|
+
for row in tests.arms.itertuples():
|
|
1142
|
+
match = by_arm.get(row.arm)
|
|
1143
|
+
# The baseline is not compared with itself: those cells stay empty, and take no part in
|
|
1144
|
+
# deciding which value in the column is best.
|
|
1145
|
+
records.append(
|
|
1146
|
+
{f: getattr(row, f) for f in ("rate", "cost", "frontier")}
|
|
1147
|
+
| {f: (None if match is None else getattr(match, f)) for f in compared}
|
|
1148
|
+
)
|
|
1149
|
+
best = [
|
|
1150
|
+
_best_cells([record[field] for record in records], highest=highest)
|
|
1151
|
+
for _title, field, highest, _fmt in _TEST_COLUMNS
|
|
1152
|
+
]
|
|
1153
|
+
|
|
1154
|
+
body = []
|
|
1155
|
+
for index, (arm, record) in enumerate(zip(tests.arms["arm"], records, strict=True)):
|
|
1156
|
+
cells = [f"<td>{_skill_label(arm)}</td>"]
|
|
1157
|
+
for column, (_title, field, _highest, fmt) in enumerate(_TEST_COLUMNS):
|
|
1158
|
+
value = record[field]
|
|
1159
|
+
text = "—" if value is None else fmt(value)
|
|
1160
|
+
cells.append(f"<td><strong>{text}</strong></td>" if index in best[column] else f"<td>{text}</td>")
|
|
1161
|
+
body.append(f"<tr>{''.join(cells)}</tr>")
|
|
1162
|
+
|
|
1163
|
+
own = "".join(f'<th rowspan="2">{title}</th>' for title, *_ in _TEST_COLUMNS[:_ARM_COLUMNS])
|
|
1164
|
+
versus = "".join(f"<th>{title}</th>" for title, *_ in _TEST_COLUMNS[_ARM_COLUMNS:])
|
|
1165
|
+
span = len(_TEST_COLUMNS) - _ARM_COLUMNS
|
|
1166
|
+
return f"""<div class="table-center"><table class="tests">
|
|
1167
|
+
<thead>
|
|
1168
|
+
<tr><th rowspan="2">Skill</th>{own}<th colspan="{span}">Compared with {_skill_label(tests.baseline)}</th></tr>
|
|
1169
|
+
<tr>{versus}</tr>
|
|
1170
|
+
</thead>
|
|
1171
|
+
<tbody>{"".join(body)}</tbody>
|
|
1172
|
+
</table></div>"""
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
@dataclass(frozen=True)
|
|
1176
|
+
class Report:
|
|
1177
|
+
"""A rendered report and the data behind it."""
|
|
1178
|
+
|
|
1179
|
+
html: str
|
|
1180
|
+
results: pd.DataFrame
|
|
1181
|
+
|
|
1182
|
+
@property
|
|
1183
|
+
def n_runs(self) -> int:
|
|
1184
|
+
"""How many runs the report covers."""
|
|
1185
|
+
return len(self.results)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _integrity_notes(df: pd.DataFrame) -> list[str]:
|
|
1189
|
+
"""Flag arms whose ``skill_loaded`` disagrees with what the arm name claims.
|
|
1190
|
+
|
|
1191
|
+
A skill arm that mostly failed to load the skill under test is not measuring it; a
|
|
1192
|
+
noskill run that did load it is not a clean baseline. Either makes the comparison a lie,
|
|
1193
|
+
so it is surfaced in the report rather than left in the transcripts. Other skills the
|
|
1194
|
+
agent may reach — the ones bundled with the CLI — do not count either way.
|
|
1195
|
+
|
|
1196
|
+
Occasional misses are normal and the per-run table already marks them, so a skill arm is
|
|
1197
|
+
only flagged once fewer than half its runs loaded the skill.
|
|
1198
|
+
"""
|
|
1199
|
+
notes = []
|
|
1200
|
+
if "skill_loaded" not in df.columns:
|
|
1201
|
+
return notes
|
|
1202
|
+
for arm in _arms_in_order(df):
|
|
1203
|
+
group = df[df["arm"] == arm]
|
|
1204
|
+
loaded = int(_loaded_flags(group).sum())
|
|
1205
|
+
if arm == NOSKILL_ARM:
|
|
1206
|
+
if loaded:
|
|
1207
|
+
notes.append(f"baseline arm '{arm}' loaded the skill under test in {loaded}/{len(group)} runs")
|
|
1208
|
+
elif loaded * 2 < len(group):
|
|
1209
|
+
notes.append(f"skill arm '{arm_label(arm)}' loaded the skill in only {loaded}/{len(group)} runs")
|
|
1210
|
+
return notes
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _loaded_cell(row: pd.Series) -> str:
|
|
1214
|
+
"""Per-run 'skill loaded' cell: did this run load the skill under test?
|
|
1215
|
+
|
|
1216
|
+
``—`` for a baseline run (no skill to load), ``yes``/``no`` for a skill run, and ``?``
|
|
1217
|
+
when the transcript was unavailable so it could not be determined. Any other skill the
|
|
1218
|
+
agent loaded — the CLI ships its own — is not this skill and does not read ``yes``. A
|
|
1219
|
+
``no`` is highlighted: that is a skill run not measuring its skill, and the transcript
|
|
1220
|
+
link in the same row is where to see why it did not load.
|
|
1221
|
+
"""
|
|
1222
|
+
if row["arm"] == NOSKILL_ARM:
|
|
1223
|
+
return "—"
|
|
1224
|
+
raw = row.get("skill_loaded")
|
|
1225
|
+
if raw is None or (not isinstance(raw, bool) and pd.isna(raw)):
|
|
1226
|
+
return "?"
|
|
1227
|
+
return "yes" if bool(raw) else '<span class="skill-miss">no</span>'
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _loaded_rank(row: pd.Series) -> float:
|
|
1231
|
+
"""Sort key for the 'skill loaded' column, ordered by how much it wants attention.
|
|
1232
|
+
|
|
1233
|
+
Sorting descending brings the runs worth looking at to the top: a skill run that did not
|
|
1234
|
+
load its skill outranks one that did, and both outrank the cells with nothing to say.
|
|
1235
|
+
"""
|
|
1236
|
+
if row["arm"] == NOSKILL_ARM:
|
|
1237
|
+
return -2.0
|
|
1238
|
+
raw = row.get("skill_loaded")
|
|
1239
|
+
if raw is None or (not isinstance(raw, bool) and pd.isna(raw)):
|
|
1240
|
+
return -1.0
|
|
1241
|
+
return 0.0 if bool(raw) else 1.0
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _runs_table_html(df: pd.DataFrame, out_dir: Path) -> str:
|
|
1245
|
+
"""Per-run table with resource use and a link to each run's ``transcript.html``.
|
|
1246
|
+
|
|
1247
|
+
``answer`` and ``expected`` are deliberately **not** shown here — they can be long free
|
|
1248
|
+
text that would blow out the table width — and neither is the input/output token split,
|
|
1249
|
+
since the total is what readers compare on. All of them remain columns in the sidecar CSV
|
|
1250
|
+
for anyone inspecting individual runs.
|
|
1251
|
+
|
|
1252
|
+
Columns are click-to-sort. A cell whose displayed text does not order the way the value
|
|
1253
|
+
does carries the value in ``data-sort``: thousands separators, a formatted duration and a
|
|
1254
|
+
padded cost all sort wrongly as text, and ``skill loaded`` is words standing in for a
|
|
1255
|
+
ranking. Everything else compares as the string the reader can see.
|
|
1256
|
+
"""
|
|
1257
|
+
headers = [
|
|
1258
|
+
"arm",
|
|
1259
|
+
"split",
|
|
1260
|
+
"task",
|
|
1261
|
+
"model",
|
|
1262
|
+
"rep",
|
|
1263
|
+
"skill loaded",
|
|
1264
|
+
"reason",
|
|
1265
|
+
"turns",
|
|
1266
|
+
"total tok",
|
|
1267
|
+
"cost $",
|
|
1268
|
+
"time",
|
|
1269
|
+
"transcript",
|
|
1270
|
+
]
|
|
1271
|
+
head = "".join(
|
|
1272
|
+
# The transcript column is a link, so there is no ordering to give it.
|
|
1273
|
+
f"<th>{html.escape(h)}</th>"
|
|
1274
|
+
if h == "transcript"
|
|
1275
|
+
else f'<th data-sortable tabindex="0" aria-sort="none">{html.escape(h)}</th>'
|
|
1276
|
+
for h in headers
|
|
1277
|
+
)
|
|
1278
|
+
body = []
|
|
1279
|
+
for _, row in df.iterrows():
|
|
1280
|
+
transcript: Path = row["transcript_path"]
|
|
1281
|
+
if transcript.exists():
|
|
1282
|
+
try:
|
|
1283
|
+
href = transcript.relative_to(out_dir).as_posix()
|
|
1284
|
+
except ValueError:
|
|
1285
|
+
href = transcript.resolve().as_uri()
|
|
1286
|
+
link = f'<a href="{html.escape(href)}">view</a>'
|
|
1287
|
+
else:
|
|
1288
|
+
link = "—"
|
|
1289
|
+
status = "pass" if row["success"] else "fail"
|
|
1290
|
+
cells: list[tuple[str, float | None]] = [
|
|
1291
|
+
(html.escape(str(row["arm_label"])), None),
|
|
1292
|
+
(html.escape(str(row["split"])), None),
|
|
1293
|
+
(html.escape(str(row["task_id"])), None),
|
|
1294
|
+
(html.escape(str(row["model"])), None),
|
|
1295
|
+
(str(int(row["rep"])), float(row["rep"])),
|
|
1296
|
+
(_loaded_cell(row), _loaded_rank(row)),
|
|
1297
|
+
(html.escape(str(row["reason"])), None),
|
|
1298
|
+
(str(int(row["turns"])), float(row["turns"])),
|
|
1299
|
+
(f"{int(row['total_tokens']):,}", float(row["total_tokens"])),
|
|
1300
|
+
(f"{float(row['cost_usd']):.3f}", float(row["cost_usd"])),
|
|
1301
|
+
(_fmt_seconds(float(row["duration_s"])), float(row["duration_s"])),
|
|
1302
|
+
(link, None),
|
|
1303
|
+
]
|
|
1304
|
+
tds = "".join(
|
|
1305
|
+
f"<td>{text}</td>" if key is None else f'<td data-sort="{key}">{text}</td>' for text, key in cells
|
|
1306
|
+
)
|
|
1307
|
+
body.append(f'<tr class="{status}">{tds}</tr>')
|
|
1308
|
+
return f'<table class="runs"><thead><tr>{head}</tr></thead><tbody>{"".join(body)}</tbody></table>'
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
_STYLE = f"""
|
|
1312
|
+
:root {{ color-scheme: light; --ink: {INK}; --page: {PAGE}; --surface: {SURFACE}; --bar: {BAR}; }}
|
|
1313
|
+
* {{ box-sizing: border-box; }}
|
|
1314
|
+
html {{ scroll-behavior: smooth; }}
|
|
1315
|
+
body {{ font-family: system-ui, -apple-system, Segoe UI, sans-serif; margin: 0; line-height: 1.5;
|
|
1316
|
+
background: var(--page); color: var(--ink); display: flex; align-items: flex-start; }}
|
|
1317
|
+
nav.toc {{ position: sticky; top: 0; flex: 0 0 210px; height: 100vh; overflow-y: auto;
|
|
1318
|
+
padding: 1.5rem 1rem; border-right: 1px solid {INK}22; font-size: 0.9rem; }}
|
|
1319
|
+
nav.toc .toc-banner {{ display: block; width: 100%; max-width: 170px; height: auto; margin: 0 0 0.9rem; }}
|
|
1320
|
+
nav.toc .toc-title {{ font-weight: 700; margin-bottom: 0.6rem; }}
|
|
1321
|
+
nav.toc ul {{ list-style: none; margin: 0; padding: 0; }}
|
|
1322
|
+
nav.toc li {{ margin: 0.15rem 0; }}
|
|
1323
|
+
nav.toc ul ul {{ padding-left: 0.8rem; }}
|
|
1324
|
+
nav.toc a {{ display: block; padding: 0.15rem 0.4rem; border-radius: 3px; text-decoration: none;
|
|
1325
|
+
color: var(--ink); border-left: 2px solid transparent; }}
|
|
1326
|
+
nav.toc a:hover {{ background: {INK}0d; }}
|
|
1327
|
+
nav.toc a.active {{ background: {BAR}22; border-left-color: var(--bar); font-weight: 600; }}
|
|
1328
|
+
main {{ flex: 1 1 auto; min-width: 0; max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; }}
|
|
1329
|
+
h1 {{ margin-bottom: 0.2rem; }}
|
|
1330
|
+
h2 {{ border-bottom: 2px solid {BAR}33; padding-bottom: 0.2rem; scroll-margin-top: 1rem; }}
|
|
1331
|
+
h3 {{ margin: 1.4rem 0 0.2rem; scroll-margin-top: 1rem; }}
|
|
1332
|
+
a {{ color: var(--bar); }}
|
|
1333
|
+
.meta {{ color: {BAR}; font-size: 0.9rem; margin-bottom: 1.5rem; }}
|
|
1334
|
+
.task-desc {{ color: {BAR}; font-size: 0.9rem; margin: 0 0 0.4rem; }}
|
|
1335
|
+
figure {{ margin: 0.6rem 0 1.6rem; text-align: center; }}
|
|
1336
|
+
img {{ max-width: 100%; height: auto; }}
|
|
1337
|
+
table {{ border-collapse: collapse; width: 100%; font-size: 0.88rem; margin: 1rem 0; display: block;
|
|
1338
|
+
overflow-x: auto; }}
|
|
1339
|
+
/* The significance table is narrower than the text column, so it sits centred in a scroll
|
|
1340
|
+
box of its own rather than hugging the left margin. */
|
|
1341
|
+
.table-center {{ overflow-x: auto; margin: 1rem 0; }}
|
|
1342
|
+
table.tests {{ display: table; width: auto; margin: 0 auto; overflow-x: visible; }}
|
|
1343
|
+
th, td {{ border: 1px solid {INK}22; padding: 0.3rem 0.55rem; text-align: right; white-space: nowrap; }}
|
|
1344
|
+
th:first-child, td:first-child {{ text-align: left; }}
|
|
1345
|
+
thead th {{ background: {BAR}1a; }}
|
|
1346
|
+
tbody tr:nth-child(even) td {{ background: {INK}08; }}
|
|
1347
|
+
tr.fail td {{ background: {ACCENT}66; }}
|
|
1348
|
+
/* Sorting affordances appear only once the script has wired the table up, so with JS off
|
|
1349
|
+
nothing invites a click that would do nothing. The arrow is padded for, not floated over
|
|
1350
|
+
the label, so the heading does not shift when the column becomes the sorted one. */
|
|
1351
|
+
table.sortable thead th[data-sortable] {{ cursor: pointer; user-select: none;
|
|
1352
|
+
position: relative; padding-right: 1.3rem; }}
|
|
1353
|
+
table.sortable thead th[data-sortable]:hover {{ background: {BAR}33; }}
|
|
1354
|
+
table.sortable thead th[data-sortable]:focus-visible {{ outline: 2px solid var(--bar);
|
|
1355
|
+
outline-offset: -2px; }}
|
|
1356
|
+
table.sortable thead th[data-sortable]::after {{ content: "\\2195"; position: absolute;
|
|
1357
|
+
right: 0.4rem; opacity: 0.3; }}
|
|
1358
|
+
table.sortable thead th[aria-sort="ascending"]::after {{ content: "\\2191"; opacity: 1; }}
|
|
1359
|
+
table.sortable thead th[aria-sort="descending"]::after {{ content: "\\2193"; opacity: 1; }}
|
|
1360
|
+
.skill-miss {{ color: #a4432b; font-weight: 700; }}
|
|
1361
|
+
.note {{ background: {ACCENT}55; border-left: 3px solid var(--bar); padding: 0.5rem 0.8rem;
|
|
1362
|
+
margin: 0.5rem 0; border-radius: 3px; }}
|
|
1363
|
+
.rationale {{ margin: 0.3rem 0 0.8rem; white-space: pre-wrap; }}
|
|
1364
|
+
/* Split diff: old version left, new version right, one bordered box per changed file. */
|
|
1365
|
+
.diff {{ background: var(--surface); border: 1px solid {INK}22; border-radius: 4px;
|
|
1366
|
+
overflow-x: auto; margin: 0.4rem 0 1.4rem; }}
|
|
1367
|
+
.diff-file {{ color: {INK}; font-weight: 700; padding: 0.4rem 0.8rem;
|
|
1368
|
+
border-bottom: 1px solid {INK}22; font-size: 0.82rem;
|
|
1369
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
|
|
1370
|
+
table.diff-table {{ display: table; table-layout: fixed; width: 100%; min-width: 34rem; margin: 0;
|
|
1371
|
+
border-collapse: collapse; overflow-x: visible; font-size: 0.82rem; line-height: 1.35;
|
|
1372
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
|
|
1373
|
+
table.diff-table td {{ border: 0; padding: 0 0.5rem; text-align: left; vertical-align: top;
|
|
1374
|
+
white-space: pre-wrap; overflow-wrap: anywhere; background: none; }}
|
|
1375
|
+
table.diff-table td.ln {{ width: 2.8rem; text-align: right; user-select: none;
|
|
1376
|
+
color: {INK}77; background: {INK}0a; }}
|
|
1377
|
+
table.diff-table tr.diff-head td {{ color: {BAR}; font-weight: 600; background: {INK}0a;
|
|
1378
|
+
border-bottom: 1px solid {INK}22; }}
|
|
1379
|
+
table.diff-table tr.diff-gap td {{ color: {INK}77; text-align: center; background: {INK}0a;
|
|
1380
|
+
border-top: 1px solid {INK}22; border-bottom: 1px solid {INK}22; }}
|
|
1381
|
+
table.diff-table td.diff-add {{ background: #4c7a3322; color: #2f5d1c; }}
|
|
1382
|
+
table.diff-table td.diff-del {{ background: #a4432b22; color: #8a2f1b; }}
|
|
1383
|
+
table.diff-table td.diff-none {{ background: {INK}0a; }}
|
|
1384
|
+
table.diff-table td.diff-ctx {{ color: {INK}bb; }}
|
|
1385
|
+
table.diff-table td.diff-add mark {{ background: #4c7a3355; color: inherit; }}
|
|
1386
|
+
table.diff-table td.diff-del mark {{ background: #a4432b55; color: inherit; }}
|
|
1387
|
+
section {{ margin-top: 2.5rem; scroll-margin-top: 1rem; }}
|
|
1388
|
+
@media (max-width: 720px) {{
|
|
1389
|
+
body {{ display: block; }}
|
|
1390
|
+
nav.toc {{ position: static; height: auto; flex-basis: auto; border-right: none;
|
|
1391
|
+
border-bottom: 1px solid {INK}22; }}
|
|
1392
|
+
nav.toc ul {{ columns: 2; }}
|
|
1393
|
+
main {{ margin: 1rem auto; }}
|
|
1394
|
+
}}
|
|
1395
|
+
"""
|
|
1396
|
+
|
|
1397
|
+
#: Highlights the nav link for whichever section is on screen. Progressive enhancement —
|
|
1398
|
+
#: the anchor links jump correctly with JS disabled; this only adds the active marker.
|
|
1399
|
+
_SCROLLSPY_JS = """
|
|
1400
|
+
const links = new Map();
|
|
1401
|
+
document.querySelectorAll('nav.toc a').forEach(a => links.set(a.getAttribute('href').slice(1), a));
|
|
1402
|
+
const observer = new IntersectionObserver((entries) => {
|
|
1403
|
+
entries.forEach(e => {
|
|
1404
|
+
if (e.isIntersecting) {
|
|
1405
|
+
links.forEach(a => a.classList.remove('active'));
|
|
1406
|
+
const a = links.get(e.target.id);
|
|
1407
|
+
if (a) a.classList.add('active');
|
|
1408
|
+
}
|
|
1409
|
+
});
|
|
1410
|
+
}, { rootMargin: '0px 0px -75% 0px' });
|
|
1411
|
+
document.querySelectorAll('[id]').forEach(el => { if (links.has(el.id)) observer.observe(el); });
|
|
1412
|
+
"""
|
|
1413
|
+
|
|
1414
|
+
#: Click-to-sort for the runs table. Progressive enhancement, like the scrollspy above: the
|
|
1415
|
+
#: table is already in a sensible order and fully readable with JS disabled, and the class
|
|
1416
|
+
#: this adds is what turns on the pointer and arrow affordances in the stylesheet.
|
|
1417
|
+
#:
|
|
1418
|
+
#: Rows are sorted from a snapshot of the original order, and that order breaks ties, so
|
|
1419
|
+
#: sorting on a coarse column (arm, split) leaves the rows within a group where they started
|
|
1420
|
+
#: instead of shuffling them differently on every click.
|
|
1421
|
+
_SORT_JS = """
|
|
1422
|
+
document.querySelectorAll('table.runs').forEach(table => {
|
|
1423
|
+
const body = table.tBodies[0];
|
|
1424
|
+
const rows = [...body.rows];
|
|
1425
|
+
rows.forEach((row, i) => { row.dataset.seq = i; });
|
|
1426
|
+
const headers = [...table.tHead.rows[0].cells];
|
|
1427
|
+
const value = (row, i) => {
|
|
1428
|
+
const cell = row.cells[i];
|
|
1429
|
+
return 'sort' in cell.dataset ? Number(cell.dataset.sort) : cell.textContent.trim().toLowerCase();
|
|
1430
|
+
};
|
|
1431
|
+
headers.forEach((th, i) => {
|
|
1432
|
+
if (!th.hasAttribute('data-sortable')) return;
|
|
1433
|
+
const sort = () => {
|
|
1434
|
+
const dir = th.getAttribute('aria-sort') === 'ascending' ? -1 : 1;
|
|
1435
|
+
headers.forEach(h => { if (h.hasAttribute('data-sortable')) h.setAttribute('aria-sort', 'none'); });
|
|
1436
|
+
th.setAttribute('aria-sort', dir === 1 ? 'ascending' : 'descending');
|
|
1437
|
+
const sorted = rows.slice().sort((x, y) => {
|
|
1438
|
+
const a = value(x, i), b = value(y, i);
|
|
1439
|
+
const cmp = typeof a === 'number' ? a - b : a.localeCompare(b);
|
|
1440
|
+
return dir * (cmp || (x.dataset.seq - y.dataset.seq));
|
|
1441
|
+
});
|
|
1442
|
+
body.append(...sorted);
|
|
1443
|
+
};
|
|
1444
|
+
th.addEventListener('click', sort);
|
|
1445
|
+
th.addEventListener('keydown', e => {
|
|
1446
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); sort(); }
|
|
1447
|
+
});
|
|
1448
|
+
});
|
|
1449
|
+
table.classList.add('sortable');
|
|
1450
|
+
document.querySelectorAll('.sort-hint').forEach(hint => hint.removeAttribute('hidden'));
|
|
1451
|
+
});
|
|
1452
|
+
"""
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _task_desc_html(task: Task) -> str:
|
|
1456
|
+
"""The task's own prompts (train and test) — the actual task, not the runtime prompt.
|
|
1457
|
+
|
|
1458
|
+
This is the text from ``tasks.yaml``, distinct from the full benchmark prompt the
|
|
1459
|
+
agent sees (which prepends the harness preamble in :mod:`acumen.prompts`).
|
|
1460
|
+
"""
|
|
1461
|
+
parts = []
|
|
1462
|
+
for split in _SPLIT_ORDER:
|
|
1463
|
+
prompt = " ".join(task.split(split).prompt.split())
|
|
1464
|
+
parts.append(f'<p class="task-desc"><strong>{split}:</strong> {html.escape(prompt)}</p>')
|
|
1465
|
+
return "".join(parts)
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
#: Unchanged lines kept on either side of a change, as in ``diff -u``. Longer untouched runs
|
|
1469
|
+
#: collapse to a single marker row, so a version bump reads as only what actually moved.
|
|
1470
|
+
_DIFF_CONTEXT = 3
|
|
1471
|
+
|
|
1472
|
+
#: Below this line-similarity the two sides of a replaced row are treated as unrelated text,
|
|
1473
|
+
#: so the row is tinted whole instead of being picked apart into confetti-sized highlights.
|
|
1474
|
+
_DIFF_INLINE_RATIO = 0.5
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
@dataclass(frozen=True)
|
|
1478
|
+
class _DiffRow:
|
|
1479
|
+
"""One row of a split diff: the same logical line on each side, either side possibly absent.
|
|
1480
|
+
|
|
1481
|
+
``kind`` is the change type from :class:`difflib.SequenceMatcher` (``equal``, ``replace``,
|
|
1482
|
+
``delete``, ``insert``), plus ``gap`` for the marker standing in for an elided run of
|
|
1483
|
+
unchanged lines. A side is ``None`` where that version has no line there at all — a pure
|
|
1484
|
+
insertion has no left-hand text — and renders as an inert filler cell.
|
|
1485
|
+
"""
|
|
1486
|
+
|
|
1487
|
+
kind: str
|
|
1488
|
+
left_no: int | None
|
|
1489
|
+
left: str | None
|
|
1490
|
+
right_no: int | None
|
|
1491
|
+
right: str | None
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _equal_rows(before: list[str], after: list[str], i: int, j: int, count: int) -> list[_DiffRow]:
|
|
1495
|
+
"""``count`` rows of unchanged text, starting at line ``i`` on the left and ``j`` on the right."""
|
|
1496
|
+
return [_DiffRow("equal", i + k + 1, before[i + k], j + k + 1, after[j + k]) for k in range(count)]
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def _split_diff_rows(before: list[str], after: list[str]) -> list[_DiffRow]:
|
|
1500
|
+
"""Align two versions of a file into side-by-side rows, old on the left, new on the right.
|
|
1501
|
+
|
|
1502
|
+
Changed runs pair off line by line, so a rewritten paragraph sits opposite its replacement
|
|
1503
|
+
rather than being stacked below it; where one side runs out, the other continues against
|
|
1504
|
+
filler. Unchanged stretches beyond :data:`_DIFF_CONTEXT` lines from any change collapse to
|
|
1505
|
+
a gap row.
|
|
1506
|
+
"""
|
|
1507
|
+
rows: list[_DiffRow] = []
|
|
1508
|
+
opcodes = difflib.SequenceMatcher(a=before, b=after, autojunk=False).get_opcodes()
|
|
1509
|
+
for index, (tag, i1, i2, j1, j2) in enumerate(opcodes):
|
|
1510
|
+
if tag == "equal":
|
|
1511
|
+
head = _DIFF_CONTEXT if index > 0 else 0
|
|
1512
|
+
tail = _DIFF_CONTEXT if index < len(opcodes) - 1 else 0
|
|
1513
|
+
if i2 - i1 > head + tail:
|
|
1514
|
+
rows += _equal_rows(before, after, i1, j1, head)
|
|
1515
|
+
rows.append(_DiffRow("gap", None, None, None, None))
|
|
1516
|
+
rows += _equal_rows(before, after, i2 - tail, j2 - tail, tail)
|
|
1517
|
+
else:
|
|
1518
|
+
rows += _equal_rows(before, after, i1, j1, i2 - i1)
|
|
1519
|
+
continue
|
|
1520
|
+
left, right = before[i1:i2], after[j1:j2]
|
|
1521
|
+
for k in range(max(len(left), len(right))):
|
|
1522
|
+
has_left, has_right = k < len(left), k < len(right)
|
|
1523
|
+
rows.append(
|
|
1524
|
+
_DiffRow(
|
|
1525
|
+
tag,
|
|
1526
|
+
i1 + k + 1 if has_left else None,
|
|
1527
|
+
left[k] if has_left else None,
|
|
1528
|
+
j1 + k + 1 if has_right else None,
|
|
1529
|
+
right[k] if has_right else None,
|
|
1530
|
+
)
|
|
1531
|
+
)
|
|
1532
|
+
return rows
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _inline_pair(left: str, right: str) -> tuple[str, str]:
|
|
1536
|
+
"""Both sides of a replaced line, escaped, with the words that differ wrapped in ``<mark>``.
|
|
1537
|
+
|
|
1538
|
+
The comparison runs over words rather than characters, so a changed word lights up whole
|
|
1539
|
+
instead of down to the letters it happens to share with its replacement. Lines too
|
|
1540
|
+
dissimilar to be a rewrite of one another are left unmarked — highlighting nearly every
|
|
1541
|
+
word says less than the row tint already does.
|
|
1542
|
+
"""
|
|
1543
|
+
a, b = re.findall(r"\w+|\W", left), re.findall(r"\w+|\W", right)
|
|
1544
|
+
matcher = difflib.SequenceMatcher(a=a, b=b, autojunk=False)
|
|
1545
|
+
if matcher.ratio() < _DIFF_INLINE_RATIO:
|
|
1546
|
+
return html.escape(left), html.escape(right)
|
|
1547
|
+
marked = ["", ""]
|
|
1548
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
1549
|
+
for side, tokens, start, end in ((0, a, i1, i2), (1, b, j1, j2)):
|
|
1550
|
+
chunk = html.escape("".join(tokens[start:end]))
|
|
1551
|
+
if chunk:
|
|
1552
|
+
marked[side] += chunk if tag == "equal" else f"<mark>{chunk}</mark>"
|
|
1553
|
+
return marked[0], marked[1]
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def _diff_cells(number: int | None, text: str | None, marked: str | None, cls: str) -> str:
|
|
1557
|
+
"""The line-number and content cell for one side of a row, or filler where that side is empty."""
|
|
1558
|
+
if text is None:
|
|
1559
|
+
return '<td class="ln"></td><td class="diff-none"></td>'
|
|
1560
|
+
body = marked if marked is not None else html.escape(text)
|
|
1561
|
+
return f'<td class="ln">{number}</td><td class="{cls}">{body or " "}</td>'
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _split_diff_table(rel: str, before: list[str], after: list[str], labels: tuple[str, str]) -> str:
|
|
1565
|
+
"""One file's split diff as a table: line numbers and text for each version, side by side."""
|
|
1566
|
+
body: list[str] = []
|
|
1567
|
+
for row in _split_diff_rows(before, after):
|
|
1568
|
+
if row.kind == "gap":
|
|
1569
|
+
body.append('<tr class="diff-gap"><td colspan="4">…</td></tr>')
|
|
1570
|
+
continue
|
|
1571
|
+
left_mark = right_mark = None
|
|
1572
|
+
if row.kind == "replace" and row.left is not None and row.right is not None:
|
|
1573
|
+
left_mark, right_mark = _inline_pair(row.left, row.right)
|
|
1574
|
+
left_cls = "diff-ctx" if row.kind == "equal" else "diff-del"
|
|
1575
|
+
right_cls = "diff-ctx" if row.kind == "equal" else "diff-add"
|
|
1576
|
+
body.append(
|
|
1577
|
+
"<tr>"
|
|
1578
|
+
+ _diff_cells(row.left_no, row.left, left_mark, left_cls)
|
|
1579
|
+
+ _diff_cells(row.right_no, row.right, right_mark, right_cls)
|
|
1580
|
+
+ "</tr>"
|
|
1581
|
+
)
|
|
1582
|
+
head = (
|
|
1583
|
+
f'<tr class="diff-head"><td class="ln"></td><td>{html.escape(labels[0])}</td>'
|
|
1584
|
+
f'<td class="ln"></td><td>{html.escape(labels[1])}</td></tr>'
|
|
1585
|
+
)
|
|
1586
|
+
return (
|
|
1587
|
+
f'<div class="diff"><div class="diff-file">{html.escape(rel)}</div>'
|
|
1588
|
+
f'<table class="diff-table"><thead>{head}</thead>'
|
|
1589
|
+
f"<tbody>{''.join(body)}</tbody></table></div>"
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def _skill_diff_html(parent: dict[str, str] | None, child: dict[str, str], labels: tuple[str, str]) -> str:
|
|
1594
|
+
"""Split diff of a skill version's content against its parent, one table per changed file.
|
|
1595
|
+
|
|
1596
|
+
``parent`` is ``None`` for the first version (nothing to diff against). Files present in
|
|
1597
|
+
only one side diff against an empty file, so added and removed references show up too.
|
|
1598
|
+
``labels`` names the two columns — the parent version and this one.
|
|
1599
|
+
"""
|
|
1600
|
+
if parent is None:
|
|
1601
|
+
return '<p class="task-desc">Initial version — no parent to diff against.</p>'
|
|
1602
|
+
tables: list[str] = []
|
|
1603
|
+
for rel in sorted(set(parent) | set(child)):
|
|
1604
|
+
before = parent.get(rel, "").splitlines()
|
|
1605
|
+
after = child.get(rel, "").splitlines()
|
|
1606
|
+
if before != after:
|
|
1607
|
+
tables.append(_split_diff_table(rel, before, after, labels))
|
|
1608
|
+
if not tables:
|
|
1609
|
+
return '<p class="task-desc">No content change from the parent version.</p>'
|
|
1610
|
+
return "".join(tables)
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def _skills_section_html(df: pd.DataFrame, skills_root: Path | None) -> tuple[str, str]:
|
|
1614
|
+
"""Render the per-version skill provenance section: rationale + diff against parent.
|
|
1615
|
+
|
|
1616
|
+
Returns ``(section_html, toc_html)`` — both empty when there is no skill directory to
|
|
1617
|
+
read, so a noskill-only report simply omits the section.
|
|
1618
|
+
"""
|
|
1619
|
+
if skills_root is None or not skills_root.is_dir():
|
|
1620
|
+
return "", ""
|
|
1621
|
+
versions = [v for a in _arms_in_order(df) if (v := skill_from_arm(a)) is not None]
|
|
1622
|
+
if not versions:
|
|
1623
|
+
return "", ""
|
|
1624
|
+
|
|
1625
|
+
blocks: list[str] = []
|
|
1626
|
+
toc: list[str] = []
|
|
1627
|
+
for version in versions:
|
|
1628
|
+
directory = skill_dir(skills_root, version)
|
|
1629
|
+
if not directory.is_dir():
|
|
1630
|
+
continue
|
|
1631
|
+
try:
|
|
1632
|
+
content = skill_content(directory)
|
|
1633
|
+
except SkillError:
|
|
1634
|
+
continue
|
|
1635
|
+
meta = read_meta(directory)
|
|
1636
|
+
parent_version = meta.parent if meta else None
|
|
1637
|
+
rationale = (meta.rationale if meta else "") or "(no rationale recorded)"
|
|
1638
|
+
hash_short = (meta.hash if meta else "")[:19]
|
|
1639
|
+
feedback = meta.feedback if meta else None
|
|
1640
|
+
|
|
1641
|
+
parent_content: dict[str, str] | None = None
|
|
1642
|
+
if parent_version:
|
|
1643
|
+
parent_dir = skill_dir(skills_root, parent_version)
|
|
1644
|
+
if parent_dir.is_dir():
|
|
1645
|
+
try:
|
|
1646
|
+
parent_content = skill_content(parent_dir)
|
|
1647
|
+
except SkillError:
|
|
1648
|
+
parent_content = None
|
|
1649
|
+
|
|
1650
|
+
anchor = f"skill-{version}"
|
|
1651
|
+
provenance = f"parent: {html.escape(parent_version)}" if parent_version else "initial draft"
|
|
1652
|
+
blocks.append(
|
|
1653
|
+
f'<h3 id="{html.escape(anchor)}">Skill {html.escape(version)}</h3>'
|
|
1654
|
+
f'<p class="task-desc">{provenance}'
|
|
1655
|
+
f"{f' · {html.escape(hash_short)}…' if hash_short else ''}</p>"
|
|
1656
|
+
f'<p class="rationale">{html.escape(rationale)}</p>'
|
|
1657
|
+
f"{f'<p class="rationale"><em>Maintainer feedback:</em> {html.escape(feedback)}</p>' if feedback else ''}"
|
|
1658
|
+
f"{_skill_diff_html(parent_content, content, (parent_version or '', version))}"
|
|
1659
|
+
)
|
|
1660
|
+
toc.append(f'<li><a href="#{html.escape(anchor)}">Skill {html.escape(version)}</a></li>')
|
|
1661
|
+
|
|
1662
|
+
if not blocks:
|
|
1663
|
+
return "", ""
|
|
1664
|
+
section = (
|
|
1665
|
+
'<section id="skills">\n<h2>Skill versions</h2>\n'
|
|
1666
|
+
'<p class="task-desc">Why each version was written, and what changed from its parent.</p>\n'
|
|
1667
|
+
+ "".join(blocks)
|
|
1668
|
+
+ "\n</section>"
|
|
1669
|
+
)
|
|
1670
|
+
toc_html = f'<li><a href="#skills">Skill versions</a><ul>{"".join(toc)}</ul></li>'
|
|
1671
|
+
return section, toc_html
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def render_report(
|
|
1675
|
+
df: pd.DataFrame,
|
|
1676
|
+
out_dir: Path,
|
|
1677
|
+
tasks: Sequence[Task] | None = None,
|
|
1678
|
+
data_href: str | None = None,
|
|
1679
|
+
skills_root: Path | None = None,
|
|
1680
|
+
palette: Mapping[str, str] | None = None,
|
|
1681
|
+
) -> str:
|
|
1682
|
+
"""Render the full report HTML for the results in ``df``.
|
|
1683
|
+
|
|
1684
|
+
Parameters
|
|
1685
|
+
----------
|
|
1686
|
+
df
|
|
1687
|
+
Results from :func:`load_results`.
|
|
1688
|
+
out_dir
|
|
1689
|
+
The directory ``report.html`` will live in — transcript links are made relative
|
|
1690
|
+
to it so the report stays portable alongside the run tree.
|
|
1691
|
+
tasks
|
|
1692
|
+
The loaded tasks, if available — used to print each task's own prompt under its
|
|
1693
|
+
per-task heading. When ``None``, the heading shows the task id alone.
|
|
1694
|
+
data_href
|
|
1695
|
+
Relative link to the aggregated data file (the CSV backing the runs table), shown
|
|
1696
|
+
above it so a reader can grab the numbers. When ``None``, no link is shown.
|
|
1697
|
+
skills_root
|
|
1698
|
+
The ``skills/`` root, if available — used to render the per-version rationale and a
|
|
1699
|
+
diff of each skill against its parent. When ``None`` (or missing), the section is
|
|
1700
|
+
omitted, so a noskill-only report still renders.
|
|
1701
|
+
palette
|
|
1702
|
+
Bar colours keyed by model id, overriding the tier defaults — see
|
|
1703
|
+
:func:`resolve_palette`. Resolved once here, against every model in ``df``, so a
|
|
1704
|
+
bad key fails before any figure is drawn and each model keeps one colour across
|
|
1705
|
+
the overview and the per-task figures.
|
|
1706
|
+
"""
|
|
1707
|
+
colors = resolve_palette(_models_in_order(df), palette)
|
|
1708
|
+
overview_uri = figure_data_uri(metrics_figure(df, split_hue=False, colors=colors))
|
|
1709
|
+
tradeoff_uri = figure_data_uri(tradeoff_figure(df, colors=colors))
|
|
1710
|
+
tests = skill_tests(df)
|
|
1711
|
+
task_by_id = {t.id: t for t in tasks or []}
|
|
1712
|
+
skills_section, skills_toc = _skills_section_html(df, skills_root)
|
|
1713
|
+
|
|
1714
|
+
task_blocks = []
|
|
1715
|
+
toc_tasks = []
|
|
1716
|
+
for task_id in sorted(df["task_id"].unique()):
|
|
1717
|
+
subset = df[df["task_id"] == task_id]
|
|
1718
|
+
uri = figure_data_uri(metrics_figure(subset, split_hue=True, colors=colors))
|
|
1719
|
+
task = task_by_id.get(task_id)
|
|
1720
|
+
desc = _task_desc_html(task) if task is not None else ""
|
|
1721
|
+
anchor = f"task-{task_id}"
|
|
1722
|
+
task_blocks.append(
|
|
1723
|
+
f'<h3 id="{html.escape(anchor)}">{html.escape(task_id)}</h3>'
|
|
1724
|
+
f"{desc}"
|
|
1725
|
+
f'<figure><img alt="Metrics for {html.escape(task_id)}" src="{uri}"></figure>'
|
|
1726
|
+
)
|
|
1727
|
+
toc_tasks.append(f'<li><a href="#{html.escape(anchor)}">{html.escape(task_id)}</a></li>')
|
|
1728
|
+
|
|
1729
|
+
toc = f"""<nav class="toc">
|
|
1730
|
+
<img class="toc-banner" src="{_asset_data_uri("banner.svg")}" alt="acumen">
|
|
1731
|
+
<div class="toc-title">acumen report</div>
|
|
1732
|
+
<ul>
|
|
1733
|
+
<li><a href="#overview">Overview</a><ul><li><a href="#tradeoff">Cost vs. success</a></li>
|
|
1734
|
+
<li><a href="#dominance">Is the difference real?</a></li></ul></li>
|
|
1735
|
+
<li><a href="#per-task">Per-task breakdown</a><ul>{"".join(toc_tasks)}</ul></li>
|
|
1736
|
+
{skills_toc}
|
|
1737
|
+
<li><a href="#runs">Runs</a></li>
|
|
1738
|
+
</ul>
|
|
1739
|
+
</nav>"""
|
|
1740
|
+
|
|
1741
|
+
generated = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
|
|
1742
|
+
arms = ", ".join(arm_label(a) for a in _arms_in_order(df))
|
|
1743
|
+
tasks = ", ".join(sorted(df["task_id"].unique()))
|
|
1744
|
+
notes = "".join(f'<div class="note">⚠ {html.escape(n)}</div>' for n in _integrity_notes(df))
|
|
1745
|
+
|
|
1746
|
+
return f"""<!doctype html>
|
|
1747
|
+
<html lang="en">
|
|
1748
|
+
<head>
|
|
1749
|
+
<meta charset="utf-8">
|
|
1750
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1751
|
+
<link rel="icon" type="image/svg+xml" href="{_asset_data_uri("logo.svg")}">
|
|
1752
|
+
<title>acumen report</title>
|
|
1753
|
+
<style>{_STYLE}</style>
|
|
1754
|
+
</head>
|
|
1755
|
+
<body>
|
|
1756
|
+
{toc}
|
|
1757
|
+
<main>
|
|
1758
|
+
<h1>acumen benchmark report</h1>
|
|
1759
|
+
<div class="meta">Generated {generated} · {len(df)} runs · test split shown
|
|
1760
|
+
· arms: {html.escape(arms)} · tasks: {html.escape(tasks)}</div>
|
|
1761
|
+
{notes}
|
|
1762
|
+
<section id="overview">
|
|
1763
|
+
<h2>Overview</h2>
|
|
1764
|
+
<p class="task-desc">Per-run means over test runs; error bars are standard errors.</p>
|
|
1765
|
+
<figure><img alt="Success rate, tokens, cost and time per skill" src="{overview_uri}"></figure>
|
|
1766
|
+
<h3 id="tradeoff">Cost vs. success</h3>
|
|
1767
|
+
<p class="task-desc">One mark per model and skill version, where colour is the model and shape is
|
|
1768
|
+
the skill version. The grey mark pools every model for that version, with standard errors on
|
|
1769
|
+
both axes. The staircase is the Pareto frontier: anything below and to the right of it is
|
|
1770
|
+
dominated, because something on the line costs less <em>and</em> succeeds more.</p>
|
|
1771
|
+
<figure><img alt="Cost per run against success rate, by model and skill version" src="{tradeoff_uri}"></figure>
|
|
1772
|
+
<h3 id="dominance">Is the difference real?</h3>
|
|
1773
|
+
<p class="task-desc">A version <em>dominates</em> the baseline when it is both cheaper and more
|
|
1774
|
+
accurate by more than chance. The two axes are tested separately and the weaker of the two
|
|
1775
|
+
p-values is reported, so a version cannot pass on price while being worse at the task.
|
|
1776
|
+
Improving both numbers is not enough on its own: a gain the resampling cannot tell apart from
|
|
1777
|
+
noise leaves the Holm p high, which says the evidence is thin rather than that the version is
|
|
1778
|
+
bad. Best value in each column is <strong>bold</strong>, highest for the rates and lowest for
|
|
1779
|
+
cost and for the p-values. <em>On frontier</em> is how often an arm holds the Pareto frontier
|
|
1780
|
+
across resamples. Resampling is over the {tests.n_clusters} test tasks, paired across arms, so the
|
|
1781
|
+
unit is the task rather than the run.</p>
|
|
1782
|
+
{_tests_table_html(tests)}
|
|
1783
|
+
</section>
|
|
1784
|
+
<section id="per-task">
|
|
1785
|
+
<h2>Per-task breakdown</h2>
|
|
1786
|
+
{"".join(task_blocks)}
|
|
1787
|
+
</section>
|
|
1788
|
+
{skills_section}
|
|
1789
|
+
<section id="runs">
|
|
1790
|
+
<h2>Runs</h2>
|
|
1791
|
+
{f'<p class="task-desc">Full data: <a href="{html.escape(data_href)}">{html.escape(data_href)}</a></p>' if data_href else ""}
|
|
1792
|
+
<p class="task-desc sort-hint" hidden>Click a column heading to sort by it, again to reverse.</p>
|
|
1793
|
+
{_runs_table_html(df, out_dir)}
|
|
1794
|
+
</section>
|
|
1795
|
+
</main>
|
|
1796
|
+
<!-- Separate tags on purpose: the two enhancements are independent, and one throwing on an
|
|
1797
|
+
old browser must not take the other down with it. -->
|
|
1798
|
+
<script>{_SCROLLSPY_JS}</script>
|
|
1799
|
+
<script>{_SORT_JS}</script>
|
|
1800
|
+
</body>
|
|
1801
|
+
</html>
|
|
1802
|
+
"""
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def build_report(
|
|
1806
|
+
runs_root: Path,
|
|
1807
|
+
out_path: Path,
|
|
1808
|
+
tasks: Sequence[Task] | None = None,
|
|
1809
|
+
skills_root: Path | None = None,
|
|
1810
|
+
palette: Mapping[str, str] | None = None,
|
|
1811
|
+
) -> Report:
|
|
1812
|
+
"""Aggregate the run tree and render ``report.html``.
|
|
1813
|
+
|
|
1814
|
+
The report is overwritten in place, so it always reflects every run on disk.
|
|
1815
|
+
|
|
1816
|
+
Parameters
|
|
1817
|
+
----------
|
|
1818
|
+
runs_root
|
|
1819
|
+
The ``runs/`` root to aggregate.
|
|
1820
|
+
out_path
|
|
1821
|
+
Where to write ``report.html``.
|
|
1822
|
+
tasks
|
|
1823
|
+
The loaded tasks, if available — used to print each task's prompt under its
|
|
1824
|
+
per-task heading.
|
|
1825
|
+
skills_root
|
|
1826
|
+
The ``skills/`` root, if available — used to render each skill version's rationale
|
|
1827
|
+
and its diff against the parent version. When ``None``, the section is omitted.
|
|
1828
|
+
palette
|
|
1829
|
+
Bar colours keyed by model id, e.g. ``{"claude-opus-5": "#3b7ea1"}``, overriding
|
|
1830
|
+
the tier defaults — see :func:`resolve_palette`.
|
|
1831
|
+
|
|
1832
|
+
Returns
|
|
1833
|
+
-------
|
|
1834
|
+
The rendered :class:`Report` (also written to ``out_path``).
|
|
1835
|
+
"""
|
|
1836
|
+
df = load_results(runs_root)
|
|
1837
|
+
resolve_palette(_models_in_order(df), palette) # reject a bad palette before writing anything
|
|
1838
|
+
out_path = out_path.resolve()
|
|
1839
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1840
|
+
|
|
1841
|
+
# A sidecar CSV of the aggregated results, for anyone who wants to reanalyse or plot
|
|
1842
|
+
# the numbers themselves. Drop the internal helper columns (Path objects and sort
|
|
1843
|
+
# keys) so it stays a clean, portable table.
|
|
1844
|
+
data_path = out_path.with_suffix(".csv")
|
|
1845
|
+
internal = [c for c in ("result_path", "transcript_path", "_arm_order") if c in df.columns]
|
|
1846
|
+
data = df.drop(columns=internal)
|
|
1847
|
+
if "skill_loaded" in data.columns:
|
|
1848
|
+
# The HTML table keeps '?' for a run whose transcript could not be read, but the CSV
|
|
1849
|
+
# is for counting: every row is True or False, so summing the column means what it
|
|
1850
|
+
# looks like — the same reading the skill-loaded figure takes.
|
|
1851
|
+
data["skill_loaded"] = _loaded_flags(data)
|
|
1852
|
+
data.to_csv(data_path, index=False)
|
|
1853
|
+
|
|
1854
|
+
html_text = render_report(
|
|
1855
|
+
df, out_path.parent, tasks, data_href=data_path.name, skills_root=skills_root, palette=palette
|
|
1856
|
+
)
|
|
1857
|
+
out_path.write_text(html_text)
|
|
1858
|
+
return Report(html=html_text, results=df)
|