peeksafe 0.0.0-stage → 0.1.0

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +78 -0
  4. package/README.md +492 -2
  5. package/RESEARCH-NOTES.md +274 -0
  6. package/dist/baseline.d.ts +45 -0
  7. package/dist/baseline.d.ts.map +1 -0
  8. package/dist/baseline.js +49 -0
  9. package/dist/baseline.js.map +1 -0
  10. package/dist/check.d.ts +64 -0
  11. package/dist/check.d.ts.map +1 -0
  12. package/dist/check.js +172 -0
  13. package/dist/check.js.map +1 -0
  14. package/dist/cluster.d.ts +328 -0
  15. package/dist/cluster.d.ts.map +1 -0
  16. package/dist/cluster.js +598 -0
  17. package/dist/cluster.js.map +1 -0
  18. package/dist/errors.d.ts +87 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +143 -0
  21. package/dist/errors.js.map +1 -0
  22. package/dist/evidence.d.ts +15 -0
  23. package/dist/evidence.d.ts.map +1 -0
  24. package/dist/evidence.js +30 -0
  25. package/dist/evidence.js.map +1 -0
  26. package/dist/frontier.d.ts +269 -0
  27. package/dist/frontier.d.ts.map +1 -0
  28. package/dist/frontier.js +460 -0
  29. package/dist/frontier.js.map +1 -0
  30. package/dist/gate.d.ts +173 -0
  31. package/dist/gate.d.ts.map +1 -0
  32. package/dist/gate.js +196 -0
  33. package/dist/gate.js.map +1 -0
  34. package/dist/index.d.ts +60 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +79 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/plan.d.ts +248 -0
  39. package/dist/plan.d.ts.map +1 -0
  40. package/dist/plan.js +482 -0
  41. package/dist/plan.js.map +1 -0
  42. package/dist/rand.d.ts +17 -0
  43. package/dist/rand.d.ts.map +1 -0
  44. package/dist/rand.js +92 -0
  45. package/dist/rand.js.map +1 -0
  46. package/dist/stats.d.ts +397 -0
  47. package/dist/stats.d.ts.map +1 -0
  48. package/dist/stats.js +1245 -0
  49. package/dist/stats.js.map +1 -0
  50. package/dist/stop.d.ts +163 -0
  51. package/dist/stop.d.ts.map +1 -0
  52. package/dist/stop.js +262 -0
  53. package/dist/stop.js.map +1 -0
  54. package/package.json +64 -4
  55. package/src/baseline.ts +72 -0
  56. package/src/check.ts +238 -0
  57. package/src/cluster.ts +830 -0
  58. package/src/errors.ts +208 -0
  59. package/src/evidence.ts +62 -0
  60. package/src/frontier.ts +782 -0
  61. package/src/gate.ts +358 -0
  62. package/src/index.ts +206 -0
  63. package/src/plan.ts +778 -0
  64. package/src/rand.ts +121 -0
  65. package/src/stats.ts +1579 -0
  66. package/src/stop.ts +452 -0
