agentpace 0.9.2__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.
- agentpace/__init__.py +3 -0
- agentpace/__main__.py +8 -0
- agentpace/_bundled/DATA-LICENSE +121 -0
- agentpace/_bundled/example-plan.json +11 -0
- agentpace/_bundled/run.schema.json +100 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W40-2026-W45.jsonl +3219 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W44-2026-W45.jsonl +211 -0
- agentpace/_bundled/turn.schema.json +30 -0
- agentpace/adapters/__init__.py +31 -0
- agentpace/adapters/base.py +74 -0
- agentpace/adapters/claude_code.py +265 -0
- agentpace/adapters/cline.py +449 -0
- agentpace/adapters/codex.py +293 -0
- agentpace/adapters/gemini_cli.py +332 -0
- agentpace/adapters/opencode.py +397 -0
- agentpace/backtest.py +801 -0
- agentpace/cache.py +115 -0
- agentpace/charts.py +191 -0
- agentpace/claims.py +164 -0
- agentpace/classify.py +54 -0
- agentpace/cli.py +754 -0
- agentpace/estimate.py +694 -0
- agentpace/forecasts.py +284 -0
- agentpace/ingest.py +337 -0
- agentpace/paths.py +62 -0
- agentpace/pseudonym.py +51 -0
- agentpace/py.typed +0 -0
- agentpace/render.py +152 -0
- agentpace/report.py +502 -0
- agentpace/schema.py +131 -0
- agentpace/share.py +130 -0
- agentpace/site.py +133 -0
- agentpace/turns.py +282 -0
- agentpace/validate.py +115 -0
- agentpace-0.9.2.dist-info/METADATA +324 -0
- agentpace-0.9.2.dist-info/RECORD +39 -0
- agentpace-0.9.2.dist-info/WHEEL +4 -0
- agentpace-0.9.2.dist-info/entry_points.txt +2 -0
- agentpace-0.9.2.dist-info/licenses/LICENSE +21 -0
agentpace/report.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Turn backtest numbers into `docs/backtest.md`, its JSON twin and its charts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from agentpace import backtest, charts
|
|
11
|
+
from agentpace import estimate as est
|
|
12
|
+
from agentpace.schema import Row
|
|
13
|
+
|
|
14
|
+
_NOTE = "<!-- Generated by `agentpace backtest`. Do not edit by hand. -->"
|
|
15
|
+
_MIN_CHART_ROWS = 10
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _rounded(value: Any) -> Any:
|
|
19
|
+
"""Four significant figures, so results compare equal across platforms."""
|
|
20
|
+
if isinstance(value, float):
|
|
21
|
+
return float(f"{value:.4g}")
|
|
22
|
+
if isinstance(value, dict):
|
|
23
|
+
return {key: _rounded(item) for key, item in value.items()}
|
|
24
|
+
if isinstance(value, list):
|
|
25
|
+
return [_rounded(item) for item in value]
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def to_json(report: dict[str, Any]) -> str:
|
|
30
|
+
return json.dumps(_rounded(report), indent=2) + "\n"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _metric_rows(results: dict[str, Any], names: Sequence[str], weighting: str) -> list[str]:
|
|
34
|
+
lines = [
|
|
35
|
+
"| Method | MAPE | MdAPE | MALE | P50 cov. | P80 cov. | P90 cov. | P90/P50 | Pinball |",
|
|
36
|
+
"|---|---:|---:|---:|---:|---:|---:|---:|---:|",
|
|
37
|
+
]
|
|
38
|
+
for name in names:
|
|
39
|
+
m = results[name][weighting]
|
|
40
|
+
lines.append(
|
|
41
|
+
f"| {name} | {m['mape']:.0f}% | {m['mdape']:.0f}% | {m['male']:.2f} | "
|
|
42
|
+
f"{m['coverage50']:.1f}% | {m['coverage80']:.1f}% | {m['coverage90']:.1f}% | "
|
|
43
|
+
f"{m['width']:.1f}x | {m['pinball']:.0f} |"
|
|
44
|
+
)
|
|
45
|
+
return lines
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ci(value: float, interval: Sequence[float], digits: int = 0, unit: str = "") -> str:
|
|
49
|
+
"""A number with its 95% interval, such as "80% (CI 70% to 91%)"."""
|
|
50
|
+
low, high = interval
|
|
51
|
+
return f"{value:.{digits}f}{unit} (CI {low:.{digits}f}{unit} to {high:.{digits}f}{unit})"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _gap(interval: Sequence[float]) -> str:
|
|
55
|
+
low, high = interval
|
|
56
|
+
return f"{low:+.0f} to {high:+.0f}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _interval_lines(
|
|
60
|
+
results: dict[str, Any], winner: str, found: dict[str, Any], weighting: str = "session"
|
|
61
|
+
) -> list[str]:
|
|
62
|
+
m = results[winner][weighting]
|
|
63
|
+
lines = [
|
|
64
|
+
f"`{winner}` with 95% session-bootstrap intervals: P50 coverage "
|
|
65
|
+
f"{ci(m['coverage50'], found['coverage50'], 1, '%')}, P80 coverage "
|
|
66
|
+
f"{ci(m['coverage80'], found['coverage80'], 1, '%')}, P90 coverage "
|
|
67
|
+
f"{ci(m['coverage90'], found['coverage90'], 1, '%')}, pinball "
|
|
68
|
+
f"{ci(m['pinball'], found['pinball'])}.",
|
|
69
|
+
"",
|
|
70
|
+
f"| Baseline | Pinball | Baseline minus `{winner}` (95% interval) |",
|
|
71
|
+
"|---|---:|---|",
|
|
72
|
+
]
|
|
73
|
+
for name, gap in found["rival_minus_ours"].items():
|
|
74
|
+
lines.append(f"| {name} | {results[name][weighting]['pinball']:.0f} | {_gap(gap)} |")
|
|
75
|
+
lines += [
|
|
76
|
+
"",
|
|
77
|
+
"An interval wholly above zero means the baseline is worse at the 95% level.",
|
|
78
|
+
"",
|
|
79
|
+
]
|
|
80
|
+
return lines
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _week_section(weeks: Sequence[dict[str, Any]], winner: str, last: str) -> list[str]:
|
|
84
|
+
lines = [
|
|
85
|
+
"## Time split, week by week",
|
|
86
|
+
"",
|
|
87
|
+
f"The pooled numbers above average over weeks that miss in both directions, so here is "
|
|
88
|
+
f"`{winner}` fold by fold. Weeks up to {last} chose the method. Later weeks are the "
|
|
89
|
+
"locked holdout. Pinball is session-weighted.",
|
|
90
|
+
"",
|
|
91
|
+
"| Week | Period | Sessions | Runs | P80 cov., per session | P80 cov., per run | "
|
|
92
|
+
"P90/P50 | Pinball | Global-median pinball |",
|
|
93
|
+
"|---|---|---:|---:|---:|---:|---:|---:|---:|",
|
|
94
|
+
]
|
|
95
|
+
for w in weeks:
|
|
96
|
+
lines.append(
|
|
97
|
+
f"| {w['week']} | {w['period']} | {w['sessions']} | {w['rows']:,} | "
|
|
98
|
+
f"{w['coverage80_session']:.0f}% | {w['coverage80_row']:.0f}% | {w['width']:.1f}x | "
|
|
99
|
+
f"{w['pinball']:.0f} | {w['median_pinball']:.0f} |"
|
|
100
|
+
)
|
|
101
|
+
lines.append("")
|
|
102
|
+
return lines
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _locked_section(locked: dict[str, Any], winner: str, last: str) -> list[str]:
|
|
106
|
+
lines = [
|
|
107
|
+
"## Locked holdout",
|
|
108
|
+
"",
|
|
109
|
+
f"Weeks after {last} never take part in selection. They score the shipped `{winner}` on "
|
|
110
|
+
f"data it was not chosen on. Results print once at least {locked['min_sessions']} "
|
|
111
|
+
f"held-out sessions have {est.MIN_SESSION_RUNS} or more completed runs.",
|
|
112
|
+
"",
|
|
113
|
+
]
|
|
114
|
+
if locked["status"] != "ok":
|
|
115
|
+
status = locked["status"]
|
|
116
|
+
return [*lines, f"**{status[0].upper()}{status[1:]}.**", "", *_locked_harness_lines(locked)]
|
|
117
|
+
results = locked["tracks"]["pre-run"]
|
|
118
|
+
names = [winner, *backtest.BASELINES]
|
|
119
|
+
lines += [f"Weeks {', '.join(locked['folds'])}. Each session counts equally:", ""]
|
|
120
|
+
lines += _metric_rows(results, names, "session")
|
|
121
|
+
lines += ["", "Each run counts equally:", ""]
|
|
122
|
+
lines += _metric_rows(results, names, "row")
|
|
123
|
+
lines += [""]
|
|
124
|
+
lines += _interval_lines(results, winner, locked["intervals"])
|
|
125
|
+
lines += [
|
|
126
|
+
f"Whole sessions, {locked['qualifying_sessions']} of them:",
|
|
127
|
+
"",
|
|
128
|
+
*_plan_rows(locked["plans"]),
|
|
129
|
+
"",
|
|
130
|
+
*_locked_harness_lines(locked),
|
|
131
|
+
]
|
|
132
|
+
return lines
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _method_names() -> list[str]:
|
|
136
|
+
return [method.name for method in est.METHODS] + list(backtest.BASELINES)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _split_section(title: str, split: dict[str, Any], track: str, intro: str) -> list[str]:
|
|
140
|
+
if split["status"] != "ok":
|
|
141
|
+
return [f"## {title}", "", f"**{split['status'].capitalize()}.**", ""]
|
|
142
|
+
results = split["tracks"][track]
|
|
143
|
+
lines = [f"## {title}", "", intro, ""]
|
|
144
|
+
if all(results[name]["session"] == results[name]["row"] for name in _method_names()):
|
|
145
|
+
lines += [
|
|
146
|
+
"Each session counts equally, and each run counts equally: every held-out session "
|
|
147
|
+
"here has the same number of runs, so both weightings give the same table.",
|
|
148
|
+
"",
|
|
149
|
+
]
|
|
150
|
+
lines += _metric_rows(results, _method_names(), "session")
|
|
151
|
+
lines.append("")
|
|
152
|
+
return lines
|
|
153
|
+
lines += ["Each session counts equally:", ""]
|
|
154
|
+
lines += _metric_rows(results, _method_names(), "session")
|
|
155
|
+
lines += ["", "Each run counts equally:", ""]
|
|
156
|
+
lines += _metric_rows(results, _method_names(), "row")
|
|
157
|
+
lines.append("")
|
|
158
|
+
return lines
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _selection_section(selection: dict[str, Any]) -> list[str]:
|
|
162
|
+
lines = [
|
|
163
|
+
"## Which method ships",
|
|
164
|
+
"",
|
|
165
|
+
f"The rule was fixed before any result existed: lowest {selection['primary_metric']}, on "
|
|
166
|
+
"the time split, with ties inside the 95% session-bootstrap interval going to the "
|
|
167
|
+
f"simpler candidate. Selection uses weeks up to {selection['last_week']} and never re-runs "
|
|
168
|
+
"on later ones.",
|
|
169
|
+
"",
|
|
170
|
+
*_population_lines(selection),
|
|
171
|
+
"| Candidate, simplest first | Loss | Difference from lowest (95% interval) |",
|
|
172
|
+
"|---|---:|---|",
|
|
173
|
+
]
|
|
174
|
+
for name, loss in selection["loss"].items():
|
|
175
|
+
low, high = selection["difference_from_best"][name]
|
|
176
|
+
gap = "lowest" if name == selection["lowest_loss"] else f"{low:+.0f} to {high:+.0f}"
|
|
177
|
+
lines.append(f"| {name} | {loss:.0f} | {gap} |")
|
|
178
|
+
lines += [
|
|
179
|
+
"",
|
|
180
|
+
f"**Winner: `{selection['winner']}`.** It is the default for `agentpace estimate`.",
|
|
181
|
+
"",
|
|
182
|
+
]
|
|
183
|
+
return lines
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _population_lines(selection: dict[str, Any]) -> list[str]:
|
|
187
|
+
# Tied to `backtest.SELECTION_HARNESS`: if that constant ever changes, so must this text.
|
|
188
|
+
harness = selection.get("harness", "all")
|
|
189
|
+
if harness == "all":
|
|
190
|
+
return []
|
|
191
|
+
return [
|
|
192
|
+
f"Selection, the time-split tables and the whole-session test score `{harness}` sessions "
|
|
193
|
+
"only, the population the rule was frozen on. Rows from every harness train the "
|
|
194
|
+
"estimator. This scope was registered on 2026-11-07, after a local run with Codex rows "
|
|
195
|
+
"pooled in had been seen.",
|
|
196
|
+
"",
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _pooled_section(pooled: dict[str, Any]) -> list[str]:
|
|
201
|
+
if pooled["status"] != "ok":
|
|
202
|
+
return []
|
|
203
|
+
lines = [
|
|
204
|
+
"## Pooled, for the record",
|
|
205
|
+
"",
|
|
206
|
+
"The same rule over every harness at once. It is never shipped. It is printed so you can "
|
|
207
|
+
"judge the choice above. Pinball is session-weighted, so a harness with many short "
|
|
208
|
+
"sessions carries most of this vote.",
|
|
209
|
+
"",
|
|
210
|
+
"| Candidate, simplest first | Loss | Difference from lowest (95% interval) |",
|
|
211
|
+
"|---|---:|---|",
|
|
212
|
+
]
|
|
213
|
+
for name, loss in pooled["loss"].items():
|
|
214
|
+
low, high = pooled["difference_from_best"][name]
|
|
215
|
+
gap = "lowest" if name == pooled["lowest_loss"] else f"{low:+.0f} to {high:+.0f}"
|
|
216
|
+
lines.append(f"| {name} | {loss:.0f} | {gap} |")
|
|
217
|
+
lines += ["", f"Pooled winner: `{pooled['winner']}`. Baselines on the same sessions:", ""]
|
|
218
|
+
lines += ["| Baseline | Pinball |", "|---|---:|"]
|
|
219
|
+
for name, loss in pooled["baselines"].items():
|
|
220
|
+
lines.append(f"| {name} | {loss:.0f} |")
|
|
221
|
+
return [*lines, ""]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _locked_harness_lines(locked: dict[str, Any]) -> list[str]:
|
|
225
|
+
found = locked.get("by_harness")
|
|
226
|
+
if not found:
|
|
227
|
+
return []
|
|
228
|
+
lines = [
|
|
229
|
+
"Each harness at task level, every candidate, each session counting equally. This is "
|
|
230
|
+
"where a harness that selection does not score is tested, on weeks no method was "
|
|
231
|
+
"chosen on.",
|
|
232
|
+
"",
|
|
233
|
+
"| Harness | Method | Sessions | Runs | Pinball | P80 cov. | P90/P50 |",
|
|
234
|
+
"|---|---|---:|---:|---:|---:|---:|",
|
|
235
|
+
]
|
|
236
|
+
for harness, entry in found.items():
|
|
237
|
+
for name, m in entry["methods"].items():
|
|
238
|
+
lines.append(
|
|
239
|
+
f"| {harness} | {name} | {entry['sessions']} | {entry['rows']:,} | "
|
|
240
|
+
f"{m['pinball']:.0f} | {m['coverage80']:.1f}% | {m['width']:.1f}x |"
|
|
241
|
+
)
|
|
242
|
+
return [*lines, ""]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _plan_section(plans: dict[str, Any]) -> list[str]:
|
|
246
|
+
lines = [
|
|
247
|
+
"## Whole sessions (oracle-structure)",
|
|
248
|
+
"",
|
|
249
|
+
f"Each of {plans['sessions']} held-out sessions with at least 5 completed runs is treated "
|
|
250
|
+
"as a plan. This is the only direct test of plan totals. Real dependencies are not "
|
|
251
|
+
"recorded, so the critical path falls back to the longest run, and the observed peak "
|
|
252
|
+
"concurrency stands in for the agent cap, even though that peak is itself an outcome. "
|
|
253
|
+
"It tests the throughput and session terms, not dependency planning.",
|
|
254
|
+
"",
|
|
255
|
+
*_plan_rows(plans["results"]),
|
|
256
|
+
"",
|
|
257
|
+
]
|
|
258
|
+
found = plans["intervals"]
|
|
259
|
+
ours = plans["results"]["agentpace"]
|
|
260
|
+
lines += [
|
|
261
|
+
"With 95% bootstrap intervals over sessions: P50 coverage "
|
|
262
|
+
f"{ci(ours['coverage50'], found['coverage50'], unit='%')}, P80 coverage "
|
|
263
|
+
f"{ci(ours['coverage80'], found['coverage80'], unit='%')}, P90 coverage "
|
|
264
|
+
f"{ci(ours['coverage90'], found['coverage90'], unit='%')}, pinball "
|
|
265
|
+
f"{ci(ours['pinball'], found['pinball'])}. Sum-of-medians pinball minus agentpace: "
|
|
266
|
+
f"{_gap(found['rival_minus_ours']['sum-of-medians'])}.",
|
|
267
|
+
"",
|
|
268
|
+
]
|
|
269
|
+
return lines
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _plan_rows(results: dict[str, Any]) -> list[str]:
|
|
273
|
+
lines = [
|
|
274
|
+
"| Method | MAPE | MdAPE | MALE | P50 cov. | P80 cov. | P90 cov. | P90/P50 | Pinball |",
|
|
275
|
+
"|---|---:|---:|---:|---:|---:|---:|---:|---:|",
|
|
276
|
+
]
|
|
277
|
+
for name, m in results.items():
|
|
278
|
+
lines.append(
|
|
279
|
+
f"| {name} | {m['mape']:.0f}% | {m['mdape']:.0f}% | {m['male']:.2f} | "
|
|
280
|
+
f"{m['coverage50']:.0f}% | {m['coverage80']:.0f}% | {m['coverage90']:.0f}% | "
|
|
281
|
+
f"{m['width']:.1f}x | {m['pinball']:.0f} |"
|
|
282
|
+
)
|
|
283
|
+
return lines
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _project_lines(project: dict[str, Any], winner: str) -> list[str]:
|
|
287
|
+
"""Each held-out project on its own, by rank, so one project's vote is visible."""
|
|
288
|
+
if "by_project" not in project:
|
|
289
|
+
return []
|
|
290
|
+
lines = [
|
|
291
|
+
"Each project on its own, most held-out sessions first. Projects are numbered by rank, "
|
|
292
|
+
"not named, so the numbers change as data is added.",
|
|
293
|
+
"",
|
|
294
|
+
f"| Project | Harness | Test sessions | Runs | `{winner}` pinball | "
|
|
295
|
+
"global-median pinball | P80 coverage, per session | P80 coverage, per run | P90/P50 |",
|
|
296
|
+
"|---:|---|---:|---:|---:|---:|---:|---:|---:|",
|
|
297
|
+
]
|
|
298
|
+
for rank, m in enumerate(project["by_project"], start=1):
|
|
299
|
+
lines.append(
|
|
300
|
+
f"| {rank} | {m['harness']} | {m['sessions']} | {m['rows']:,} | {m['pinball']:.0f} | "
|
|
301
|
+
f"{m['median_pinball']:.0f} | {m['coverage80_session']:.1f}% | "
|
|
302
|
+
f"{m['coverage80_row']:.1f}% | {m['width']:.1f}x |"
|
|
303
|
+
)
|
|
304
|
+
if "without_largest" not in project:
|
|
305
|
+
return [*lines, ""]
|
|
306
|
+
largest = project["without_largest"]
|
|
307
|
+
losses = ", ".join(f"{name} {value:.0f}" for name, value in largest["loss"].items())
|
|
308
|
+
if largest["lowest"] == largest["lowest_with_it"]:
|
|
309
|
+
verdict = f"The lowest candidate is still `{largest['lowest']}`."
|
|
310
|
+
else:
|
|
311
|
+
verdict = (
|
|
312
|
+
f"The lowest candidate changes from `{largest['lowest_with_it']}` to "
|
|
313
|
+
f"`{largest['lowest']}`."
|
|
314
|
+
)
|
|
315
|
+
lines += [
|
|
316
|
+
"",
|
|
317
|
+
f"Without project {largest['project']} ({largest['sessions']} of "
|
|
318
|
+
f"{largest['of_sessions']} test sessions), session-weighted pinball: {losses}. "
|
|
319
|
+
f"{verdict} This split does not choose the method.",
|
|
320
|
+
"",
|
|
321
|
+
]
|
|
322
|
+
return lines
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _harness_section(harness: dict[str, Any], split: dict[str, Any], winner: str) -> list[str]:
|
|
326
|
+
lines = [
|
|
327
|
+
"## By harness",
|
|
328
|
+
"",
|
|
329
|
+
"The estimator does not use the harness. Each harness is shown on its own, from the "
|
|
330
|
+
"same selection-fold forecasts, before anyone pools them.",
|
|
331
|
+
"",
|
|
332
|
+
"| Harness | Runs | Completed | Sessions |",
|
|
333
|
+
"|---|---:|---:|---:|",
|
|
334
|
+
]
|
|
335
|
+
for name, counts in harness["dataset"].items():
|
|
336
|
+
lines.append(
|
|
337
|
+
f"| {name} | {counts['rows']:,} | {counts['completed']:,} | {counts['sessions']} |"
|
|
338
|
+
)
|
|
339
|
+
lines += [
|
|
340
|
+
"",
|
|
341
|
+
f"| Harness | Test sessions | `{winner}` pinball | global-median pinball | "
|
|
342
|
+
"P80 coverage, per session | P80 coverage, per run | P90/P50 |",
|
|
343
|
+
"|---|---:|---:|---:|---:|---:|---:|",
|
|
344
|
+
]
|
|
345
|
+
for name, m in harness["selection_folds"].items():
|
|
346
|
+
lines.append(
|
|
347
|
+
f"| {name} | {m['sessions']} | {m['pinball']:.0f} | {m['median_pinball']:.0f} | "
|
|
348
|
+
f"{m['coverage80_session']:.1f}% | {m['coverage80_row']:.1f}% | {m['width']:.1f}x |"
|
|
349
|
+
)
|
|
350
|
+
if split["status"] != "ok":
|
|
351
|
+
lines += ["", f"Harness split: **{split['status']}.**"]
|
|
352
|
+
return [*lines, ""]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _loss_section(losses: Sequence[dict[str, Any]], winner: str) -> list[str]:
|
|
356
|
+
lines = [
|
|
357
|
+
"## Where agentpace loses",
|
|
358
|
+
"",
|
|
359
|
+
f"This section is generated. It lists every split, track, weighting and metric where a "
|
|
360
|
+
f"baseline beat `{winner}`. Lower is better, except P80 coverage, where nearer 80% wins.",
|
|
361
|
+
"",
|
|
362
|
+
]
|
|
363
|
+
if not losses:
|
|
364
|
+
return [*lines, "No baseline won on any metric.", ""]
|
|
365
|
+
lines += [
|
|
366
|
+
"| Split | Track | Weighting | Metric | Baseline | Baseline value | agentpace value |",
|
|
367
|
+
"|---|---|---|---|---|---:|---:|",
|
|
368
|
+
]
|
|
369
|
+
for item in losses:
|
|
370
|
+
lines.append(
|
|
371
|
+
f"| {item['split']} | {item['track']} | {item['weighting']} | {item['metric']} | "
|
|
372
|
+
f"{item['baseline']} | {item['baseline_value']:.2f} | {item['agentpace_value']:.2f} |"
|
|
373
|
+
)
|
|
374
|
+
lines.append("")
|
|
375
|
+
return lines
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def markdown(report: dict[str, Any]) -> str:
|
|
379
|
+
data = report["dataset"]
|
|
380
|
+
weeks = data["weeks"]
|
|
381
|
+
lines = [
|
|
382
|
+
_NOTE,
|
|
383
|
+
"# Backtest",
|
|
384
|
+
"",
|
|
385
|
+
f"From {data['rows']:,} runs ({data['completed']:,} completed) in {data['sessions']} "
|
|
386
|
+
f"sessions across {data['projects']} projects, {weeks[0]} to {weeks[-1]}, from "
|
|
387
|
+
f"{data['contributors']} contributor{'' if data['contributors'] == 1 else 's'}. "
|
|
388
|
+
f"The largest project holds "
|
|
389
|
+
f"{data['top_project_share']:.0f}% of runs and the three largest sessions hold "
|
|
390
|
+
f"{data['top_three_session_share']:.0f}%.",
|
|
391
|
+
"",
|
|
392
|
+
f"Reproduce with `agentpace backtest` (seed {report['seed']}). Forecasts use only "
|
|
393
|
+
"`model`, `task_kind` and `risk` unless a track says otherwise.",
|
|
394
|
+
"",
|
|
395
|
+
"",
|
|
396
|
+
"",
|
|
397
|
+
"Reading the columns: MAPE is the mean percentage error of the P50, which heavy tails "
|
|
398
|
+
"inflate. MdAPE is its median. MALE is mean absolute log error. Coverage is how often "
|
|
399
|
+
"the actual run landed at or under that forecast. P90/P50 is the median width of the "
|
|
400
|
+
"range. Pinball is in seconds, summed over 0.5, 0.8 and 0.9.",
|
|
401
|
+
"",
|
|
402
|
+
]
|
|
403
|
+
lines += _selection_section(report["selection"])
|
|
404
|
+
stored = _rounded(report)
|
|
405
|
+
lines += _pooled_section(stored.get("pooled_selection", {"status": "absent"}))
|
|
406
|
+
splits = report["splits"]
|
|
407
|
+
winner = report["selection"]["winner"]
|
|
408
|
+
last = report["selection"]["last_week"]
|
|
409
|
+
# The README quotes the stored JSON, which is rounded. Sections added after 0.1.0 render
|
|
410
|
+
# from the same rounded values so the two documents never disagree in the last digit.
|
|
411
|
+
lines += _split_section(
|
|
412
|
+
"Time split, pre-run",
|
|
413
|
+
splits["time"],
|
|
414
|
+
"pre-run",
|
|
415
|
+
f"Train on earlier weeks, test on {', '.join(splits['time']['folds'])}. This is the "
|
|
416
|
+
"honest forecast and the headline.",
|
|
417
|
+
)
|
|
418
|
+
time = stored["splits"]["time"]
|
|
419
|
+
lines += _interval_lines(time["tracks"]["pre-run"], winner, time["intervals"])
|
|
420
|
+
lines += _week_section(time["by_week"], winner, last)
|
|
421
|
+
lines += _split_section(
|
|
422
|
+
"Time split, oracle-size",
|
|
423
|
+
splits["time"],
|
|
424
|
+
"oracle-size",
|
|
425
|
+
"The same split with stored `size` added. Size is measured after a run, so this is an "
|
|
426
|
+
"upper bound, not a forecast anyone could make.",
|
|
427
|
+
)
|
|
428
|
+
if splits["project"]["status"] == "ok":
|
|
429
|
+
lines += _split_section(
|
|
430
|
+
"Project split",
|
|
431
|
+
splits["project"],
|
|
432
|
+
"pre-run",
|
|
433
|
+
f"Leave one project out, over {splits['project']['folds']} projects. This tests "
|
|
434
|
+
"generalizing to a new codebase, not to a new person.",
|
|
435
|
+
)
|
|
436
|
+
lines += _project_lines(stored["splits"]["project"], winner)
|
|
437
|
+
else:
|
|
438
|
+
lines += _split_section("Project split", splits["project"], "pre-run", "")
|
|
439
|
+
if splits["contributor"]["status"] == "ok":
|
|
440
|
+
lines += _split_section(
|
|
441
|
+
"Contributor split", splits["contributor"], "pre-run", "Leave one contributor out."
|
|
442
|
+
)
|
|
443
|
+
else:
|
|
444
|
+
lines += _split_section("Contributor split", splits["contributor"], "pre-run", "")
|
|
445
|
+
if splits["harness"]["status"] == "ok":
|
|
446
|
+
lines += _split_section(
|
|
447
|
+
"Harness split", splits["harness"], "pre-run", "Leave one harness out."
|
|
448
|
+
)
|
|
449
|
+
lines += _harness_section(stored["harness"], splits["harness"], winner)
|
|
450
|
+
lines += _plan_section(stored["plans"])
|
|
451
|
+
lines += _locked_section(stored["locked"], winner, last)
|
|
452
|
+
lines += _loss_section(report["losses"], winner)
|
|
453
|
+
baselines = (
|
|
454
|
+
"## Baselines",
|
|
455
|
+
"",
|
|
456
|
+
"- `human-guess`: a hand-written table of hours per kind. An assumption, not a "
|
|
457
|
+
"measurement.",
|
|
458
|
+
"- `fixed-rounds`: 3 minutes per round, with rounds from a hand-written table per kind.",
|
|
459
|
+
"- `rounds-oracle`: 3 minutes times the actual `tool_calls`. It uses the answer, so no "
|
|
460
|
+
"forecast could do this. It is shown anyway.",
|
|
461
|
+
"- `global-median`: the training median, for every run.",
|
|
462
|
+
"",
|
|
463
|
+
"Baselines give one number, used for every quantile, so their P90/P50 is always 1.0x.",
|
|
464
|
+
)
|
|
465
|
+
return "\n".join([*lines, *baselines]) + "\n"
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def run_range_chart(rows: Sequence[Row]) -> str:
|
|
469
|
+
completed = [row for row in rows if row["outcome"] == "completed"]
|
|
470
|
+
bars = []
|
|
471
|
+
for kind in sorted({row["task_kind"] for row in completed}):
|
|
472
|
+
values = sorted(row["duration_s"] for row in completed if row["task_kind"] == kind)
|
|
473
|
+
if len(values) >= _MIN_CHART_ROWS:
|
|
474
|
+
p50, p80, p90 = (est.quantile(values, q) for q in est.QUANTILES)
|
|
475
|
+
bars.append(charts.RangeBar(kind, len(values), p50, p80, p90))
|
|
476
|
+
bars.sort(key=lambda bar: bar.p50)
|
|
477
|
+
return charts.range_chart("How long agent runs took, by task kind", bars)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def coverage_chart(report: dict[str, Any]) -> str:
|
|
481
|
+
results = report["splits"]["time"]["tracks"]["pre-run"]
|
|
482
|
+
points = [(m.name, results[m.name]["session"]["coverage80"], False) for m in est.METHODS]
|
|
483
|
+
points += [(name, results[name]["session"]["coverage80"], True) for name in backtest.BASELINES]
|
|
484
|
+
return charts.coverage_chart("P80 coverage on the time split, per session", points, 80.0)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def write(report: dict[str, Any], rows: Sequence[Row], out: Path) -> None:
|
|
488
|
+
(out / "assets").mkdir(parents=True, exist_ok=True)
|
|
489
|
+
files = {
|
|
490
|
+
out / "backtest.md": markdown(report),
|
|
491
|
+
out / "backtest.json": to_json(report),
|
|
492
|
+
out / "assets" / "backtest-coverage.svg": coverage_chart(report),
|
|
493
|
+
out / "assets" / "run-ranges.svg": run_range_chart(rows),
|
|
494
|
+
}
|
|
495
|
+
for path, text in files.items():
|
|
496
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def is_current(report: dict[str, Any], out: Path) -> bool:
|
|
500
|
+
"""Compare rounded numbers rather than chart bytes, so this holds on every platform."""
|
|
501
|
+
stored = out / "backtest.json"
|
|
502
|
+
return stored.exists() and stored.read_text(encoding="utf-8") == to_json(report)
|
agentpace/schema.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Row validation against `schema/run.schema.json`, without third-party packages.
|
|
2
|
+
|
|
3
|
+
This implements only the JSON Schema keywords that file uses. An unknown keyword raises instead
|
|
4
|
+
of being ignored, so the schema cannot quietly gain a rule that nothing enforces.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from agentpace import paths
|
|
18
|
+
|
|
19
|
+
Row = dict[str, Any]
|
|
20
|
+
|
|
21
|
+
_ANNOTATIONS = {"description", "title", "$schema", "$id"}
|
|
22
|
+
_KNOWN = _ANNOTATIONS | {
|
|
23
|
+
"type",
|
|
24
|
+
"enum",
|
|
25
|
+
"pattern",
|
|
26
|
+
"minimum",
|
|
27
|
+
"required",
|
|
28
|
+
"properties",
|
|
29
|
+
"additionalProperties",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@lru_cache(maxsize=4)
|
|
34
|
+
def load_schema_file(path: Path) -> dict[str, Any]:
|
|
35
|
+
schema: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
36
|
+
for node in [schema, *schema["properties"].values()]:
|
|
37
|
+
unknown = set(node) - _KNOWN
|
|
38
|
+
if unknown:
|
|
39
|
+
raise ValueError(f"unsupported schema keywords: {sorted(unknown)}")
|
|
40
|
+
return schema
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_schema() -> dict[str, Any]:
|
|
44
|
+
return load_schema_file(paths.schema_file())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fields() -> tuple[str, ...]:
|
|
48
|
+
"""Row keys in canonical order."""
|
|
49
|
+
return tuple(load_schema()["properties"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def enum(field: str) -> tuple[str, ...]:
|
|
53
|
+
return tuple(load_schema()["properties"][field]["enum"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _check_value(name: str, value: Any, rule: dict[str, Any]) -> str | None:
|
|
57
|
+
if "enum" in rule and value not in rule["enum"]:
|
|
58
|
+
return f"{name}: not an allowed value"
|
|
59
|
+
expected = rule.get("type")
|
|
60
|
+
if expected == "integer" and (not isinstance(value, int) or isinstance(value, bool)):
|
|
61
|
+
return f"{name}: must be an integer"
|
|
62
|
+
if expected == "string" and not isinstance(value, str):
|
|
63
|
+
return f"{name}: must be a string"
|
|
64
|
+
if "minimum" in rule and isinstance(value, int) and value < rule["minimum"]:
|
|
65
|
+
return f"{name}: below minimum {rule['minimum']}"
|
|
66
|
+
if "pattern" in rule and isinstance(value, str) and not re.fullmatch(rule["pattern"], value):
|
|
67
|
+
return f"{name}: does not match the required pattern"
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_row(row: Any) -> list[str]:
|
|
72
|
+
"""Return every schema violation. Messages name fields, never echo values."""
|
|
73
|
+
return check_against(load_schema(), row)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_against(schema: dict[str, Any], row: Any) -> list[str]:
|
|
77
|
+
"""`check_row` for any schema this module can read."""
|
|
78
|
+
if not isinstance(row, dict):
|
|
79
|
+
return ["row is not a JSON object"]
|
|
80
|
+
properties = schema["properties"]
|
|
81
|
+
errors = [f"{name}: missing" for name in schema["required"] if name not in row]
|
|
82
|
+
errors += [f"unknown field #{i}" for i, name in enumerate(row) if name not in properties]
|
|
83
|
+
for name, rule in properties.items():
|
|
84
|
+
if name in row:
|
|
85
|
+
problem = _check_value(name, row[name], rule)
|
|
86
|
+
if problem:
|
|
87
|
+
errors.append(problem)
|
|
88
|
+
return errors
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def canonical(row: Row) -> str:
|
|
92
|
+
"""One row as one line of JSON, in schema field order."""
|
|
93
|
+
ordered = {name: row[name] for name in fields() if name in row}
|
|
94
|
+
return json.dumps(ordered, separators=(",", ":"), ensure_ascii=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def dedupe_key(row: Row) -> tuple[str, str, int, int]:
|
|
98
|
+
"""Identifies a run, not its labels, so a relabeled re-share is still a duplicate."""
|
|
99
|
+
return (row["contributor"], row["batch"], row["offset_s"], row["duration_s"])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_rows(path: Path) -> list[Row]:
|
|
103
|
+
rows: list[Row] = []
|
|
104
|
+
with path.open(encoding="utf-8") as handle:
|
|
105
|
+
for line in handle:
|
|
106
|
+
if line.strip():
|
|
107
|
+
rows.append(json.loads(line))
|
|
108
|
+
return rows
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_dataset(sources: Iterable[Path]) -> list[Row]:
|
|
112
|
+
"""Load rows from files or directories of `.jsonl`, keeping one row per run."""
|
|
113
|
+
merged: dict[tuple[str, str, int, int], Row] = {}
|
|
114
|
+
for source in sources:
|
|
115
|
+
files = sorted(source.glob("*.jsonl")) if source.is_dir() else [source]
|
|
116
|
+
for file in files:
|
|
117
|
+
for row in read_rows(file):
|
|
118
|
+
merged[dedupe_key(row)] = row
|
|
119
|
+
return [merged[key] for key in sorted(merged)]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_rows(path: Path, rows: Iterable[Row]) -> int:
|
|
123
|
+
"""Write atomically. A hook can be killed at its timeout, and a half-written store would make
|
|
124
|
+
every later read fail."""
|
|
125
|
+
lines = [canonical(row) for row in rows]
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
128
|
+
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
|
|
129
|
+
handle.writelines(line + "\n" for line in lines)
|
|
130
|
+
os.replace(temporary, path)
|
|
131
|
+
return len(lines)
|