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