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/backtest.py ADDED
@@ -0,0 +1,801 @@
1
+ """Honest evaluation: every forecast is made from strictly earlier or separate data.
2
+
3
+ Whole sessions move between train and test, because rows inside one session are far from
4
+ independent. A random row split would leak them across the line, so it is deliberately absent.
5
+
6
+ Two tracks exist because stored `size` is measured after a run. The pre-run track is the real
7
+ forecast and the headline. The oracle-size track is an upper bound and is labeled as one.
8
+
9
+ The default method is chosen by a rule fixed before any result was seen: the lowest
10
+ session-weighted pinball loss on the time split, with ties inside the bootstrap interval going to
11
+ the simpler candidate. Every other number is reported whether it flatters the project or not.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import random
18
+ import statistics
19
+ from collections import defaultdict
20
+ from collections.abc import Callable, Sequence
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ from agentpace import estimate as est
25
+ from agentpace.schema import Row
26
+
27
+ MIN_TRAIN_ROWS = 200
28
+ MIN_PROJECT_ROWS = 30
29
+ CELL_DRAWS = 4000
30
+ PLAN_DRAWS = 1000
31
+ BOOTSTRAPS = 2000
32
+ SEED = 20261107
33
+ MINUTES_PER_ROUND = 3
34
+
35
+ # Frozen. Selection uses time folds up to this week and never re-runs on later ones. Later weeks
36
+ # form the locked holdout, which scores the shipped method on data it was never chosen on.
37
+ SELECTION_LAST_WEEK = "2026-W45"
38
+ # Registered on 2026-11-07, after a local run with Codex rows pooled in had been seen (DESIGN
39
+ # section 11). Selection and the headline numbers score only this harness, the population the
40
+ # rule was frozen on. Every harness still trains the estimator and is reported on its own.
41
+ SELECTION_HARNESS = "claude-code"
42
+ LOCKED_MIN_SESSIONS = 20 # sessions with at least est.MIN_SESSION_RUNS completed runs
43
+
44
+ # Assumptions, not measurements. They stand in for what a person might guess.
45
+ HUMAN_HOURS = {
46
+ "bugfix": 4.0,
47
+ "docs": 2.0,
48
+ "feature": 8.0,
49
+ "migration": 16.0,
50
+ "other": 2.0,
51
+ "refactor": 6.0,
52
+ "review": 1.0,
53
+ "tests": 3.0,
54
+ }
55
+ ROUNDS = {
56
+ "bugfix": 4,
57
+ "docs": 2,
58
+ "feature": 8,
59
+ "migration": 12,
60
+ "other": 3,
61
+ "refactor": 8,
62
+ "review": 2,
63
+ "tests": 4,
64
+ }
65
+
66
+ BASELINES = ("human-guess", "fixed-rounds", "rounds-oracle", "global-median")
67
+ ERROR_METRICS = ("mape", "mdape", "male", "pinball")
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Forecast:
72
+ p50: float
73
+ p80: float
74
+ p90: float
75
+ actual: float
76
+ session: str
77
+ harness: str = "claude-code"
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Fold:
82
+ name: str
83
+ train: list[Row]
84
+ test: list[Row]
85
+
86
+
87
+ def _completed(rows: Sequence[Row]) -> list[Row]:
88
+ return [row for row in rows if row["outcome"] == "completed"]
89
+
90
+
91
+ def _sessions(rows: Sequence[Row]) -> dict[str, list[Row]]:
92
+ groups: dict[str, list[Row]] = defaultdict(list)
93
+ for row in rows:
94
+ groups[row["batch"]].append(row)
95
+ return groups
96
+
97
+
98
+ def session_weeks(rows: Sequence[Row]) -> dict[str, str]:
99
+ """A session belongs to the week of its first run, so it never straddles a split."""
100
+ return {
101
+ batch: min(members, key=lambda r: (r["offset_s"], r["week"]))["week"]
102
+ for batch, members in _sessions(rows).items()
103
+ }
104
+
105
+
106
+ def time_folds(rows: Sequence[Row]) -> list[Fold]:
107
+ """Rolling origin: train on all earlier weeks, test on one week."""
108
+ weeks = session_weeks(rows)
109
+ folds = []
110
+ for week in sorted(set(weeks.values())):
111
+ train = [row for row in rows if weeks[row["batch"]] < week]
112
+ test = [row for row in rows if weeks[row["batch"]] == week]
113
+ if len(_completed(train)) >= MIN_TRAIN_ROWS and _completed(test):
114
+ folds.append(Fold(week, train, test))
115
+ return folds
116
+
117
+
118
+ def group_folds(rows: Sequence[Row], field: str, minimum: int) -> list[Fold]:
119
+ """Leave one group out."""
120
+ groups = sorted({row[field] for row in rows if field in row})
121
+ folds = []
122
+ for group in groups:
123
+ test = [row for row in rows if row.get(field) == group]
124
+ train = [row for row in rows if row.get(field) != group]
125
+ if len(test) >= minimum and len(_completed(train)) >= MIN_TRAIN_ROWS:
126
+ folds.append(Fold(group, train, test))
127
+ return folds
128
+
129
+
130
+ def _candidate(method: str, use_size: bool) -> Callable[[Fold, random.Random], list[Forecast]]:
131
+ def predict(fold: Fold, rng: random.Random) -> list[Forecast]:
132
+ model = est.fit(fold.train, method, use_size)
133
+ cache: dict[tuple[str, ...], tuple[float, float, float]] = {}
134
+ forecasts = []
135
+ for row in _completed(fold.test):
136
+ query = {name: row[name] for name in ("task_kind", "risk", "model", "size")}
137
+ key = tuple(query.values())
138
+ if key not in cache:
139
+ cache[key] = est.cell_quantiles(model, query, CELL_DRAWS, rng)
140
+ p50, p80, p90 = cache[key]
141
+ forecasts.append(
142
+ Forecast(p50, p80, p90, row["duration_s"], row["batch"], row["harness"])
143
+ )
144
+ return forecasts
145
+
146
+ return predict
147
+
148
+
149
+ def _point(guess: Callable[[Row, float], float]) -> Callable[[Fold, random.Random], list[Forecast]]:
150
+ """A single-number baseline. The same number stands in for every quantile."""
151
+
152
+ def predict(fold: Fold, _: random.Random) -> list[Forecast]:
153
+ ordered = sorted(row["duration_s"] for row in _completed(fold.train))
154
+ median = est.quantile(ordered, 0.5)
155
+ forecasts = []
156
+ for row in _completed(fold.test):
157
+ value = guess(row, median)
158
+ forecasts.append(
159
+ Forecast(value, value, value, row["duration_s"], row["batch"], row["harness"])
160
+ )
161
+ return forecasts
162
+
163
+ return predict
164
+
165
+
166
+ def _baselines() -> dict[str, Callable[[Fold, random.Random], list[Forecast]]]:
167
+ return {
168
+ "human-guess": _point(lambda row, _: HUMAN_HOURS[row["task_kind"]] * 3600),
169
+ "fixed-rounds": _point(lambda row, _: ROUNDS[row["task_kind"]] * MINUTES_PER_ROUND * 60.0),
170
+ "rounds-oracle": _point(
171
+ lambda row, _: max(row["tool_calls"], 1) * MINUTES_PER_ROUND * 60.0
172
+ ),
173
+ "global-median": _point(lambda _, median: median),
174
+ }
175
+
176
+
177
+ def _pinball(forecast: Forecast, predicted: float, q: float) -> float:
178
+ gap = forecast.actual - predicted
179
+ return max(q * gap, (q - 1) * gap)
180
+
181
+
182
+ def pinball_sum(forecast: Forecast) -> float:
183
+ return (
184
+ _pinball(forecast, forecast.p50, 0.5)
185
+ + _pinball(forecast, forecast.p80, 0.8)
186
+ + _pinball(forecast, forecast.p90, 0.9)
187
+ )
188
+
189
+
190
+ def _weighted_median(pairs: list[tuple[float, float]]) -> float:
191
+ pairs = sorted(pairs)
192
+ half = sum(weight for _, weight in pairs) / 2
193
+ running = 0.0
194
+ for value, weight in pairs:
195
+ running += weight
196
+ if running >= half:
197
+ return value
198
+ return pairs[-1][0]
199
+
200
+
201
+ def metrics(forecasts: Sequence[Forecast], weights: Sequence[float]) -> dict[str, float]:
202
+ """Accuracy, calibration and sharpness. Width sits beside coverage, because a method can
203
+ earn perfect coverage with absurdly wide ranges."""
204
+ total = sum(weights)
205
+
206
+ def mean(values: Sequence[float]) -> float:
207
+ return sum(v * w for v, w in zip(values, weights, strict=True)) / total
208
+
209
+ percentage = [abs(f.p50 - f.actual) / f.actual for f in forecasts]
210
+ return {
211
+ "mape": 100 * mean(percentage),
212
+ "mdape": 100 * _weighted_median(list(zip(percentage, weights, strict=True))),
213
+ "male": mean([abs(math.log(f.p50 / f.actual)) for f in forecasts]),
214
+ "coverage50": 100 * mean([float(f.actual <= f.p50) for f in forecasts]),
215
+ "coverage80": 100 * mean([float(f.actual <= f.p80) for f in forecasts]),
216
+ "coverage90": 100 * mean([float(f.actual <= f.p90) for f in forecasts]),
217
+ "pinball": mean([pinball_sum(f) for f in forecasts]),
218
+ "width": _weighted_median(
219
+ [(f.p90 / f.p50, w) for f, w in zip(forecasts, weights, strict=True)]
220
+ ),
221
+ # Added in 0.2.1 for the portable prompt. The same forecasts, no new random draw.
222
+ "width80": _weighted_median(
223
+ [(f.p80 / f.p50, w) for f, w in zip(forecasts, weights, strict=True)]
224
+ ),
225
+ }
226
+
227
+
228
+ def both_weightings(forecasts: Sequence[Forecast]) -> dict[str, dict[str, float]]:
229
+ sizes: dict[str, int] = defaultdict(int)
230
+ for forecast in forecasts:
231
+ sizes[forecast.session] += 1
232
+ return {
233
+ "row": metrics(forecasts, [1.0] * len(forecasts)),
234
+ "session": metrics(forecasts, [1.0 / sizes[f.session] for f in forecasts]),
235
+ }
236
+
237
+
238
+ def session_means(
239
+ forecasts: Sequence[Forecast], value: Callable[[Forecast], float]
240
+ ) -> dict[str, float]:
241
+ """One value per session, so a session-weighted metric is the plain mean of these."""
242
+ groups: dict[str, list[float]] = defaultdict(list)
243
+ for forecast in forecasts:
244
+ groups[forecast.session].append(value(forecast))
245
+ return {session: statistics.fmean(values) for session, values in groups.items()}
246
+
247
+
248
+ def session_losses(forecasts: Sequence[Forecast]) -> dict[str, float]:
249
+ """The primary metric, one value per session."""
250
+ return session_means(forecasts, pinball_sum)
251
+
252
+
253
+ def _covered(level: str) -> Callable[[Forecast], float]:
254
+ return lambda f: 100.0 * (f.actual <= getattr(f, level))
255
+
256
+
257
+ def difference_interval(
258
+ a: dict[str, float], b: dict[str, float], rng: random.Random
259
+ ) -> tuple[float, float]:
260
+ """95% cluster bootstrap interval for mean(a) - mean(b), resampling whole sessions."""
261
+ sessions = sorted(a)
262
+ gaps = [a[session] - b[session] for session in sessions]
263
+ means = sorted(statistics.fmean(rng.choices(gaps, k=len(gaps))) for _ in range(BOOTSTRAPS))
264
+ return est.quantile(means, 0.025), est.quantile(means, 0.975)
265
+
266
+
267
+ def mean_interval(values: dict[str, float], rng: random.Random) -> list[float]:
268
+ """95% cluster bootstrap interval for the mean of one value per session."""
269
+ return list(difference_interval(values, dict.fromkeys(values, 0.0), rng))
270
+
271
+
272
+ def intervals(
273
+ forecasts: dict[str, list[Forecast]], winner: str, rivals: Sequence[str], rng: random.Random
274
+ ) -> dict[str, Any]:
275
+ """Session-bootstrap intervals for the shipped method's numbers, and for each rival's
276
+ pinball loss minus ours. A gap interval above zero means the rival really is worse."""
277
+ ours = forecasts[winner]
278
+ losses = session_losses(ours)
279
+ result: dict[str, Any] = {
280
+ level: mean_interval(session_means(ours, _covered(f"p{level[-2:]}")), rng)
281
+ for level in ("coverage50", "coverage80", "coverage90")
282
+ }
283
+ result["pinball"] = mean_interval(losses, rng)
284
+ result["rival_minus_ours"] = {
285
+ name: list(difference_interval(session_losses(forecasts[name]), losses, rng))
286
+ for name in rivals
287
+ }
288
+ return result
289
+
290
+
291
+ def select(forecasts: dict[str, list[Forecast]], rng: random.Random) -> dict[str, Any]:
292
+ """Apply the pre-registered rule. `est.METHODS` is ordered simplest first."""
293
+ losses = {method.name: session_losses(forecasts[method.name]) for method in est.METHODS}
294
+ means = {name: statistics.fmean(values.values()) for name, values in losses.items()}
295
+ best = min(means, key=lambda name: means[name])
296
+ intervals = {name: difference_interval(losses[name], losses[best], rng) for name in means}
297
+ winner = next(
298
+ name for name in means if name == best or intervals[name][0] <= 0 <= intervals[name][1]
299
+ )
300
+ return {
301
+ "primary_metric": "session-weighted pinball loss, summed over 0.5, 0.8 and 0.9",
302
+ "loss": means,
303
+ "difference_from_best": {name: list(intervals[name]) for name in means},
304
+ "lowest_loss": best,
305
+ "winner": winner,
306
+ }
307
+
308
+
309
+ def _forecast_folds(
310
+ folds: Sequence[Fold], use_size: bool, seed: int
311
+ ) -> dict[str, list[list[Forecast]]]:
312
+ """Forecasts per method and per fold. Each method draws from one stream across the folds in
313
+ order, so appending later folds never changes the forecasts for earlier ones."""
314
+ predictors = {m.name: _candidate(m.name, use_size) for m in est.METHODS}
315
+ predictors.update(_baselines())
316
+ forecasts: dict[str, list[list[Forecast]]] = {}
317
+ for name, predict in predictors.items():
318
+ rng = random.Random(f"{seed}:{name}:{use_size}")
319
+ forecasts[name] = [predict(fold, rng) for fold in folds]
320
+ return forecasts
321
+
322
+
323
+ def _pooled(per_fold: dict[str, list[list[Forecast]]], picked: range) -> dict[str, list[Forecast]]:
324
+ return {name: [item for i in picked for item in folds[i]] for name, folds in per_fold.items()}
325
+
326
+
327
+ def _evaluate(
328
+ folds: Sequence[Fold], use_size: bool, seed: int
329
+ ) -> tuple[dict[str, list[Forecast]], dict[str, dict[str, dict[str, float]]]]:
330
+ forecasts = _pooled(_forecast_folds(folds, use_size, seed), range(len(folds)))
331
+ return forecasts, {name: both_weightings(items) for name, items in forecasts.items()}
332
+
333
+
334
+ def by_week(
335
+ folds: Sequence[Fold], per_fold: dict[str, list[list[Forecast]]], winner: str, last: str
336
+ ) -> list[dict[str, Any]]:
337
+ """The shipped method fold by fold, because a pooled coverage can hide weeks that miss in
338
+ both directions."""
339
+ weeks = []
340
+ for i, fold in enumerate(folds):
341
+ if not per_fold[winner][i]:
342
+ continue # a later week may hold no session of the selection harness
343
+ ours = both_weightings(per_fold[winner][i])
344
+ median = both_weightings(per_fold["global-median"][i])["session"]
345
+ weeks.append(
346
+ {
347
+ "week": fold.name,
348
+ "period": "selection" if fold.name <= last else "locked",
349
+ "sessions": len({f.session for f in per_fold[winner][i]}),
350
+ "rows": len(per_fold[winner][i]),
351
+ "coverage80_session": ours["session"]["coverage80"],
352
+ "coverage80_row": ours["row"]["coverage80"],
353
+ "width": ours["session"]["width"],
354
+ "pinball": ours["session"]["pinball"],
355
+ "median_pinball": median["pinball"],
356
+ }
357
+ )
358
+ return weeks
359
+
360
+
361
+ def plan_forecasts(
362
+ folds: Sequence[Fold], method: str, seed: int, harness: str | None = None
363
+ ) -> dict[str, list[Forecast]]:
364
+ """Treat each held-out session as a plan and predict how long it took, end to end.
365
+
366
+ Real dependencies are not recorded, so the critical path falls back to the longest run, and
367
+ the observed peak concurrency stands in for the cap. That peak is itself an outcome, so this
368
+ tests the throughput and session terms, not dependency planning.
369
+
370
+ With `harness`, only that harness's sessions are scored. Training keeps every row.
371
+ """
372
+ ours: list[Forecast] = []
373
+ naive: list[Forecast] = []
374
+ for fold in folds:
375
+ model = est.fit(fold.train, method)
376
+ median = est.quantile(sorted(r["duration_s"] for r in _completed(fold.train)), 0.5)
377
+ for batch, members in sorted(_sessions(_completed(fold.test)).items()):
378
+ if len(members) < est.MIN_SESSION_RUNS:
379
+ continue
380
+ if harness is not None and members[0]["harness"] != harness:
381
+ continue
382
+ tasks = [
383
+ est.Task(str(i), row["task_kind"], row["risk"], model=row["model"])
384
+ for i, row in enumerate(members)
385
+ ]
386
+ cap = max(row["parallel_agents"] for row in members)
387
+ plan = est.estimate(model, tasks, cap, PLAN_DRAWS, seed)
388
+ first = min(row["offset_s"] for row in members)
389
+ actual = max(row["offset_s"] + row["duration_s"] for row in members) - first
390
+ actual = max(actual, 1)
391
+ ours.append(Forecast(plan.p50, plan.p80, plan.p90, actual, batch))
392
+ guess = median * len(members) / cap
393
+ naive.append(Forecast(guess, guess, guess, actual, batch))
394
+ return {"agentpace": ours, "sum-of-medians": naive}
395
+
396
+
397
+ def _losses(
398
+ scheme: str, track: str, results: dict[str, dict[str, dict[str, float]]], winner: str
399
+ ) -> list[dict[str, Any]]:
400
+ """Every place a baseline beat the shipped method. Generated, so it cannot be edited out."""
401
+ entries = []
402
+ ours = results[winner]
403
+ for weighting in ("row", "session"):
404
+ for baseline in BASELINES:
405
+ theirs = results[baseline][weighting]
406
+ for metric in ERROR_METRICS:
407
+ if theirs[metric] < ours[weighting][metric]:
408
+ entries.append(
409
+ {
410
+ "split": scheme,
411
+ "track": track,
412
+ "weighting": weighting,
413
+ "metric": metric,
414
+ "baseline": baseline,
415
+ "baseline_value": theirs[metric],
416
+ "agentpace_value": ours[weighting][metric],
417
+ }
418
+ )
419
+ gap_theirs = abs(theirs["coverage80"] - 80)
420
+ gap_ours = abs(ours[weighting]["coverage80"] - 80)
421
+ if gap_theirs < gap_ours:
422
+ entries.append(
423
+ {
424
+ "split": scheme,
425
+ "track": track,
426
+ "weighting": weighting,
427
+ "metric": "coverage80",
428
+ "baseline": baseline,
429
+ "baseline_value": theirs["coverage80"],
430
+ "agentpace_value": ours[weighting]["coverage80"],
431
+ }
432
+ )
433
+ return entries
434
+
435
+
436
+ def dataset_summary(rows: Sequence[Row]) -> dict[str, Any]:
437
+ completed = _completed(rows)
438
+ sessions = _sessions(rows)
439
+ largest = sorted((len(members) for members in sessions.values()), reverse=True)
440
+ project_sizes: dict[str, int] = defaultdict(int)
441
+ for row in rows:
442
+ project_sizes[row.get("project", "")] += 1
443
+ return {
444
+ "rows": len(rows),
445
+ "completed": len(completed),
446
+ "contributors": len({row["contributor"] for row in rows}),
447
+ "projects": len({row["project"] for row in rows if "project" in row}),
448
+ "sessions": len(sessions),
449
+ "weeks": sorted({row["week"] for row in rows}),
450
+ "top_project_share": 100 * max(project_sizes.values()) / len(rows),
451
+ "top_three_session_share": 100 * sum(largest[:3]) / len(rows),
452
+ }
453
+
454
+
455
+ def harness_summary(
456
+ rows: Sequence[Row], forecasts: dict[str, list[Forecast]], winner: str
457
+ ) -> dict[str, Any]:
458
+ """Each harness on its own, from forecasts already drawn, so no random number is used.
459
+
460
+ The estimator does not use the harness. This shows how each one fares before anyone
461
+ pools them, which is the check a harness term would have to pass."""
462
+ completed = _completed(rows)
463
+ dataset = {
464
+ harness: {
465
+ "rows": sum(row["harness"] == harness for row in rows),
466
+ "completed": sum(row["harness"] == harness for row in completed),
467
+ "sessions": len({row["batch"] for row in rows if row["harness"] == harness}),
468
+ }
469
+ for harness in sorted({row["harness"] for row in rows})
470
+ }
471
+ selection: dict[str, Any] = {}
472
+ for harness in dataset:
473
+ ours = [f for f in forecasts[winner] if f.harness == harness]
474
+ median = [f for f in forecasts["global-median"] if f.harness == harness]
475
+ if not ours:
476
+ continue
477
+ mine, theirs = both_weightings(ours), both_weightings(median)
478
+ selection[harness] = {
479
+ "sessions": len({f.session for f in ours}),
480
+ "rows": len(ours),
481
+ "pinball": mine["session"]["pinball"],
482
+ "median_pinball": theirs["session"]["pinball"],
483
+ "coverage80_session": mine["session"]["coverage80"],
484
+ "coverage80_row": mine["row"]["coverage80"],
485
+ "width": mine["session"]["width"],
486
+ }
487
+ return {"dataset": dataset, "selection_folds": selection}
488
+
489
+
490
+ def harness_methods(forecasts: dict[str, list[Forecast]]) -> dict[str, Any]:
491
+ """Every candidate and the global median, per harness, at task level. The forward test
492
+ for a harness the selection does not score: from forecasts already drawn, so no random
493
+ number is used."""
494
+ names = [method.name for method in est.METHODS] + ["global-median"]
495
+ result: dict[str, Any] = {}
496
+ for harness in sorted({f.harness for f in forecasts[names[0]]}):
497
+ scores = {}
498
+ for name in names:
499
+ items = [f for f in forecasts[name] if f.harness == harness]
500
+ found = both_weightings(items)["session"]
501
+ scores[name] = {
502
+ "pinball": found["pinball"],
503
+ "coverage80": found["coverage80"],
504
+ "width": found["width"],
505
+ }
506
+ items = [f for f in forecasts[names[0]] if f.harness == harness]
507
+ result[harness] = {
508
+ "sessions": len({f.session for f in items}),
509
+ "rows": len(items),
510
+ "methods": scores,
511
+ }
512
+ return result
513
+
514
+
515
+ def _project_order(
516
+ folds: Sequence[Fold], per_fold: dict[str, list[list[Forecast]]], winner: str
517
+ ) -> list[int]:
518
+ """Fold indexes, most held-out sessions first, then most rows, then pseudonym, so the
519
+ order never depends on how folds were listed. Folds with no completed run are left out."""
520
+ sizes = {
521
+ i: (len({f.session for f in per_fold[winner][i]}), len(per_fold[winner][i]), fold.name)
522
+ for i, fold in enumerate(folds)
523
+ if per_fold[winner][i]
524
+ }
525
+ return sorted(sizes, key=lambda i: (-sizes[i][0], -sizes[i][1], sizes[i][2]))
526
+
527
+
528
+ def by_project(
529
+ folds: Sequence[Fold], per_fold: dict[str, list[list[Forecast]]], winner: str
530
+ ) -> list[dict[str, Any]]:
531
+ """Each held-out project on its own, from forecasts already drawn, so no random number is
532
+ used. Listed in rank order and never by pseudonym: the rank is the label."""
533
+ result = []
534
+ for i in _project_order(folds, per_fold, winner):
535
+ ours, median = per_fold[winner][i], per_fold["global-median"][i]
536
+ mine, theirs = both_weightings(ours), both_weightings(median)
537
+ result.append(
538
+ {
539
+ "harness": ", ".join(sorted({row["harness"] for row in folds[i].test})),
540
+ "sessions": len({f.session for f in ours}),
541
+ "rows": len(ours),
542
+ "pinball": mine["session"]["pinball"],
543
+ "median_pinball": theirs["session"]["pinball"],
544
+ "coverage80_session": mine["session"]["coverage80"],
545
+ "coverage80_row": mine["row"]["coverage80"],
546
+ "width": mine["session"]["width"],
547
+ }
548
+ )
549
+ return result
550
+
551
+
552
+ def without_largest(
553
+ folds: Sequence[Fold], per_fold: dict[str, list[list[Forecast]]], winner: str
554
+ ) -> dict[str, Any]:
555
+ """The project split's session-weighted pinball with and without the project holding the
556
+ most held-out sessions, so one project's vote is visible. From forecasts already drawn."""
557
+ order = _project_order(folds, per_fold, winner)
558
+ largest = order[0]
559
+ candidates = [method.name for method in est.METHODS]
560
+ names = [*candidates, "global-median"]
561
+
562
+ def loss(name: str, kept: Sequence[int]) -> float:
563
+ items = [f for i in kept for f in per_fold[name][i]]
564
+ return both_weightings(items)["session"]["pinball"]
565
+
566
+ rest = [i for i in order if i != largest]
567
+ without = {name: loss(name, rest) for name in names}
568
+ with_it = {name: loss(name, order) for name in candidates}
569
+ return {
570
+ "project": 1,
571
+ "harness": ", ".join(sorted({row["harness"] for row in folds[largest].test})),
572
+ "sessions": len({f.session for f in per_fold[winner][largest]}),
573
+ "of_sessions": len({f.session for i in order for f in per_fold[winner][i]}),
574
+ "loss": without,
575
+ "lowest": min(candidates, key=lambda name: without[name]),
576
+ "lowest_with_it": min(candidates, key=lambda name: with_it[name]),
577
+ }
578
+
579
+
580
+ def project_rows(
581
+ folds: Sequence[Fold], per_fold: dict[str, list[list[Forecast]]], winner: str
582
+ ) -> dict[str, Any]:
583
+ """Each project alone and, when at least two have completed runs, the split without the
584
+ largest. With only one, dropping it would leave nothing to score."""
585
+ rows = by_project(folds, per_fold, winner)
586
+ result: dict[str, Any] = {"by_project": rows}
587
+ if len(rows) >= 2:
588
+ result["without_largest"] = without_largest(folds, per_fold, winner)
589
+ return result
590
+
591
+
592
+ def _locked_holdout(
593
+ locked: Sequence[Fold],
594
+ forecasts: dict[str, list[Forecast]],
595
+ winner: str,
596
+ seed: int,
597
+ harness: str | None = None,
598
+ everyone: dict[str, list[Forecast]] | None = None,
599
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
600
+ """Score the shipped method on weeks after selection. Nothing here feeds back into it.
601
+
602
+ The headline scores `harness`, as selection does. `everyone` adds each harness at task
603
+ level, whatever the headline status, so a harness selection never scored is tested here."""
604
+ plans = plan_forecasts(locked, winner, seed, harness)
605
+ qualifying = len(plans["agentpace"])
606
+ base: dict[str, Any] = {
607
+ "folds": [fold.name for fold in locked],
608
+ "min_sessions": LOCKED_MIN_SESSIONS,
609
+ "qualifying_sessions": qualifying,
610
+ }
611
+ if everyone is not None and everyone[winner]:
612
+ base["by_harness"] = harness_methods(everyone)
613
+ if qualifying < LOCKED_MIN_SESSIONS:
614
+ status = (
615
+ f"not enough data ({qualifying} of {LOCKED_MIN_SESSIONS} sessions with "
616
+ f"{est.MIN_SESSION_RUNS}+ runs)"
617
+ )
618
+ return {**base, "status": status}, []
619
+ names = [winner, *BASELINES]
620
+ results = {name: both_weightings(forecasts[name]) for name in names}
621
+ rng = random.Random(f"{seed}:locked-intervals")
622
+ locked_result = {
623
+ **base,
624
+ "status": "ok",
625
+ "rows": len(forecasts[winner]),
626
+ "tracks": {"pre-run": results},
627
+ "intervals": intervals(forecasts, winner, BASELINES, rng),
628
+ "plans": {name: both_weightings(items)["row"] for name, items in plans.items()},
629
+ }
630
+ return locked_result, _losses("locked", "pre-run", results, winner)
631
+
632
+
633
+ def _of_harness(forecasts: list[Forecast], harness: str | None) -> list[Forecast]:
634
+ return forecasts if harness is None else [f for f in forecasts if f.harness == harness]
635
+
636
+
637
+ def _pooled_selection(
638
+ everyone: dict[str, list[Forecast]], harnesses: Sequence[str], seed: int
639
+ ) -> dict[str, Any]:
640
+ """The same rule over every harness at once, printed for the record and never shipped.
641
+ Its own random stream, so it moves no other number."""
642
+ if len(harnesses) < 2:
643
+ return {"status": f"same as above ({len(harnesses)} harness)"}
644
+ result = select(everyone, random.Random(f"{seed}:pooled-selection"))
645
+ result["status"] = "ok"
646
+ result["baselines"] = {
647
+ name: both_weightings(everyone[name])["session"]["pinball"] for name in BASELINES
648
+ }
649
+ return result
650
+
651
+
652
+ def _plan_losses(split: str, results: dict[str, dict[str, float]]) -> list[dict[str, Any]]:
653
+ ours = results["agentpace"]
654
+ theirs = results["sum-of-medians"]
655
+ return [
656
+ {
657
+ "split": split,
658
+ "track": "whole sessions",
659
+ "weighting": "session",
660
+ "metric": metric,
661
+ "baseline": "sum-of-medians",
662
+ "baseline_value": theirs[metric],
663
+ "agentpace_value": ours[metric],
664
+ }
665
+ for metric in ("mape", "mdape", "male", "pinball")
666
+ if theirs[metric] < ours[metric]
667
+ ]
668
+
669
+
670
+ def run(
671
+ rows: Sequence[Row], seed: int = SEED, selection_last_week: str = SELECTION_LAST_WEEK
672
+ ) -> dict[str, Any]:
673
+ """Everything `docs/backtest.md` reports."""
674
+ usable = [dict(row) for row in rows]
675
+ for row in usable:
676
+ row.setdefault("project", "")
677
+ every_week = time_folds(usable)
678
+ time = [fold for fold in every_week if fold.name <= selection_last_week]
679
+ locked = [fold for fold in every_week if fold.name > selection_last_week]
680
+ if not time:
681
+ raise ValueError("not enough weeks of data for a time split")
682
+ harnesses = sorted({row["harness"] for row in rows})
683
+ # The population the rule was frozen on. A dataset without it, such as one person's Codex
684
+ # rows, has a single harness, and selection scores all of it.
685
+ population = SELECTION_HARNESS if SELECTION_HARNESS in harnesses else None
686
+ every_fold = _forecast_folds([*time, *locked], use_size=False, seed=seed)
687
+ per_fold = {
688
+ name: [_of_harness(fold, population) for fold in folds]
689
+ for name, folds in every_fold.items()
690
+ }
691
+ forecasts = _pooled(per_fold, range(len(time)))
692
+ everyone = _pooled(every_fold, range(len(time)))
693
+ pre_run = {name: both_weightings(items) for name, items in forecasts.items()}
694
+ selection = select(forecasts, random.Random(seed))
695
+ selection["last_week"] = selection_last_week
696
+ selection["harness"] = population or "all"
697
+ winner = selection["winner"]
698
+ oracle_forecasts, _ = _evaluate(time, use_size=True, seed=seed)
699
+ oracle = {
700
+ name: both_weightings(_of_harness(items, population))
701
+ for name, items in oracle_forecasts.items()
702
+ }
703
+ report: dict[str, Any] = {
704
+ "seed": seed,
705
+ "dataset": dataset_summary(rows),
706
+ "selection": selection,
707
+ "pooled_selection": _pooled_selection(everyone, harnesses, seed),
708
+ "splits": {
709
+ "time": {
710
+ "status": "ok",
711
+ "folds": [fold.name for fold in time],
712
+ "rows": len(forecasts[winner]),
713
+ "tracks": {"pre-run": pre_run, "oracle-size": oracle},
714
+ "intervals": intervals(
715
+ forecasts, winner, BASELINES, random.Random(f"{seed}:intervals")
716
+ ),
717
+ "by_week": by_week([*time, *locked], per_fold, winner, selection_last_week),
718
+ }
719
+ },
720
+ }
721
+ report_losses = _losses("time", "pre-run", pre_run, winner)
722
+ report_losses += _losses("time", "oracle-size", oracle, winner)
723
+ if locked:
724
+ later = range(len(time), len(time) + len(locked))
725
+ report["locked"], locked_losses = _locked_holdout(
726
+ locked,
727
+ _pooled(per_fold, later),
728
+ winner,
729
+ seed,
730
+ population,
731
+ _pooled(every_fold, later),
732
+ )
733
+ else:
734
+ report["locked"] = {
735
+ "status": f"not enough data (no weeks after {selection_last_week} yet)",
736
+ "folds": [],
737
+ "min_sessions": LOCKED_MIN_SESSIONS,
738
+ "qualifying_sessions": 0,
739
+ }
740
+ locked_losses = []
741
+
742
+ projects = group_folds(usable, "project", MIN_PROJECT_ROWS)
743
+ projects = [fold for fold in projects if fold.name]
744
+ if len(projects) >= 2:
745
+ # The same draws `_evaluate` makes, kept per fold so each project can be shown alone.
746
+ per_project = _forecast_folds(projects, use_size=False, seed=seed)
747
+ held_out = _pooled(per_project, range(len(projects)))
748
+ project_results = {name: both_weightings(items) for name, items in held_out.items()}
749
+ report["splits"]["project"] = {
750
+ "status": "ok",
751
+ "folds": len(projects),
752
+ "rows": len(held_out[winner]),
753
+ "tracks": {"pre-run": project_results},
754
+ **project_rows(projects, per_project, winner),
755
+ }
756
+ report_losses += _losses("project", "pre-run", project_results, winner)
757
+ else:
758
+ report["splits"]["project"] = {"status": "could not run (fewer than 2 usable projects)"}
759
+
760
+ contributors = len({row["contributor"] for row in rows})
761
+ if contributors >= 2:
762
+ groups = group_folds(usable, "contributor", 1)
763
+ held_out, contributor_results = _evaluate(groups, use_size=False, seed=seed)
764
+ report["splits"]["contributor"] = {
765
+ "status": "ok",
766
+ "folds": len(groups),
767
+ "rows": len(held_out[winner]),
768
+ "tracks": {"pre-run": contributor_results},
769
+ }
770
+ report_losses += _losses("contributor", "pre-run", contributor_results, winner)
771
+ else:
772
+ report["splits"]["contributor"] = {"status": f"could not run ({contributors} contributor)"}
773
+
774
+ if len(harnesses) >= 2:
775
+ groups = group_folds(usable, "harness", 1)
776
+ held_out, harness_results = _evaluate(groups, use_size=False, seed=seed)
777
+ report["splits"]["harness"] = {
778
+ "status": "ok",
779
+ "folds": len(groups),
780
+ "rows": len(held_out[winner]),
781
+ "tracks": {"pre-run": harness_results},
782
+ }
783
+ report_losses += _losses("harness", "pre-run", harness_results, winner)
784
+ else:
785
+ report["splits"]["harness"] = {"status": f"could not run ({len(harnesses)} harness)"}
786
+ report["harness"] = harness_summary(rows, everyone, winner)
787
+
788
+ plans = plan_forecasts(time, winner, seed, population)
789
+ report["plans"] = {
790
+ "label": "oracle-structure",
791
+ "sessions": len(plans["agentpace"]),
792
+ "results": {name: both_weightings(items)["row"] for name, items in plans.items()},
793
+ "intervals": intervals(
794
+ plans, "agentpace", ["sum-of-medians"], random.Random(f"{seed}:plan-intervals")
795
+ ),
796
+ }
797
+ report_losses += _plan_losses("time", report["plans"]["results"])
798
+ if report["locked"]["status"] == "ok":
799
+ locked_losses += _plan_losses("locked", report["locked"]["plans"])
800
+ report["losses"] = report_losses + locked_losses
801
+ return report