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/dist/plan.js ADDED
@@ -0,0 +1,626 @@
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
+ import { baselineNullRate } from "./baseline.js";
35
+ import { PeeksafeError, requireCounts, requireErrorRates, requireOpenProbability, requireProbability, requireRunCeiling, requireTrialGrid, requireUniqueCaseIds, } from "./errors.js";
36
+ import { betaQuantile, ebhSoloThreshold, evidenceCeilingLogE, mcnemarSamplesForEvidence, pairPhi, sampleSizeTwoProportion, samplesForEvidence, sprtExpectedN, } from "./stats.js";
37
+ import { DEFAULT_STOP_OPTIONS, settleLogE, shouldStop } from "./stop.js";
38
+ export const DEFAULT_PLAN = {
39
+ mde: 0.15,
40
+ fdr: 0.05,
41
+ // These three are *assumptions* until you measure them on a real run: cost
42
+ // and latency from your runner, coupling with `pairPhi` on a pilot.
43
+ costPerRunUsd: 0.0021,
44
+ msPerRun: 780,
45
+ pairCoupling: 0.9,
46
+ screenRuns: 4,
47
+ concurrency: 8,
48
+ runsPerCaseCeiling: 2000,
49
+ alpha: 0.05,
50
+ beta: 0.1,
51
+ minTrials: 8,
52
+ maxTrials: 96,
53
+ batchSize: 4,
54
+ nullQuantile: 0.5,
55
+ };
56
+ const round = (x) => (Number.isFinite(x) ? Math.ceil(x) : Infinity);
57
+ const medianTrials = (ps) => {
58
+ const xs = ps.map((p) => p.baseline.trials).sort((a, b) => a - b);
59
+ return xs.length === 0 ? 0 : xs[Math.floor(xs.length / 2)];
60
+ };
61
+ /**
62
+ * How many baseline runs would lift this case's evidence ceiling over the bar?
63
+ * The *smallest* such number, holding the baseline *rate* fixed, "if I keep
64
+ * measuring main at the same quality, how long until this case becomes
65
+ * decidable at all?"
66
+ *
67
+ * Smallest, and not a level the case stays decidable above: the ceiling is a
68
+ * sawtooth in the baseline size (see the comment in the body), so a baseline a
69
+ * few runs longer than this can fall back under the bar. Treat it as the floor
70
+ * it is rather than a promise about a bigger baseline.
71
+ */
72
+ function baselineRunsToClear(rate, mde, barLogE, cap = 100_000) {
73
+ const at = (n) => {
74
+ const s = Math.round(rate * n);
75
+ return evidenceCeilingLogE(Math.max(1e-6, Math.min(1 - 1e-6, rate - mde)), s, n, mde);
76
+ };
77
+ // `at` is not monotone in `n`, so this cannot bisect the way
78
+ // `samplesForEvidence` does. Holding the rate fixed still forces an integer
79
+ // success count, so the implied rate jitters around `rate` and the ceiling
80
+ // drops hard every time the count ticks over: at 99% losing 10 points it
81
+ // clears the bar at 49 runs, falls back under it at 51, and does not clear
82
+ // again until 71. Bisecting that returns whichever crossing the doubling
83
+ // happened to bracket, which is no particular quantity at all; it answered 81
84
+ // for the README's own case where 75 is enough, and 5 for every case that
85
+ // needed fewer, because it bisected (4, 8]. Doubling now only bounds the
86
+ // scan, and never steps past `cap`, so the answer is one this search checked.
87
+ let hi = 1;
88
+ while (hi < cap && at(hi) < barLogE)
89
+ hi = Math.min(cap, hi * 2);
90
+ if (at(hi) < barLogE)
91
+ return null;
92
+ for (let n = 1; n < hi; n++)
93
+ if (at(n) >= barLogE)
94
+ return n;
95
+ return hi;
96
+ }
97
+ function requirePrices(cfg, where) {
98
+ if (!(cfg.costPerRunUsd >= 0) || !(cfg.msPerRun >= 0)) {
99
+ throw new PeeksafeError("PEEKSAFE_E_CONFIG", `${where}: costPerRunUsd and msPerRun must be ≥ 0`, {
100
+ detail: { costPerRunUsd: cfg.costPerRunUsd, msPerRun: cfg.msPerRun },
101
+ });
102
+ }
103
+ }
104
+ export function planCase(c, b, cfg, m) {
105
+ // 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.
106
+ if (b)
107
+ requireCounts(b.successes, b.trials, `planCase(${c.id}).baseline`);
108
+ requireErrorRates(cfg.alpha, cfg.beta, "planCase");
109
+ requirePrices(cfg, "planCase");
110
+ const barLogE = Math.log(ebhSoloThreshold(m, cfg.fdr));
111
+ const trials = b?.trials ?? 0;
112
+ const successes = b?.successes ?? 0;
113
+ const rate = trials > 0 ? successes / trials : 0;
114
+ const alt = Math.max(0, rate - cfg.mde);
115
+ const base = {
116
+ caseId: c.id,
117
+ // `suite` groups cases into correlation families. A caller who does not
118
+ // group them gets one family per case, which is the conservative reading.
119
+ suite: c.suite ?? c.id,
120
+ baseline: { successes, trials, rate },
121
+ alternativeRate: alt,
122
+ barLogE,
123
+ fixedNPerArm: sampleSizeTwoProportion(Math.max(1e-6, rate), cfg.mde, cfg.alpha, cfg.beta),
124
+ };
125
+ if (trials === 0) {
126
+ return {
127
+ ...base,
128
+ ceilingLogE: 0,
129
+ unpaired: { runs: Infinity, costUsd: Infinity, wallMs: Infinity },
130
+ paired: { pairs: Infinity, runs: Infinity, costUsd: Infinity, wallMs: Infinity },
131
+ pairCoupling: cfg.pairCoupling,
132
+ detectability: "IMPOSSIBLE",
133
+ design: null,
134
+ baselineRunsNeeded: null,
135
+ reason: "no baseline: nothing to compare against, record one first",
136
+ };
137
+ }
138
+ if (rate <= cfg.mde) {
139
+ return {
140
+ ...base,
141
+ ceilingLogE: 0,
142
+ unpaired: { runs: Infinity, costUsd: Infinity, wallMs: Infinity },
143
+ paired: { pairs: Infinity, runs: Infinity, costUsd: Infinity, wallMs: Infinity },
144
+ pairCoupling: cfg.pairCoupling,
145
+ detectability: "IMPOSSIBLE",
146
+ design: null,
147
+ baselineRunsNeeded: null,
148
+ reason: `baseline rate ${(rate * 100).toFixed(0)}% cannot lose ${(cfg.mde * 100).toFixed(0)} points, ` +
149
+ `the effect is arithmetically impossible, not merely expensive`,
150
+ };
151
+ }
152
+ const pTrue = Math.max(1e-6, Math.min(1 - 1e-6, alt));
153
+ const ceilingLogE = evidenceCeilingLogE(pTrue, successes, trials, cfg.mde);
154
+ const unpairedRuns = samplesForEvidence(pTrue, successes, trials, cfg.mde, barLogE, cfg.runsPerCaseCeiling * 8);
155
+ const pairs = mcnemarSamplesForEvidence(rate, cfg.mde, cfg.pairCoupling, barLogE, cfg.runsPerCaseCeiling * 8);
156
+ const unpaired = {
157
+ runs: unpairedRuns,
158
+ costUsd: unpairedRuns * cfg.costPerRunUsd,
159
+ wallMs: unpairedRuns * cfg.msPerRun,
160
+ };
161
+ const paired = {
162
+ pairs,
163
+ runs: pairs * 2,
164
+ costUsd: pairs * 2 * cfg.costPerRunUsd,
165
+ wallMs: pairs * 2 * cfg.msPerRun,
166
+ };
167
+ let detectability;
168
+ let reason;
169
+ if (Number.isFinite(unpairedRuns) && unpairedRuns <= cfg.runsPerCaseCeiling) {
170
+ detectability = "UNPAIRED";
171
+ reason = `${unpairedRuns} candidate runs against the stored baseline`;
172
+ }
173
+ else if (Number.isFinite(pairs) && pairs * 2 <= cfg.runsPerCaseCeiling) {
174
+ detectability = "PAIRED_ONLY";
175
+ reason = Number.isFinite(unpairedRuns)
176
+ ? `unpaired needs ${unpairedRuns} runs (past the ${cfg.runsPerCaseCeiling} ceiling); paired needs ${pairs * 2}`
177
+ : `unpaired evidence ceiling is e^${ceilingLogE.toFixed(1)}, under the bar e^${barLogE.toFixed(1)}, ` +
178
+ `no number of candidate runs certifies it. Paired: ${pairs * 2} runs.`;
179
+ }
180
+ else if (Number.isFinite(unpairedRuns) || Number.isFinite(pairs)) {
181
+ detectability = "TOO_EXPENSIVE";
182
+ reason = `cheapest design needs ${Math.min(unpairedRuns, pairs * 2)} runs, past the ${cfg.runsPerCaseCeiling} ceiling`;
183
+ }
184
+ else {
185
+ detectability = "IMPOSSIBLE";
186
+ reason = `neither design reaches the bar within ${cfg.runsPerCaseCeiling * 8} runs`;
187
+ }
188
+ const baselineRunsNeeded = detectability === "UNPAIRED" ? null : baselineRunsToClear(rate, cfg.mde, barLogE);
189
+ const design = detectability === "PAIRED_ONLY"
190
+ ? "paired"
191
+ : detectability !== "UNPAIRED"
192
+ ? null
193
+ : paired.runs < unpaired.runs
194
+ ? "paired"
195
+ : "unpaired";
196
+ return {
197
+ ...base,
198
+ ceilingLogE,
199
+ unpaired,
200
+ paired,
201
+ pairCoupling: cfg.pairCoupling,
202
+ detectability,
203
+ design,
204
+ baselineRunsNeeded,
205
+ reason,
206
+ };
207
+ }
208
+ /**
209
+ * Expected total samples for the sequential design, priced under Wald's
210
+ * expected sample number at a supplied "true" rate per case, and clamped into
211
+ * the gate's own [minTrials, maxTrials] batch grid.
212
+ *
213
+ * `makePlan` no longer bills from it: an unpaired case there is counted in
214
+ * the runs `shouldStop` takes, which this figure misses either way (#105).
215
+ * `samplesForEvidence`, the one behind `PlanCase.unpaired.runs`, answers a
216
+ * different question: how many runs it takes to *certify a regression*. A case
217
+ * that did not regress never gets there; it hits the H₀ wall in a few batches
218
+ * and stops. Pricing every case at the certification cost over-predicts the
219
+ * bill.
220
+ *
221
+ * `trueRate` defaults to the case's own baseline rate, i.e. "nothing moved", * which is what almost every pull request looks like.
222
+ */
223
+ export function expectedSequentialSamples(cases, baseline, cfg, trueRate = (_id, b) => b.trials > 0 ? b.successes / b.trials : 0) {
224
+ requireTrialGrid(cfg, "expectedSequentialSamples");
225
+ // A repeated id was added to `samples` once per listing but kept once in `perCase`, so the two disagreed.
226
+ requireUniqueCaseIds(cases, "expectedSequentialSamples");
227
+ const perCase = new Map();
228
+ let total = 0;
229
+ for (const c of cases) {
230
+ const b = baseline.get(c.id);
231
+ if (!b || b.trials === 0)
232
+ continue;
233
+ const p0 = baselineNullRate(b, cfg.nullQuantile);
234
+ const p1 = Math.max(0.005, p0 - cfg.mde);
235
+ const p = Math.max(0, Math.min(1, trueRate(c.id, b)));
236
+ const raw = sprtExpectedN(p, p0, p1, cfg.alpha, cfg.beta);
237
+ const capped = onBatchGrid(Number.isFinite(raw) && raw > 0 ? Math.ceil(raw) : cfg.maxTrials, cfg);
238
+ perCase.set(c.id, capped);
239
+ total += capped;
240
+ }
241
+ return { samples: total, perCase };
242
+ }
243
+ /** Refuses a set `futilityConfidence` outside (0, 1), where the settle bar is 0 or Infinity. */
244
+ export function requireSettleConfidence(cfg, where) {
245
+ if (cfg.futilityConfidence !== undefined) {
246
+ requireOpenProbability(cfg.futilityConfidence, "futilityConfidence", where);
247
+ }
248
+ }
249
+ // The gate only ever adds whole batches past the minimum, so a prediction is rounded onto the grid it samples on.
250
+ function onBatchGrid(n, cfg) {
251
+ const clamped = Math.min(cfg.maxTrials, Math.max(cfg.minTrials, n));
252
+ const batched = cfg.minTrials + Math.ceil(Math.max(0, clamped - cfg.minTrials) / cfg.batchSize) * cfg.batchSize;
253
+ return Math.min(cfg.maxTrials, batched);
254
+ }
255
+ /**
256
+ * Expected pairs for a paired case that did not move: the first pair count at
257
+ * which `shouldStopPaired`'s settle e-value, on the mean trajectory at coupling
258
+ * `rho` and `cfg.futilityConfidence` (the stopping rule's default when unset),
259
+ * rules out a drop of `mde`, on the same batch grid as
260
+ * `expectedSequentialSamples`, and `maxTrials` when it never does. The
261
+ * unpaired SPRT figure knows nothing of this rule or of the coupling, which
262
+ * sets how often an unmoved pair disagrees.
263
+ */
264
+ export function expectedSettlePairs(rate, rho, mde, cfg) {
265
+ // Unmoved arms disagree each way only when uncoupled: (1 - rho) p (1 - p).
266
+ const each = (1 - rho) * rate * (1 - rate);
267
+ const confidence = cfg.futilityConfidence ?? DEFAULT_STOP_OPTIONS.futilityConfidence;
268
+ const target = -Math.log(1 - confidence);
269
+ for (let n = 1; n <= cfg.maxTrials; n++) {
270
+ const counts = { bothPass: n * (1 - 2 * each), worse: n * each, better: n * each, bothFail: 0 };
271
+ if (settleLogE(counts, mde) >= target)
272
+ return onBatchGrid(n, cfg);
273
+ }
274
+ return cfg.maxTrials;
275
+ }
276
+ /**
277
+ * Expected runs `shouldStop` takes to stop an unpaired case whose candidate
278
+ * passes at `rate`, checked at `minTrials` and then every `batchSize` runs up
279
+ * to `maxTrials`, in a suite of `suiteSize`. Exact rather than simulated:
280
+ * the decision depends on the candidate only through its counts, so a dynamic
281
+ * program carries the distribution of the success count from one check to the
282
+ * next. Rounded up to a whole run; an expectation is not a grid point.
283
+ */
284
+ function expectedStopRuns(baseline, rate, suiteSize, cfg) {
285
+ const options = {
286
+ suiteSize,
287
+ mde: cfg.mde,
288
+ fdr: cfg.fdr,
289
+ futilityConfidence: cfg.futilityConfidence,
290
+ };
291
+ // live[k]: probability of k successes in n runs with the case still running.
292
+ let live = new Float64Array([1]);
293
+ let n = 0;
294
+ let expected = 0;
295
+ for (let step = cfg.minTrials;; step = Math.min(cfg.batchSize, cfg.maxTrials - n)) {
296
+ for (let i = 0; i < step; i++) {
297
+ const next = new Float64Array(live.length + 1);
298
+ for (let k = 0; k < live.length; k++) {
299
+ next[k] += live[k] * (1 - rate);
300
+ next[k + 1] += live[k] * rate;
301
+ }
302
+ live = next;
303
+ }
304
+ n += step;
305
+ let running = 0;
306
+ for (let k = 0; k <= n; k++) {
307
+ if (live[k] === 0)
308
+ continue;
309
+ if (n >= cfg.maxTrials || shouldStop({ successes: k, trials: n }, baseline, options).stop) {
310
+ expected += live[k] * n;
311
+ live[k] = 0;
312
+ }
313
+ else {
314
+ running += live[k];
315
+ }
316
+ }
317
+ if (running === 0)
318
+ break;
319
+ }
320
+ // Float residue must not push an exact whole number up a run.
321
+ return Math.ceil(expected - 1e-9);
322
+ }
323
+ /**
324
+ * Plan a suite. `pilots` holds a pair table per case from a pilot run of the
325
+ * paired design, ideally with both arms on the same revision; a case with one
326
+ * prices pairing at its measured `pairPhi` instead of `cfg.pairCoupling`, so
327
+ * its `design` is chosen from a coupling the runner actually delivers.
328
+ */
329
+ export function makePlan(cases, baseline, cfg = DEFAULT_PLAN, pilots = new Map()) {
330
+ // Open interval: mde = 0 asks for a zero-point drop and fdr = 0 asks for a
331
+ // bar of Infinity. Both used to be accepted and produce a plan in which
332
+ // nothing is decidable, for a reason the printed message blamed on the
333
+ // baseline.
334
+ requireOpenProbability(cfg.mde, "mde", "makePlan");
335
+ requireOpenProbability(cfg.fdr, "fdr", "makePlan");
336
+ // The two error rates the SPRT costing runs on. Left unchecked they reached
337
+ // `sprtExpectedN`, which answers NaN or Infinity outside (0,1), and both
338
+ // callers of it here gate on `Number.isFinite(raw) && raw > 0` and quote
339
+ // `maxTrials` when that fails. So an alpha of 2 produced a complete plan with
340
+ // every case at the per-case cap and no error anywhere.
341
+ requireErrorRates(cfg.alpha, cfg.beta, "makePlan");
342
+ requireProbability(cfg.pairCoupling, "pairCoupling", "makePlan");
343
+ // Refused by `baselineNullRate` already, but only once a case with a baseline
344
+ // reaches it, and under that function's name for it, `q`. Checked here so
345
+ // every planner entry point refuses it the same way and names it.
346
+ requireOpenProbability(cfg.nullQuantile, "nullQuantile", "makePlan");
347
+ requireSettleConfidence(cfg, "makePlan");
348
+ requireRunCeiling(cfg.runsPerCaseCeiling, "makePlan");
349
+ requireTrialGrid(cfg, "makePlan");
350
+ if (cases.length === 0) {
351
+ throw new PeeksafeError("PEEKSAFE_E_SUITE_EMPTY", "makePlan: no cases to plan for", {
352
+ hint: "pass at least one case; makePlan prices a suite, not an empty list",
353
+ });
354
+ }
355
+ // `gate` refuses a repeated id; planned, it was priced twice and counted twice in the suite size the bar is set from.
356
+ requireUniqueCaseIds(cases, "makePlan");
357
+ requirePrices(cfg, "makePlan");
358
+ const m = cases.length;
359
+ const planned = cases.map((c) => {
360
+ const pilot = pilots.get(c.id);
361
+ // pairPhi validates the counts, so a malformed pilot is refused even when it sums to 0.
362
+ const phi = pilot ? pairPhi(pilot) : 0;
363
+ // An empty pilot measured nothing, so it leaves the configured coupling in place.
364
+ const measured = pilot && pilot.bothPass + pilot.worse + pilot.better + pilot.bothFail > 0;
365
+ // A negative phi is noise around independence for a design that couples by construction.
366
+ const caseCfg = measured ? { ...cfg, pairCoupling: Math.max(0, phi) } : cfg;
367
+ return planCase(c, baseline.get(c.id), caseCfg, m);
368
+ });
369
+ const finite = (x) => (Number.isFinite(x) ? x : 0);
370
+ const decidableUnpaired = planned.filter((p) => p.detectability === "UNPAIRED");
371
+ const decidableAny = planned.filter((p) => p.detectability === "UNPAIRED" || p.detectability === "PAIRED_ONLY");
372
+ const unpairedRuns = decidableUnpaired.reduce((a, p) => a + finite(p.unpaired.runs), 0);
373
+ const pairedRuns = decidableAny.reduce((a, p) => a + finite(p.paired.runs), 0);
374
+ // Per case, take the cheaper design that can actually decide it. This is the
375
+ // number the recommendation and the affordability verdict are built on: a
376
+ // design that is cheap because it decides nothing is not cheap.
377
+ const bestRuns = decidableAny.reduce((a, p) => a + Math.min(finite(p.unpaired.runs) || Infinity, finite(p.paired.runs) || Infinity), 0);
378
+ const pairedOnlyCount = planned.filter((p) => p.detectability === "PAIRED_ONLY").length;
379
+ const pairedDesignCount = decidableAny.filter((p) => p.design === "paired").length;
380
+ // The recommendation string reads these same two flags, so it cannot drift from the design it names.
381
+ const manyPairedOnly = pairedOnlyCount > cases.length * 0.25;
382
+ const mostlyPaired = pairedDesignCount * 2 > decidableAny.length;
383
+ const recommendedDesign = cfg.design ??
384
+ (decidableAny.length > 0 && (manyPairedOnly || mostlyPaired) ? "paired" : "unpaired");
385
+ // Per case, like `bestRuns`: one suite-wide design billed a mixed suite as if every case ran it.
386
+ const runsPerObs = (p) => (cfg.design ?? p.design ?? recommendedDesign) === "paired" ? 2 : 1;
387
+ // `screenRuns` counts observations, so each case's pass costs its own design's runs.
388
+ const screenPassRuns = planned.reduce((a, p) => a + cfg.screenRuns * runsPerObs(p), 0);
389
+ // Screening: everything gets `screenRuns`; only the cases the screen ranks as
390
+ // plausibly moved go on to the sequential test. A clean PR moves nothing, so
391
+ // in expectation only the FDR-sized tail continues, but a plan must be
392
+ // conservative, so we budget for the worst realistic case: 10% continue.
393
+ const screenContinueFraction = 0.1;
394
+ const screenSelected = Math.ceil(decidableAny.length * screenContinueFraction);
395
+ const meanBest = decidableAny.length > 0 ? bestRuns / decidableAny.length : 0;
396
+ // `screenRuns` counts *observations*, and a paired observation is two runs,
397
+ // so a paired screening pass costs twice what an unpaired one does. Only the
398
+ // screen term is scaled here: `meanBest` already comes from `paired.runs`,
399
+ // which is `pairs * 2`. The expected total below reads `screenRuns` the same
400
+ // way, and when this one did not, the two screened figures in `totals`
401
+ // disagreed about the price of the same pass.
402
+ const screenedRuns = screenPassRuns + screenSelected * meanBest;
403
+ const fixedNRuns = planned.reduce((a, p) => a + finite(p.fixedNPerArm), 0);
404
+ // The expected cost of a pull request that changed nothing, which is what a
405
+ // pull request almost always is, and therefore the number a budget should be
406
+ // set from.
407
+ // Both counts are *observations*. A paired observation is two runs, the
408
+ // candidate and the reference revision on the same seed, and so is a paired
409
+ // screening run. Each case is counted under the rule that stops it: a paired
410
+ // one in pairs to settle, an unpaired one in the runs `shouldStop` takes.
411
+ // Wald's SPRT figure, used for both before, runs a different rule, ignores
412
+ // the coupling and the suite size, and mispriced cases either way.
413
+ const stopRuns = new Map();
414
+ const observations = (p) => {
415
+ const b = baseline.get(p.caseId);
416
+ if (!b || b.trials === 0)
417
+ return 0;
418
+ if (runsPerObs(p) === 2) {
419
+ return expectedSettlePairs(p.baseline.rate, p.pairCoupling, cfg.mde, cfg);
420
+ }
421
+ const key = `${b.successes}/${b.trials}`;
422
+ const known = stopRuns.get(key);
423
+ if (known !== undefined)
424
+ return known;
425
+ const runs = expectedStopRuns(b, b.successes / b.trials, m, cfg);
426
+ stopRuns.set(key, runs);
427
+ return runs;
428
+ };
429
+ const priced = planned.filter((p) => (baseline.get(p.caseId)?.trials ?? 0) > 0).length;
430
+ const typicalObservations = planned.reduce((a, p) => a + observations(p), 0);
431
+ const typicalRuns = planned.reduce((a, p) => a + observations(p) * runsPerObs(p), 0);
432
+ const runsPerObservation = typicalObservations > 0
433
+ ? typicalRuns / typicalObservations
434
+ : recommendedDesign === "paired"
435
+ ? 2
436
+ : 1;
437
+ const meanTypical = priced > 0 ? typicalRuns / priced : 0;
438
+ const typicalScreenedRuns = Math.ceil(screenPassRuns + Math.ceil(m * screenContinueFraction) * meanTypical);
439
+ const totals = {
440
+ cases: m,
441
+ decidableUnpaired: decidableUnpaired.length,
442
+ decidableBest: decidableAny.length,
443
+ unpairedRuns,
444
+ unpairedCostUsd: unpairedRuns * cfg.costPerRunUsd,
445
+ pairedRuns,
446
+ pairedCostUsd: pairedRuns * cfg.costPerRunUsd,
447
+ bestRuns: round(bestRuns),
448
+ bestCostUsd: round(bestRuns) * cfg.costPerRunUsd,
449
+ screenedRuns: round(screenedRuns),
450
+ screenedCostUsd: round(screenedRuns) * cfg.costPerRunUsd,
451
+ fixedNRuns,
452
+ fixedNCostUsd: fixedNRuns * cfg.costPerRunUsd,
453
+ wallMsUnpaired: (unpairedRuns * cfg.msPerRun) / Math.max(1, cfg.concurrency),
454
+ wallMsPaired: (pairedRuns * cfg.msPerRun) / Math.max(1, cfg.concurrency),
455
+ wallMsBest: (round(bestRuns) * cfg.msPerRun) / Math.max(1, cfg.concurrency),
456
+ wallMsScreened: (round(screenedRuns) * cfg.msPerRun) / Math.max(1, cfg.concurrency),
457
+ screenContinueFraction,
458
+ runsPerObservation,
459
+ typicalRuns,
460
+ typicalCostUsd: typicalRuns * cfg.costPerRunUsd,
461
+ wallMsTypical: (typicalRuns * cfg.msPerRun) / Math.max(1, cfg.concurrency),
462
+ typicalScreenedRuns,
463
+ typicalScreenedCostUsd: typicalScreenedRuns * cfg.costPerRunUsd,
464
+ };
465
+ const undecidable = planned.filter((p) => p.detectability === "TOO_EXPENSIVE" || p.detectability === "IMPOSSIBLE");
466
+ const pairedOnly = planned.filter((p) => p.detectability === "PAIRED_ONLY");
467
+ const impossible = planned.filter((p) => p.detectability === "IMPOSSIBLE");
468
+ const recommendation = decidableAny.length === 0
469
+ ? `nothing is decidable at ${(cfg.mde * 100).toFixed(0)} points against this baseline, record more baseline runs or raise the MDE`
470
+ : recommendedDesign === "paired"
471
+ ? (manyPairedOnly
472
+ ? `pair the runs: ${pairedOnly.length}/${m} cases have an unpaired evidence ceiling under the bar, so no candidate budget can decide them`
473
+ : `pair the runs: ${pairedDesignCount}/${decidableAny.length} decidable cases are cheaper paired than against the stored baseline`) +
474
+ (cfg.design === undefined
475
+ ? ` (the costs below price each case at its own design, two runs an observation where it is paired)`
476
+ : ` (the costs below are priced at two runs an observation, which is what pairing costs)`)
477
+ : totals.screenedCostUsd < totals.bestCostUsd
478
+ ? `screen first, then run the sequential test on what the screen keeps ` +
479
+ `($${totals.screenedCostUsd.toFixed(2)} vs $${totals.bestCostUsd.toFixed(2)})`
480
+ : `run the sequential test directly`;
481
+ // The honest cost is the cheapest plan that still decides something. A design
482
+ // that is free because it certifies nothing is not a cheap design.
483
+ const ceilingCost = decidableAny.length === 0 ? Infinity : Math.min(totals.screenedCostUsd, totals.bestCostUsd);
484
+ // The headline is the expected cost; leading with the ceiling over-states the bill.
485
+ // Mirrors the ceiling's structure, the cheapest plan of its kind, so the two
486
+ // numbers are roughly comparable. Comparing an unscreened expected cost
487
+ // with a screened ceiling was the first version of this and it made the
488
+ // "expected" figure look *larger* than the worst case.
489
+ const expectedCost = decidableAny.length === 0
490
+ ? Infinity
491
+ : Math.min(totals.typicalCostUsd, totals.typicalScreenedCostUsd);
492
+ const coverage = `${decidableAny.length}/${m} cases`;
493
+ // "Every case regressed" is not the expensive pull request, and calling it
494
+ // the worst case put a number below the headline that an operator would
495
+ // budget from: a 200-case suite on a 12-run baseline read "about $11.42 per
496
+ // pull request. Worst case ... is $9.16". The sequential test stops a case
497
+ // the moment it clears the bar, so a suite that really did regress finishes
498
+ // early, while one that did not move runs its full schedule. When the
499
+ // all-regressed cost lands under the expected one, say which it is.
500
+ //
501
+ // The sentence stops short of naming early stopping as *the* cause, because
502
+ // there is a second one: both figures are a `min` over two designs, and the
503
+ // two minima need not select the same design, so the cheaper figure may be
504
+ // pricing a plan the dearer one is not. Until both sides are made to select
505
+ // together, the honest claim is only that neither bounds the other.
506
+ const ceilingNote = !Number.isFinite(ceilingCost)
507
+ ? ""
508
+ : ceilingCost >= expectedCost
509
+ ? ` Worst case, every case regressing at once, is $${ceilingCost.toFixed(2)}.`
510
+ : ` A pull request in which every case regressed costs less, $${ceilingCost.toFixed(2)}: ` +
511
+ `the two do not price the same schedule, so neither bounds the other.`;
512
+ const verdict = !Number.isFinite(expectedCost)
513
+ ? `undecidable: no design reaches the bar for any case at ${(cfg.mde * 100).toFixed(0)} points with a ${medianTrials(planned)}-run baseline`
514
+ : expectedCost <= 5
515
+ ? `affordable: about $${expectedCost.toFixed(2)} per pull request, covering ${coverage}.${ceilingNote}`
516
+ : expectedCost <= 100
517
+ ? `viable but not free: about $${expectedCost.toFixed(2)} per pull request covering ${coverage}, worth gating a release on, not every commit.${ceilingNote}`
518
+ : `not affordable per-PR at $${cfg.costPerRunUsd.toFixed(4)}/run: about $${expectedCost.toFixed(0)} a time for ${coverage}. ` +
519
+ `Cut the suite, raise the MDE, or gate nightly instead of per-PR.${ceilingNote}`;
520
+ return {
521
+ config: cfg,
522
+ cases: planned,
523
+ totals,
524
+ undecidable,
525
+ pairedOnly,
526
+ impossible,
527
+ recommendedDesign,
528
+ recommendation,
529
+ verdict,
530
+ expectedCostUsd: expectedCost,
531
+ ceilingCostUsd: ceilingCost,
532
+ };
533
+ }
534
+ export function affordabilityGrid(baselineRate, baselineTrials, cfg, budgetUsd, caseCounts = [20, 50, 100, 200], mdes = [0.1, 0.15, 0.25, 0.35]) {
535
+ requireProbability(baselineRate, "baselineRate", "affordabilityGrid");
536
+ requireErrorRates(cfg.alpha, cfg.beta, "affordabilityGrid");
537
+ // The typical bill below takes this quantile of the baseline posterior
538
+ // straight from `betaQuantile`, which answers 0 or 1 for a quantile outside
539
+ // (0,1) and about 0 for NaN instead of refusing. `makePlan` refuses it.
540
+ requireOpenProbability(cfg.nullQuantile, "nullQuantile", "affordabilityGrid");
541
+ // makePlan refuses both; unchecked, an fdr of 2 priced a grid cheaper than one at 0.05.
542
+ requireOpenProbability(cfg.fdr, "fdr", "affordabilityGrid");
543
+ requireSettleConfidence(cfg, "affordabilityGrid");
544
+ requireTrialGrid(cfg, "affordabilityGrid");
545
+ if (!(cfg.costPerRunUsd >= 0)) {
546
+ throw new PeeksafeError("PEEKSAFE_E_CONFIG", `affordabilityGrid: costPerRunUsd must be ≥ 0, got ${cfg.costPerRunUsd}`, {
547
+ detail: { costPerRunUsd: cfg.costPerRunUsd },
548
+ });
549
+ }
550
+ for (const m of caseCounts) {
551
+ if (!Number.isInteger(m) || m < 1) {
552
+ throw new PeeksafeError("PEEKSAFE_E_CONFIG", `affordabilityGrid: every case count must be a positive integer, got ${m}`, {
553
+ detail: { cases: m },
554
+ });
555
+ }
556
+ }
557
+ // Every other planner entry point refuses a case with no baseline: `planCase`
558
+ // returns IMPOSSIBLE with "no baseline", `evaluatePoint` throws on
559
+ // `baselineRuns < 1`, and `gate` throws PEEKSAFE_E_BASELINE_MISSING. This one
560
+ // checked only the rate, so `baselineTrials: 0` reached `twoSamplePriors` as
561
+ // 0 successes in 0 trials, whose null prior is Beta(1, 1), and the grid
562
+ // priced the suite against "every case passes half the time" and reported it
563
+ // affordable, on a cheaper typical bill than the same call at a real
564
+ // baseline. A fractional or negative count did reach a refusal, but from
565
+ // `requireCounts` three frames down, naming a success count the caller never
566
+ // passed. `test/budget.test.ts` has the figures.
567
+ if (!Number.isInteger(baselineTrials) || baselineTrials < 1) {
568
+ throw new PeeksafeError("PEEKSAFE_E_CONFIG", `affordabilityGrid: baselineTrials must be a positive integer, got ${baselineTrials}`, {
569
+ detail: { baselineTrials },
570
+ 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"',
571
+ });
572
+ }
573
+ const out = [];
574
+ for (const m of caseCounts) {
575
+ for (const mde of mdes) {
576
+ const bar = Math.log(ebhSoloThreshold(m, cfg.fdr));
577
+ const pTrue = Math.max(1e-6, baselineRate - mde);
578
+ const s = Math.round(baselineRate * baselineTrials);
579
+ const perCase = samplesForEvidence(pTrue, s, baselineTrials, mde, bar, 200_000);
580
+ const pairs = mcnemarSamplesForEvidence(baselineRate, mde, cfg.pairCoupling, bar, 200_000);
581
+ // the cheaper of the two designs, screened down to a 10% continue rate
582
+ const best = Math.min(Number.isFinite(perCase) ? perCase : Infinity, pairs * 2);
583
+ // Which design `best` just picked, needed by both totals below: they have
584
+ // to price the same screening pass as each other, and `screenRuns` counts
585
+ // observations, so a paired pass costs two runs an observation. Only the
586
+ // screen term is scaled, because `best` is already in runs (`pairs * 2`).
587
+ // When this total left the screen term unscaled it understated a paired
588
+ // cell by `m * screenRuns` runs, which made `affordable` below answer for
589
+ // a cheaper ceiling than the one it reports, and made this grid disagree
590
+ // with `makePlan`'s `screenedRuns` about the price of the same pass.
591
+ const runsPerObs = Number.isFinite(perCase) && perCase <= pairs * 2 ? 1 : 2;
592
+ const runs = Number.isFinite(best)
593
+ ? Math.ceil(m * cfg.screenRuns * runsPerObs + Math.ceil(m * 0.1) * best)
594
+ : Infinity;
595
+ const costUsd = runs * cfg.costPerRunUsd;
596
+ // The ceiling above prices a pull request in which every case regressed.
597
+ // This prices one in which nothing did, which is what a pull request
598
+ // almost always is.
599
+ const p0 = betaQuantile(1 + s, 1 + baselineTrials - s, cfg.nullQuantile);
600
+ const p1 = Math.max(0.005, p0 - mde);
601
+ const rawN = sprtExpectedN(baselineRate, p0, p1, cfg.alpha, cfg.beta);
602
+ // A paired cell counts the pairs `shouldStopPaired` takes to settle, as `makePlan` does; the SPRT figure ignores the coupling.
603
+ const perCaseTypical = runsPerObs === 2
604
+ ? expectedSettlePairs(baselineRate, cfg.pairCoupling, mde, cfg)
605
+ : Number.isFinite(rawN) && rawN > 0
606
+ ? Math.min(cfg.maxTrials, Math.max(cfg.minTrials, Math.ceil(rawN)))
607
+ : cfg.maxTrials;
608
+ // Priced for the same design as the ceiling above (`runsPerObs`), or the
609
+ // cell quotes an unpaired price for a plan only pairing can execute.
610
+ const typicalRuns = Math.ceil((m * cfg.screenRuns + Math.ceil(m * 0.1) * perCaseTypical) * runsPerObs);
611
+ const typicalCostUsd = typicalRuns * cfg.costPerRunUsd;
612
+ out.push({
613
+ cases: m,
614
+ mde,
615
+ runs,
616
+ costUsd,
617
+ typicalRuns,
618
+ typicalCostUsd,
619
+ affordable: costUsd <= budgetUsd,
620
+ typicallyAffordable: typicalCostUsd <= budgetUsd,
621
+ });
622
+ }
623
+ }
624
+ return out;
625
+ }
626
+ //# sourceMappingURL=plan.js.map