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/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 "&mdash;"
1093
+ return "&lt;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
+ ("&Delta; rate", "d_rate", True, _fmt_delta_rate),
1106
+ ("&Delta; 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 = "&mdash;" 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 "&mdash;"
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 = "&mdash;"
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 "&nbsp;"}</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">&hellip;</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' &middot; {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} &middot; {len(df)} runs &middot; test split shown
1760
+ &middot; arms: {html.escape(arms)} &middot; 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)