package/src/plan.ts ADDED
@@ -0,0 +1,778 @@
1
+ /**
2
+ * plan.ts: power economics. What will this cost, and can we even detect it?
3
+ *
4
+ * Without a plan, the first sign that a suite cannot be decided is a large bill
5
+ * and no certified case. This module tells you *before* you spend anything, and it will tell you that the honest answer is
6
+ * sometimes "you cannot detect this at any price".
7
+ *
8
+ * The three numbers that matter, per case:
9
+ *
10
+ * 1. **The bar.** e-BH at FDR q over m cases certifies a case on its own only
11
+ * once its e-value clears m/q. That is the target the evidence has to hit.
12
+ *
13
+ * 2. **The ceiling.** `evidenceCeilingLogE`, the limit of the unpaired
14
+ * two-sample e-value as candidate runs go to infinity. Both marginals are
15
+ * the same binomial likelihood under different priors, so everything that
16
+ * grows with n cancels and the Bayes factor converges to the prior density
17
+ * ratio at the observed rate. If the ceiling is under the bar, no number of
18
+ * candidate runs will certify the case. This is not a budget problem and no
19
+ * budget fixes it: **more baseline runs** fix it, by sharpening the null.
20
+ *
21
+ * 3. **The paired alternative.** Re-run the baseline alongside the candidate
22
+ * on the same seed and the null becomes an exact point mass at ½ (a
23
+ * discordant pair points either way with probability exactly one half under
24
+ * "nothing changed"). Nothing is estimated, so nothing caps the evidence:
25
+ * the paired e-value grows linearly in discordant pairs. It costs two runs
26
+ * per observation and it is still, routinely, the cheaper design, and it
27
+ * is the only one that works at all for a case whose ceiling is under the
28
+ * bar.
29
+ *
30
+ * Everything here is a *plan*: it evaluates the mean trajectory (the candidate
31
+ * scores exactly its expected successes). Realised sample counts vary around
32
+ * it.
33
+ */
34
+
35
+ import { type BaselineStat, baselineNullRate, type CaseRef } from "./baseline.js";
36
+ import {
37
+ PeeksafeError,
38
+ requireErrorRates,
39
+ requireOpenProbability,
40
+ requireProbability,
41
+ } from "./errors.js";
42
+ import {
43
+ betaQuantile,
44
+ ebhSoloThreshold,
45
+ evidenceCeilingLogE,
46
+ mcnemarSamplesForEvidence,
47
+ type PairedCounts,
48
+ pairPhi,
49
+ sampleSizeTwoProportion,
50
+ samplesForEvidence,
51
+ sprtExpectedN,
52
+ } from "./stats.js";
53
+
54
+ export interface PlanConfig {
55
+ /** the drop we want to be able to detect, in probability units (0.15 = 15 points) */
56
+ mde: number;
57
+ /** suite-level false discovery rate */
58
+ fdr: number;
59
+ /** USD per candidate run */
60
+ costPerRunUsd: number;
61
+ /** milliseconds per run, wall clock, at concurrency 1 */
62
+ msPerRun: number;
63
+ /** how many runs the screening pass spends per case */
64
+ screenRuns: number;
65
+ /**
66
+ * Measured coupling between the paired arms, in [0,1]. 0 means the seeds
67
+ * bought nothing; 1 means perfect common-random-numbers coupling. Measure it
68
+ * with `pairPhi` on a pilot rather than assuming it, and plan at 0 as well
69
+ * so the sensitivity is visible.
70
+ */
71
+ pairCoupling: number;
72
+ /** how many runs to fan out in parallel, only affects wall time */
73
+ concurrency: number;
74
+ /** beyond this many runs for one case we stop calling it "affordable" */
75
+ runsPerCaseCeiling: number;
76
+ /**
77
+ * Which design the *expected* bill is priced for. A paired observation is two
78
+ * runs, so this doubles `typicalRuns`.
79
+ *
80
+ * `undefined` means "price the design this plan recommends", which is the
81
+ * only self-consistent answer: `makePlan` recommends pairing whenever more
82
+ * than a quarter of the suite has an unpaired evidence ceiling under the bar,
83
+ * and quoting the *unpaired* bill underneath that recommendation would price
84
+ * a design nobody runs.
85
+ */
86
+ design?: "unpaired" | "paired";
87
+
88
+ /* ── the sequential design being priced ────────────────────────────────
89
+ * Only the *expected* cost depends on these; the certify-all ceiling does
90
+ * not. Keep them in step with the stopping rule you actually run, or the plan prices a design
91
+ * nobody is going to execute.
92
+ */
93
+ alpha: number;
94
+ beta: number;
95
+ minTrials: number;
96
+ maxTrials: number;
97
+ batchSize: number;
98
+ nullQuantile: number;
99
+ }
100
+
101
+ export const DEFAULT_PLAN: PlanConfig = {
102
+ mde: 0.15,
103
+ fdr: 0.05,
104
+ // These three are *assumptions* until you measure them on a real run: cost
105
+ // and latency from your runner, coupling with `pairPhi` on a pilot.
106
+ costPerRunUsd: 0.0021,
107
+ msPerRun: 780,
108
+ pairCoupling: 0.9,
109
+ screenRuns: 4,
110
+ concurrency: 8,
111
+ runsPerCaseCeiling: 2000,
112
+ alpha: 0.05,
113
+ beta: 0.1,
114
+ minTrials: 8,
115
+ maxTrials: 96,
116
+ batchSize: 4,
117
+ nullQuantile: 0.5,
118
+ };
119
+
120
+ export type Detectability =
121
+ /** the unpaired sequential test can certify this case within the run ceiling */
122
+ | "UNPAIRED"
123
+ /** only the paired design can certify it, the unpaired evidence ceiling is under the bar */
124
+ | "PAIRED_ONLY"
125
+ /** the run count is finite but past the ceiling: technically possible, practically not */
126
+ | "TOO_EXPENSIVE"
127
+ /** the case cannot lose `mde` points at all, its baseline rate is not that high */
128
+ | "IMPOSSIBLE";
129
+
130
+ export interface PlanCase {
131
+ caseId: string;
132
+ suite: string;
133
+ baseline: { successes: number; trials: number; rate: number };
134
+ /** the rate the alternative sits at: baseline − mde, floored at 0 */
135
+ alternativeRate: number;
136
+ /** log of the e-value a case must reach to be certified on its own evidence */
137
+ barLogE: number;
138
+ /** the limit of the unpaired log e-value as runs → ∞ */
139
+ ceilingLogE: number;
140
+ unpaired: { runs: number; costUsd: number; wallMs: number };
141
+ paired: { pairs: number; runs: number; costUsd: number; wallMs: number };
142
+ /**
143
+ * The coupling `paired` is priced at: `pairPhi` of this case's pilot when
144
+ * `makePlan` was given one, floored at 0, otherwise `config.pairCoupling`.
145
+ */
146
+ pairCoupling: number;
147
+ detectability: Detectability;
148
+ /**
149
+ * The design to run this case with: of the designs that decide it within
150
+ * `runsPerCaseCeiling`, the one that needs fewer runs, unpaired on a tie.
151
+ * Null when neither does.
152
+ */
153
+ design: "unpaired" | "paired" | null;
154
+ /**
155
+ * How many baseline runs this case would need for the *unpaired* design to
156
+ * work, or null if it already works. This is the actionable half of an
157
+ * "undetectable" verdict.
158
+ */
159
+ baselineRunsNeeded: number | null;
160
+ /** what a fixed-N two-proportion design would spend per arm, for comparison */
161
+ fixedNPerArm: number;
162
+ reason: string;
163
+ }
164
+
165
+ export interface PlanTotals {
166
+ cases: number;
167
+ /** how many cases each design can actually certify */
168
+ decidableUnpaired: number;
169
+ decidableBest: number;
170
+ /** every case run to certification, unpaired */
171
+ unpairedRuns: number;
172
+ unpairedCostUsd: number;
173
+ /** every case run to certification, paired */
174
+ pairedRuns: number;
175
+ pairedCostUsd: number;
176
+ /** per case, the cheaper of the two designs that can decide it */
177
+ bestRuns: number;
178
+ bestCostUsd: number;
179
+ /** screen everything cheaply, then pay only for what the screen keeps */
180
+ screenedRuns: number;
181
+ screenedCostUsd: number;
182
+ /** the fixed-N design with the same nominal guarantees */
183
+ fixedNRuns: number;
184
+ fixedNCostUsd: number;
185
+ wallMsUnpaired: number;
186
+ wallMsPaired: number;
187
+ wallMsBest: number;
188
+ wallMsScreened: number;
189
+ /** fraction of cases the screen is budgeted to carry through to the full test */
190
+ screenContinueFraction: number;
191
+
192
+ /* ── the expected cost, as opposed to the worst case ───────────────────
193
+ * `bestRuns` above is what it costs to certify *every* case, i.e. a pull
194
+ * request in which everything regressed by exactly the MDE. Real pull
195
+ * requests move nothing, cases hit the H₀ wall early, and they stop costing
196
+ * money. Measured against three real runs of this suite, `bestRuns`
197
+ * over-predicts what the gate actually spends by 5 to 8×, and the numbers below
198
+ * predict it to within about 10% when checked against real runs.
199
+ */
200
+ /** 1 unpaired, 2 paired, what one observation costs in runs */
201
+ runsPerObservation: number;
202
+ /** expected *runs* (not observations) for a pull request in which nothing moved */
203
+ typicalRuns: number;
204
+ typicalCostUsd: number;
205
+ wallMsTypical: number;
206
+ /** the same, with a screening pass in front */
207
+ typicalScreenedRuns: number;
208
+ typicalScreenedCostUsd: number;
209
+ }
210
+
211
+ export interface Plan {
212
+ config: PlanConfig;
213
+ cases: PlanCase[];
214
+ totals: PlanTotals;
215
+ /** cases no budget can decide with the current baseline */
216
+ undecidable: PlanCase[];
217
+ /** cases only the paired design can reach */
218
+ pairedOnly: PlanCase[];
219
+ /** cases whose baseline rate is too low to lose `mde` points at all */
220
+ impossible: PlanCase[];
221
+ /** the design the recommendation names, and the one `expectedCostUsd` prices */
222
+ recommendedDesign: "unpaired" | "paired";
223
+ /** the cheapest design that decides every decidable case */
224
+ recommendation: string;
225
+ /** honest one-liner about affordability at the configured cost per run */
226
+ verdict: string;
227
+ /**
228
+ * What a *typical* pull request costs, one in which nothing moved, so most
229
+ * cases stop at the H₀ wall. This is the headline number.
230
+ */
231
+ expectedCostUsd: number;
232
+ /**
233
+ * What a pull request in which *every* case regressed would cost.
234
+ *
235
+ * Not an upper bound on `expectedCostUsd`, despite running every case to
236
+ * certification: the sequential test stops a case as soon as it clears the
237
+ * bar, so an all-regressed suite can finish cheaper than one that did not
238
+ * move and ran its full schedule. `verdict` says which way round they came
239
+ * out rather than presenting this as the worst case.
240
+ */
241
+ ceilingCostUsd: number;
242
+ }
243
+
244
+ const round = (x: number) => (Number.isFinite(x) ? Math.ceil(x) : Infinity);
245
+
246
+ const medianTrials = (ps: PlanCase[]): number => {
247
+ const xs = ps.map((p) => p.baseline.trials).sort((a, b) => a - b);
248
+ return xs.length === 0 ? 0 : xs[Math.floor(xs.length / 2)]!;
249
+ };
250
+
251
+ /**
252
+ * How many baseline runs would lift this case's evidence ceiling over the bar?
253
+ * The *smallest* such number, holding the baseline *rate* fixed, "if I keep
254
+ * measuring main at the same quality, how long until this case becomes
255
+ * decidable at all?"
256
+ *
257
+ * Smallest, and not a level the case stays decidable above: the ceiling is a
258
+ * sawtooth in the baseline size (see the comment in the body), so a baseline a
259
+ * few runs longer than this can fall back under the bar. Treat it as the floor
260
+ * it is rather than a promise about a bigger baseline.
261
+ */
262
+ function baselineRunsToClear(
263
+ rate: number,
264
+ mde: number,
265
+ barLogE: number,
266
+ cap = 100_000,
267
+ ): number | null {
268
+ const at = (n: number) => {
269
+ const s = Math.round(rate * n);
270
+ return evidenceCeilingLogE(Math.max(1e-6, Math.min(1 - 1e-6, rate - mde)), s, n, mde);
271
+ };
272
+ // `at` is not monotone in `n`, so this cannot bisect the way
273
+ // `samplesForEvidence` does. Holding the rate fixed still forces an integer
274
+ // success count, so the implied rate jitters around `rate` and the ceiling
275
+ // drops hard every time the count ticks over: at 99% losing 10 points it
276
+ // clears the bar at 49 runs, falls back under it at 51, and does not clear
277
+ // again until 71. Bisecting that returns whichever crossing the doubling
278
+ // happened to bracket, which is no particular quantity at all; it answered 81
279
+ // for the README's own case where 75 is enough, and 5 for every case that
280
+ // needed fewer, because it bisected (4, 8]. Doubling now only bounds the
281
+ // scan, and never steps past `cap`, so the answer is one this search checked.
282
+ let hi = 1;
283
+ while (hi < cap && at(hi) < barLogE) hi = Math.min(cap, hi * 2);
284
+ if (at(hi) < barLogE) return null;
285
+ for (let n = 1; n < hi; n++) if (at(n) >= barLogE) return n;
286
+ return hi;
287
+ }
288
+
289
+ export function planCase(
290
+ c: CaseRef,
291
+ b: BaselineStat | undefined,
292
+ cfg: PlanConfig,
293
+ m: number,
294
+ ): PlanCase {
295
+ const barLogE = Math.log(ebhSoloThreshold(m, cfg.fdr));
296
+ const trials = b?.trials ?? 0;
297
+ const successes = b?.successes ?? 0;
298
+ const rate = trials > 0 ? successes / trials : 0;
299
+ const alt = Math.max(0, rate - cfg.mde);
300
+
301
+ const base = {
302
+ caseId: c.id,
303
+ // `suite` groups cases into correlation families. A caller who does not
304
+ // group them gets one family per case, which is the conservative reading.
305
+ suite: c.suite ?? c.id,
306
+ baseline: { successes, trials, rate },
307
+ alternativeRate: alt,
308
+ barLogE,
309
+ fixedNPerArm: sampleSizeTwoProportion(Math.max(1e-6, rate), cfg.mde, cfg.alpha, cfg.beta),
310
+ };
311
+
312
+ if (trials === 0) {
313
+ return {
314
+ ...base,
315
+ ceilingLogE: 0,
316
+ unpaired: { runs: Infinity, costUsd: Infinity, wallMs: Infinity },
317
+ paired: { pairs: Infinity, runs: Infinity, costUsd: Infinity, wallMs: Infinity },
318
+ pairCoupling: cfg.pairCoupling,
319
+ detectability: "IMPOSSIBLE",
320
+ design: null,
321
+ baselineRunsNeeded: null,
322
+ reason: "no baseline: nothing to compare against, record one first",
323
+ };
324
+ }
325
+ if (rate <= cfg.mde) {
326
+ return {
327
+ ...base,
328
+ ceilingLogE: 0,
329
+ unpaired: { runs: Infinity, costUsd: Infinity, wallMs: Infinity },
330
+ paired: { pairs: Infinity, runs: Infinity, costUsd: Infinity, wallMs: Infinity },
331
+ pairCoupling: cfg.pairCoupling,
332
+ detectability: "IMPOSSIBLE",
333
+ design: null,
334
+ baselineRunsNeeded: null,
335
+ reason:
336
+ `baseline rate ${(rate * 100).toFixed(0)}% cannot lose ${(cfg.mde * 100).toFixed(0)} points, ` +
337
+ `the effect is arithmetically impossible, not merely expensive`,
338
+ };
339
+ }
340
+
341
+ const pTrue = Math.max(1e-6, Math.min(1 - 1e-6, alt));
342
+ const ceilingLogE = evidenceCeilingLogE(pTrue, successes, trials, cfg.mde);
343
+ const unpairedRuns = samplesForEvidence(
344
+ pTrue,
345
+ successes,
346
+ trials,
347
+ cfg.mde,
348
+ barLogE,
349
+ cfg.runsPerCaseCeiling * 8,
350
+ );
351
+ const pairs = mcnemarSamplesForEvidence(
352
+ rate,
353
+ cfg.mde,
354
+ cfg.pairCoupling,
355
+ barLogE,
356
+ cfg.runsPerCaseCeiling * 8,
357
+ );
358
+
359
+ const unpaired = {
360
+ runs: unpairedRuns,
361
+ costUsd: unpairedRuns * cfg.costPerRunUsd,
362
+ wallMs: unpairedRuns * cfg.msPerRun,
363
+ };
364
+ const paired = {
365
+ pairs,
366
+ runs: pairs * 2,
367
+ costUsd: pairs * 2 * cfg.costPerRunUsd,
368
+ wallMs: pairs * 2 * cfg.msPerRun,
369
+ };
370
+
371
+ let detectability: Detectability;
372
+ let reason: string;
373
+ if (Number.isFinite(unpairedRuns) && unpairedRuns <= cfg.runsPerCaseCeiling) {
374
+ detectability = "UNPAIRED";
375
+ reason = `${unpairedRuns} candidate runs against the stored baseline`;
376
+ } else if (Number.isFinite(pairs) && pairs * 2 <= cfg.runsPerCaseCeiling) {
377
+ detectability = "PAIRED_ONLY";
378
+ reason = Number.isFinite(unpairedRuns)
379
+ ? `unpaired needs ${unpairedRuns} runs (past the ${cfg.runsPerCaseCeiling} ceiling); paired needs ${pairs * 2}`
380
+ : `unpaired evidence ceiling is e^${ceilingLogE.toFixed(1)}, under the bar e^${barLogE.toFixed(1)}, ` +
381
+ `no number of candidate runs certifies it. Paired: ${pairs * 2} runs.`;
382
+ } else if (Number.isFinite(unpairedRuns) || Number.isFinite(pairs)) {
383
+ detectability = "TOO_EXPENSIVE";
384
+ reason = `cheapest design needs ${Math.min(unpairedRuns, pairs * 2)} runs, past the ${cfg.runsPerCaseCeiling} ceiling`;
385
+ } else {
386
+ detectability = "IMPOSSIBLE";
387
+ reason = `neither design reaches the bar within ${cfg.runsPerCaseCeiling * 8} runs`;
388
+ }
389
+
390
+ const baselineRunsNeeded =
391
+ detectability === "UNPAIRED" ? null : baselineRunsToClear(rate, cfg.mde, barLogE);
392
+ const design =
393
+ detectability === "PAIRED_ONLY"
394
+ ? "paired"
395
+ : detectability !== "UNPAIRED"
396
+ ? null
397
+ : paired.runs < unpaired.runs
398
+ ? "paired"
399
+ : "unpaired";
400
+
401
+ return {
402
+ ...base,
403
+ ceilingLogE,
404
+ unpaired,
405
+ paired,
406
+ pairCoupling: cfg.pairCoupling,
407
+ detectability,
408
+ design,
409
+ baselineRunsNeeded,
410
+ reason,
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Expected total samples for the sequential design, priced under Wald's
416
+ * expected sample number at a supplied "true" rate per case, and clamped into
417
+ * the gate's own [minTrials, maxTrials] batch grid.
418
+ *
419
+ * This is the number that predicts what the gate will actually spend.
420
+ * `samplesForEvidence`, the one behind `PlanCase.unpaired.runs`, answers a
421
+ * different question: how many runs it takes to *certify a regression*. A case
422
+ * that did not regress never gets there; it hits the H₀ wall in a few batches
423
+ * and stops. Pricing every case at the certification cost over-predicts the
424
+ * bill.
425
+ *
426
+ * `trueRate` defaults to the case's own baseline rate, i.e. "nothing moved", * which is what almost every pull request looks like.
427
+ */
428
+ export function expectedSequentialSamples(
429
+ cases: Array<CaseRef>,
430
+ baseline: Map<string, BaselineStat>,
431
+ cfg: PlanConfig,
432
+ trueRate: (caseId: string, b: BaselineStat) => number = (_id, b) =>
433
+ b.trials > 0 ? b.successes / b.trials : 0,
434
+ ): { samples: number; perCase: Map<string, number> } {
435
+ const perCase = new Map<string, number>();
436
+ let total = 0;
437
+ for (const c of cases) {
438
+ const b = baseline.get(c.id);
439
+ if (!b || b.trials === 0) continue;
440
+ const p0 = baselineNullRate(b, cfg.nullQuantile);
441
+ const p1 = Math.max(0.005, p0 - cfg.mde);
442
+ const p = Math.max(0, Math.min(1, trueRate(c.id, b)));
443
+ const raw = sprtExpectedN(p, p0, p1, cfg.alpha, cfg.beta);
444
+ const n =
445
+ Number.isFinite(raw) && raw > 0
446
+ ? Math.min(cfg.maxTrials, Math.max(cfg.minTrials, Math.ceil(raw)))
447
+ : cfg.maxTrials;
448
+ // The gate only ever adds whole batches past the minimum, so round the
449
+ // prediction onto the same grid the gate samples on.
450
+ const batched =
451
+ cfg.minTrials + Math.ceil(Math.max(0, n - cfg.minTrials) / cfg.batchSize) * cfg.batchSize;
452
+ const capped = Math.min(cfg.maxTrials, batched);
453
+ perCase.set(c.id, capped);
454
+ total += capped;
455
+ }
456
+ return { samples: total, perCase };
457
+ }
458
+
459
+ /**
460
+ * Plan a suite. `pilots` holds a pair table per case from a pilot run of the
461
+ * paired design, ideally with both arms on the same revision; a case with one
462
+ * prices pairing at its measured `pairPhi` instead of `cfg.pairCoupling`, so
463
+ * its `design` is chosen from a coupling the runner actually delivers.
464
+ */
465
+ export function makePlan(
466
+ cases: Array<CaseRef>,
467
+ baseline: Map<string, BaselineStat>,
468
+ cfg: PlanConfig = DEFAULT_PLAN,
469
+ pilots: Map<string, PairedCounts> = new Map(),
470
+ ): Plan {
471
+ // Open interval: mde = 0 asks for a zero-point drop and fdr = 0 asks for a
472
+ // bar of Infinity. Both used to be accepted and produce a plan in which
473
+ // nothing is decidable, for a reason the printed message blamed on the
474
+ // baseline.
475
+ requireOpenProbability(cfg.mde, "mde", "makePlan");
476
+ requireOpenProbability(cfg.fdr, "fdr", "makePlan");
477
+ // The two error rates the SPRT costing runs on. Left unchecked they reached
478
+ // `sprtExpectedN`, which answers NaN or Infinity outside (0,1), and both
479
+ // callers of it here gate on `Number.isFinite(raw) && raw > 0` and quote
480
+ // `maxTrials` when that fails. So an alpha of 2 produced a complete plan with
481
+ // every case at the per-case cap and no error anywhere.
482
+ requireErrorRates(cfg.alpha, cfg.beta, "makePlan");
483
+ requireProbability(cfg.pairCoupling, "pairCoupling", "makePlan");
484
+ // Refused by `baselineNullRate` already, but only once a case with a baseline
485
+ // reaches it, and under that function's name for it, `q`. Checked here so
486
+ // every planner entry point refuses it the same way and names it.
487
+ requireOpenProbability(cfg.nullQuantile, "nullQuantile", "makePlan");
488
+ if (cases.length === 0) {
489
+ throw new PeeksafeError("PEEKSAFE_E_SUITE_EMPTY", "makePlan: no cases to plan for", {
490
+ hint: "pass at least one case; makePlan prices a suite, not an empty list",
491
+ });
492
+ }
493
+ if (!(cfg.costPerRunUsd >= 0) || !(cfg.msPerRun >= 0)) {
494
+ throw new PeeksafeError(
495
+ "PEEKSAFE_E_CONFIG",
496
+ "makePlan: costPerRunUsd and msPerRun must be ≥ 0",
497
+ {
498
+ detail: { costPerRunUsd: cfg.costPerRunUsd, msPerRun: cfg.msPerRun },
499
+ },
500
+ );
501
+ }
502
+
503
+ const m = cases.length;
504
+ const planned = cases.map((c) => {
505
+ const pilot = pilots.get(c.id);
506
+ // pairPhi validates the counts, so a malformed pilot is refused even when it sums to 0.
507
+ const phi = pilot ? pairPhi(pilot) : 0;
508
+ // An empty pilot measured nothing, so it leaves the configured coupling in place.
509
+ const measured = pilot && pilot.bothPass + pilot.worse + pilot.better + pilot.bothFail > 0;
510
+ // A negative phi is noise around independence for a design that couples by construction.
511
+ const caseCfg = measured ? { ...cfg, pairCoupling: Math.max(0, phi) } : cfg;
512
+ return planCase(c, baseline.get(c.id), caseCfg, m);
513
+ });
514
+
515
+ const finite = (x: number) => (Number.isFinite(x) ? x : 0);
516
+ const decidableUnpaired = planned.filter((p) => p.detectability === "UNPAIRED");
517
+ const decidableAny = planned.filter(
518
+ (p) => p.detectability === "UNPAIRED" || p.detectability === "PAIRED_ONLY",
519
+ );
520
+
521
+ const unpairedRuns = decidableUnpaired.reduce((a, p) => a + finite(p.unpaired.runs), 0);
522
+ const pairedRuns = decidableAny.reduce((a, p) => a + finite(p.paired.runs), 0);
523
+ // Per case, take the cheaper design that can actually decide it. This is the
524
+ // number the recommendation and the affordability verdict are built on: a
525
+ // design that is cheap because it decides nothing is not cheap.
526
+ const bestRuns = decidableAny.reduce(
527
+ (a, p) => a + Math.min(finite(p.unpaired.runs) || Infinity, finite(p.paired.runs) || Infinity),
528
+ 0,
529
+ );
530
+
531
+ const pairedOnlyCount = planned.filter((p) => p.detectability === "PAIRED_ONLY").length;
532
+ // The design the recommendation will name, computed here because the
533
+ // *expected* bill has to be priced for it. `pairedOnly > 25%` is the same
534
+ // condition the recommendation string uses; they cannot drift apart.
535
+ const recommendedDesign: "unpaired" | "paired" =
536
+ cfg.design ??
537
+ (decidableAny.length > 0 && pairedOnlyCount > cases.length * 0.25 ? "paired" : "unpaired");
538
+ const runsPerObservation = recommendedDesign === "paired" ? 2 : 1;
539
+
540
+ // Screening: everything gets `screenRuns`; only the cases the screen ranks as
541
+ // plausibly moved go on to the sequential test. A clean PR moves nothing, so
542
+ // in expectation only the FDR-sized tail continues, but a plan must be
543
+ // conservative, so we budget for the worst realistic case: 10% continue.
544
+ const screenContinueFraction = 0.1;
545
+ const screenSelected = Math.ceil(decidableAny.length * screenContinueFraction);
546
+ const meanBest = decidableAny.length > 0 ? bestRuns / decidableAny.length : 0;
547
+ // `screenRuns` counts *observations*, and a paired observation is two runs,
548
+ // so a paired screening pass costs twice what an unpaired one does. Only the
549
+ // screen term is scaled here: `meanBest` already comes from `paired.runs`,
550
+ // which is `pairs * 2`. The expected total below reads `screenRuns` the same
551
+ // way, and when this one did not, the two screened figures in `totals`
552
+ // disagreed about the price of the same pass.
553
+ const screenedRuns = m * cfg.screenRuns * runsPerObservation + screenSelected * meanBest;
554
+
555
+ const fixedNRuns = planned.reduce((a, p) => a + finite(p.fixedNPerArm), 0);
556
+
557
+ // The expected cost of a pull request that changed nothing, which is what a
558
+ // pull request almost always is, and therefore the number a budget should be
559
+ // set from.
560
+ // `expectedSequentialSamples` counts *observations*. A paired observation is
561
+ // two runs, the candidate and the reference revision on the same seed, and
562
+ // so is a paired screening run.
563
+ const typical = expectedSequentialSamples(cases, baseline, cfg);
564
+ const typicalRuns = typical.samples * runsPerObservation;
565
+ const meanTypical = typical.perCase.size > 0 ? typical.samples / typical.perCase.size : 0;
566
+ const typicalScreenedRuns = Math.ceil(
567
+ (m * cfg.screenRuns + Math.ceil(m * screenContinueFraction) * meanTypical) * runsPerObservation,
568
+ );
569
+
570
+ const totals: PlanTotals = {
571
+ cases: m,
572
+ decidableUnpaired: decidableUnpaired.length,
573
+ decidableBest: decidableAny.length,
574
+ unpairedRuns,
575
+ unpairedCostUsd: unpairedRuns * cfg.costPerRunUsd,
576
+ pairedRuns,
577
+ pairedCostUsd: pairedRuns * cfg.costPerRunUsd,
578
+ bestRuns: round(bestRuns),
579
+ bestCostUsd: round(bestRuns) * cfg.costPerRunUsd,
580
+ screenedRuns: round(screenedRuns),
581
+ screenedCostUsd: round(screenedRuns) * cfg.costPerRunUsd,
582
+ fixedNRuns,
583
+ fixedNCostUsd: fixedNRuns * cfg.costPerRunUsd,
584
+ wallMsUnpaired: (unpairedRuns * cfg.msPerRun) / Math.max(1, cfg.concurrency),
585
+ wallMsPaired: (pairedRuns * cfg.msPerRun) / Math.max(1, cfg.concurrency),
586
+ wallMsBest: (round(bestRuns) * cfg.msPerRun) / Math.max(1, cfg.concurrency),
587
+ wallMsScreened: (round(screenedRuns) * cfg.msPerRun) / Math.max(1, cfg.concurrency),
588
+ screenContinueFraction,
589
+ runsPerObservation,
590
+ typicalRuns,
591
+ typicalCostUsd: typicalRuns * cfg.costPerRunUsd,
592
+ wallMsTypical: (typicalRuns * cfg.msPerRun) / Math.max(1, cfg.concurrency),
593
+ typicalScreenedRuns,
594
+ typicalScreenedCostUsd: typicalScreenedRuns * cfg.costPerRunUsd,
595
+ };
596
+
597
+ const undecidable = planned.filter(
598
+ (p) => p.detectability === "TOO_EXPENSIVE" || p.detectability === "IMPOSSIBLE",
599
+ );
600
+ const pairedOnly = planned.filter((p) => p.detectability === "PAIRED_ONLY");
601
+ const impossible = planned.filter((p) => p.detectability === "IMPOSSIBLE");
602
+
603
+ const recommendation =
604
+ decidableAny.length === 0
605
+ ? `nothing is decidable at ${(cfg.mde * 100).toFixed(0)} points against this baseline, record more baseline runs or raise the MDE`
606
+ : recommendedDesign === "paired"
607
+ ? `pair the runs: ${pairedOnly.length}/${m} cases have an unpaired evidence ceiling under the bar, so no candidate budget can decide them` +
608
+ ` (the costs below are priced at two runs an observation, which is what pairing costs)`
609
+ : totals.screenedCostUsd < totals.bestCostUsd
610
+ ? `screen first, then run the sequential test on what the screen keeps ` +
611
+ `($${totals.screenedCostUsd.toFixed(2)} vs $${totals.bestCostUsd.toFixed(2)})`
612
+ : `run the sequential test directly`;
613
+
614
+ // The honest cost is the cheapest plan that still decides something. A design
615
+ // that is free because it certifies nothing is not a cheap design.
616
+ const ceilingCost =
617
+ decidableAny.length === 0 ? Infinity : Math.min(totals.screenedCostUsd, totals.bestCostUsd);
618
+ // The headline is the expected cost; leading with the ceiling over-states the bill.
619
+ // Mirrors the ceiling's structure, the cheapest plan of its kind, so the two
620
+ // numbers are roughly comparable. Comparing an unscreened expected cost
621
+ // with a screened ceiling was the first version of this and it made the
622
+ // "expected" figure look *larger* than the worst case.
623
+ const expectedCost =
624
+ decidableAny.length === 0
625
+ ? Infinity
626
+ : Math.min(totals.typicalCostUsd, totals.typicalScreenedCostUsd);
627
+ const coverage = `${decidableAny.length}/${m} cases`;
628
+ // "Every case regressed" is not the expensive pull request, and calling it
629
+ // the worst case put a number below the headline that an operator would
630
+ // budget from: a 200-case suite on a 12-run baseline read "about $11.42 per
631
+ // pull request. Worst case ... is $9.16". The sequential test stops a case
632
+ // the moment it clears the bar, so a suite that really did regress finishes
633
+ // early, while one that did not move runs its full schedule. When the
634
+ // all-regressed cost lands under the expected one, say which it is.
635
+ //
636
+ // The sentence stops short of naming early stopping as *the* cause, because
637
+ // there is a second one: both figures are a `min` over two designs, and the
638
+ // two minima need not select the same design, so the cheaper figure may be
639
+ // pricing a plan the dearer one is not. Until both sides are made to select
640
+ // together, the honest claim is only that neither bounds the other.
641
+ const ceilingNote = !Number.isFinite(ceilingCost)
642
+ ? ""
643
+ : ceilingCost >= expectedCost
644
+ ? ` Worst case, every case regressing at once, is $${ceilingCost.toFixed(2)}.`
645
+ : ` A pull request in which every case regressed costs less, $${ceilingCost.toFixed(2)}: ` +
646
+ `the two do not price the same schedule, so neither bounds the other.`;
647
+ const verdict = !Number.isFinite(expectedCost)
648
+ ? `undecidable: no design reaches the bar for any case at ${(cfg.mde * 100).toFixed(0)} points with a ${medianTrials(planned)}-run baseline`
649
+ : expectedCost <= 5
650
+ ? `affordable: about $${expectedCost.toFixed(2)} per pull request, covering ${coverage}.${ceilingNote}`
651
+ : expectedCost <= 100
652
+ ? `viable but not free: about $${expectedCost.toFixed(2)} per pull request covering ${coverage}, worth gating a release on, not every commit.${ceilingNote}`
653
+ : `not affordable per-PR at $${cfg.costPerRunUsd.toFixed(4)}/run: about $${expectedCost.toFixed(0)} a time for ${coverage}. ` +
654
+ `Cut the suite, raise the MDE, or gate nightly instead of per-PR.${ceilingNote}`;
655
+
656
+ return {
657
+ config: cfg,
658
+ cases: planned,
659
+ totals,
660
+ undecidable,
661
+ pairedOnly,
662
+ impossible,
663
+ recommendedDesign,
664
+ recommendation,
665
+ verdict,
666
+ expectedCostUsd: expectedCost,
667
+ ceilingCostUsd: ceilingCost,
668
+ };
669
+ }
670
+
671
+ /**
672
+ * The suite size / MDE combinations that *are* affordable at a given per-run
673
+ * cost and budget. This is the honest answer to "is this usable for a
674
+ * coding-agent benchmark at $1 a run?", usually "not at 200 cases and 15
675
+ * points, but yes at 20 cases and 25 points".
676
+ */
677
+ export interface AffordabilityCell {
678
+ cases: number;
679
+ mde: number;
680
+ /** runs to certify every case, the ceiling */
681
+ runs: number;
682
+ costUsd: number;
683
+ /** runs a pull request that moved nothing actually spends, the expected bill */
684
+ typicalRuns: number;
685
+ typicalCostUsd: number;
686
+ /** does the *ceiling* fit the budget? */
687
+ affordable: boolean;
688
+ /** does the *expected* cost fit the budget? */
689
+ typicallyAffordable: boolean;
690
+ }
691
+
692
+ export function affordabilityGrid(
693
+ baselineRate: number,
694
+ baselineTrials: number,
695
+ cfg: PlanConfig,
696
+ budgetUsd: number,
697
+ caseCounts: number[] = [20, 50, 100, 200],
698
+ mdes: number[] = [0.1, 0.15, 0.25, 0.35],
699
+ ): AffordabilityCell[] {
700
+ requireProbability(baselineRate, "baselineRate", "affordabilityGrid");
701
+ requireErrorRates(cfg.alpha, cfg.beta, "affordabilityGrid");
702
+ // The typical bill below takes this quantile of the baseline posterior
703
+ // straight from `betaQuantile`, which answers 0 or 1 for a quantile outside
704
+ // (0,1) and about 0 for NaN instead of refusing. `makePlan` refuses it.
705
+ requireOpenProbability(cfg.nullQuantile, "nullQuantile", "affordabilityGrid");
706
+ // Every other planner entry point refuses a case with no baseline: `planCase`
707
+ // returns IMPOSSIBLE with "no baseline", `evaluatePoint` throws on
708
+ // `baselineRuns < 1`, and `gate` throws PEEKSAFE_E_BASELINE_MISSING. This one
709
+ // checked only the rate, so `baselineTrials: 0` reached `twoSamplePriors` as
710
+ // 0 successes in 0 trials, whose null prior is Beta(1, 1), and the grid
711
+ // priced the suite against "every case passes half the time" and reported it
712
+ // affordable, on a cheaper typical bill than the same call at a real
713
+ // baseline. A fractional or negative count did reach a refusal, but from
714
+ // `requireCounts` three frames down, naming a success count the caller never
715
+ // passed. `test/budget.test.ts` has the figures.
716
+ if (!Number.isInteger(baselineTrials) || baselineTrials < 1) {
717
+ throw new PeeksafeError(
718
+ "PEEKSAFE_E_CONFIG",
719
+ `affordabilityGrid: baselineTrials must be a positive integer, got ${baselineTrials}`,
720
+ {
721
+ detail: { baselineTrials },
722
+ hint: 'this grid prices a suite against a recorded baseline; with none the null prior is Beta(1, 1), which reads as "every case passes half the time"',
723
+ },
724
+ );
725
+ }
726
+ const out: AffordabilityCell[] = [];
727
+ for (const m of caseCounts) {
728
+ for (const mde of mdes) {
729
+ const bar = Math.log(ebhSoloThreshold(m, cfg.fdr));
730
+ const pTrue = Math.max(1e-6, baselineRate - mde);
731
+ const s = Math.round(baselineRate * baselineTrials);
732
+ const perCase = samplesForEvidence(pTrue, s, baselineTrials, mde, bar, 200_000);
733
+ const pairs = mcnemarSamplesForEvidence(baselineRate, mde, cfg.pairCoupling, bar, 200_000);
734
+ // the cheaper of the two designs, screened down to a 10% continue rate
735
+ const best = Math.min(Number.isFinite(perCase) ? perCase : Infinity, pairs * 2);
736
+ // Which design `best` just picked, needed by both totals below: they have
737
+ // to price the same screening pass as each other, and `screenRuns` counts
738
+ // observations, so a paired pass costs two runs an observation. Only the
739
+ // screen term is scaled, because `best` is already in runs (`pairs * 2`).
740
+ // When this total left the screen term unscaled it understated a paired
741
+ // cell by `m * screenRuns` runs, which made `affordable` below answer for
742
+ // a cheaper ceiling than the one it reports, and made this grid disagree
743
+ // with `makePlan`'s `screenedRuns` about the price of the same pass.
744
+ const runsPerObs = Number.isFinite(perCase) && perCase <= pairs * 2 ? 1 : 2;
745
+ const runs = Number.isFinite(best)
746
+ ? Math.ceil(m * cfg.screenRuns * runsPerObs + Math.ceil(m * 0.1) * best)
747
+ : Infinity;
748
+ const costUsd = runs * cfg.costPerRunUsd;
749
+ // The ceiling above prices a pull request in which every case regressed.
750
+ // This prices one in which nothing did, which is what a pull request
751
+ // almost always is.
752
+ const p0 = betaQuantile(1 + s, 1 + baselineTrials - s, cfg.nullQuantile);
753
+ const p1 = Math.max(0.005, p0 - mde);
754
+ const rawN = sprtExpectedN(baselineRate, p0, p1, cfg.alpha, cfg.beta);
755
+ const perCaseTypical =
756
+ Number.isFinite(rawN) && rawN > 0
757
+ ? Math.min(cfg.maxTrials, Math.max(cfg.minTrials, Math.ceil(rawN)))
758
+ : cfg.maxTrials;
759
+ // Priced for the same design as the ceiling above (`runsPerObs`), or the
760
+ // cell quotes an unpaired price for a plan only pairing can execute.
761
+ const typicalRuns = Math.ceil(
762
+ (m * cfg.screenRuns + Math.ceil(m * 0.1) * perCaseTypical) * runsPerObs,
763
+ );
764
+ const typicalCostUsd = typicalRuns * cfg.costPerRunUsd;
765
+ out.push({
766
+ cases: m,
767
+ mde,
768
+ runs,
769
+ costUsd,
770
+ typicalRuns,
771
+ typicalCostUsd,
772
+ affordable: costUsd <= budgetUsd,
773
+ typicallyAffordable: typicalCostUsd <= budgetUsd,
774
+ });
775
+ }
776
+ }
777
+ return out;
778
+ }