peeksafe 0.2.0 → 0.4.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.
package/src/frontier.ts CHANGED
@@ -65,14 +65,17 @@ import {
65
65
  requireRunCeiling,
66
66
  requireTrialGrid,
67
67
  } from "./errors.js";
68
- import { DEFAULT_PLAN, expectedSettlePairs, requireSettleConfidence } from "./plan.js";
69
68
  import {
70
- betaQuantile,
69
+ DEFAULT_PLAN,
70
+ expectedSettlePairs,
71
+ expectedStopRuns,
72
+ requireSettleConfidence,
73
+ } from "./plan.js";
74
+ import {
71
75
  ebhSoloThreshold,
72
76
  evidenceCeilingLogE,
73
77
  mcnemarSamplesForEvidence,
74
78
  samplesForEvidence,
75
- sprtExpectedN,
76
79
  } from "./stats.js";
77
80
 
78
81
  export type FrontierDesign = "unpaired" | "paired";
@@ -187,7 +190,7 @@ export interface FrontierConfig {
187
190
  maxTrials: number;
188
191
  batchSize: number;
189
192
  nullQuantile: number;
190
- /** the `futilityConfidence` `shouldStopPaired` runs at; unset takes its default */
193
+ /** the `futilityConfidence` `shouldStop` and `shouldStopPaired` run at; unset takes their default */
191
194
  futilityConfidence?: number;
192
195
  }
193
196
 
@@ -345,36 +348,41 @@ function requirePricing(cfg: FrontierConfig, where: string): void {
345
348
  }
346
349
 
347
350
  /**
348
- * Observations per case on a pull request where nothing moved, clamped onto the
349
- * gate's own batch grid.
351
+ * Expected runs `shouldStop` takes to stop an unpaired case where nothing
352
+ * moved, in a suite of `cases`, at `mde`, `fdr` and `futilityConfidence`: the
353
+ * per-case share `evaluatePoint` bills an unpaired point at.
350
354
  */
351
355
  export function typicalObservationsPerCase(
352
356
  rate: number,
353
357
  mde: number,
354
358
  cfg: FrontierConfig,
355
359
  baselineRuns: number,
360
+ cases: number,
356
361
  ): number {
357
- // `makePlan` reaches this quantile through `baselineNullRate`, which refuses
358
- // one outside (0,1). This handed it to `betaQuantile` directly, which answers
359
- // 0 for a quantile at or below 0, 1 at or above 1, and about 0 for NaN, so the
360
- // frontier priced the typical bill against a null rate nobody chose.
361
- requireOpenProbability(cfg.nullQuantile, "nullQuantile", "typicalObservationsPerCase");
362
- requireTrialGrid(cfg, "typicalObservationsPerCase");
363
- // Unchecked, an mde of 0 or below priced a bill for an effect nobody can detect, and a bad rate or run count was refused under betaQuantile's name.
364
- requireOpenProbability(rate, "rate", "typicalObservationsPerCase");
365
- requireOpenProbability(mde, "mde", "typicalObservationsPerCase");
366
- requireBaselineRuns(baselineRuns, "baselineRuns", "typicalObservationsPerCase");
367
- const s = Math.round(rate * baselineRuns);
368
- const p0 = betaQuantile(1 + s, 1 + baselineRuns - s, cfg.nullQuantile);
369
- const p1 = Math.max(0.005, p0 - mde);
370
- const raw = sprtExpectedN(rate, p0, p1, cfg.alpha, cfg.beta);
371
- const n =
372
- Number.isFinite(raw) && raw > 0
373
- ? Math.min(cfg.maxTrials, Math.max(cfg.minTrials, Math.ceil(raw)))
374
- : cfg.maxTrials;
375
- const batched =
376
- cfg.minTrials + Math.ceil(Math.max(0, n - cfg.minTrials) / cfg.batchSize) * cfg.batchSize;
377
- return Math.min(cfg.maxTrials, batched);
362
+ const where = "typicalObservationsPerCase";
363
+ // `makePlan` reaches this quantile through `baselineNullRate`, which refuses one outside (0,1), so the frontier refuses it too.
364
+ requireOpenProbability(cfg.nullQuantile, "nullQuantile", where);
365
+ requireTrialGrid(cfg, where);
366
+ // Unchecked, an mde of 0 or below priced a bill for an effect nobody can detect, and a bad rate or run count was refused under another function's name.
367
+ requireOpenProbability(rate, "rate", where);
368
+ requireOpenProbability(mde, "mde", where);
369
+ requireBaselineRuns(baselineRuns, "baselineRuns", where);
370
+ if (!Number.isInteger(cases) || cases < 1) {
371
+ throw new PeeksafeError(
372
+ "PEEKSAFE_E_CONFIG",
373
+ `${where}: cases must be a positive integer, got ${cases}`,
374
+ {
375
+ detail: { cases, where },
376
+ },
377
+ );
378
+ }
379
+ requireOpenProbability(cfg.fdr, "fdr", where);
380
+ requireSettleConfidence(cfg, where);
381
+ const successes = Math.round(rate * baselineRuns);
382
+ return expectedStopRuns({ caseId: "typical", successes, trials: baselineRuns }, rate, cases, {
383
+ ...cfg,
384
+ mde,
385
+ });
378
386
  }
379
387
 
380
388
  export function evaluatePoint(
@@ -393,10 +401,7 @@ export function evaluatePoint(
393
401
  requireOpenProbability(mde, "mde", "evaluatePoint");
394
402
  requireOpenProbability(cfg.baselineRate, "baselineRate", "evaluatePoint");
395
403
  // `makePlan` refuses these two and this did not, so one options object was a
396
- // refusal through the planner and a priced frontier through here. Both end up
397
- // in `sprtExpectedN`, which answers NaN or Infinity outside (0,1), and
398
- // `typicalObservationsPerCase` reads either as "cannot price this" and falls
399
- // back to the run ceiling.
404
+ // refusal through the planner and a priced frontier through here.
400
405
  requireErrorRates(cfg.alpha, cfg.beta, "evaluatePoint");
401
406
  requireProbability(cfg.pairCoupling, "pairCoupling", "evaluatePoint");
402
407
  requireOpenProbability(cfg.nullQuantile, "nullQuantile", "evaluatePoint");
@@ -509,11 +514,11 @@ export function evaluatePoint(
509
514
  }
510
515
  const feasible = infeasibleBecause === null;
511
516
 
512
- // A paired case counts the pairs `shouldStopPaired` takes to settle, as `makePlan` does; the SPRT figure ignores the coupling.
517
+ // Each design is counted under the rule that stops it, as `makePlan` does; Wald's SPRT figure ignores the coupling and the suite size.
513
518
  const typicalObservations =
514
519
  design === "paired"
515
- ? expectedSettlePairs(rate, cfg.pairCoupling, mde, cfg)
516
- : typicalObservationsPerCase(rate, mde, cfg, effectiveBaselineRuns);
520
+ ? expectedSettlePairs(rate, cfg.pairCoupling, mde, cases, cfg)
521
+ : typicalObservationsPerCase(rate, mde, cfg, effectiveBaselineRuns, cases);
517
522
  const keep = screen === "none" ? cases : Math.max(1, Math.ceil(cases * cfg.screenKeepFraction));
518
523
 
519
524
  // Screening runs are graded by whichever grader the screen uses. A proxy
package/src/gate.ts CHANGED
@@ -189,6 +189,39 @@ export interface GateResult {
189
189
  headline: string;
190
190
  }
191
191
 
192
+ /** A number as JSON can hold it: finite as is, the rest as the string `String` gives, which `Number` reads back. */
193
+ export type JsonNumber = number | "Infinity" | "-Infinity" | "NaN";
194
+
195
+ /** A `CaseVerdict` as `gateReport` writes it. A paired case's `ceiling` is `"Infinity"`. */
196
+ export type CaseReport = Omit<CaseVerdict, "ceiling" | "logE"> & {
197
+ ceiling: JsonNumber;
198
+ logE: JsonNumber;
199
+ };
200
+
201
+ /** A `GateResult` as `gateReport` writes it, and as `JSON.stringify(result)` does. */
202
+ export type GateReport = Omit<GateResult, "cases" | "regressed"> & {
203
+ cases: CaseReport[];
204
+ regressed: CaseReport[];
205
+ };
206
+
207
+ const jsonSafe = (v: unknown): unknown => {
208
+ if (typeof v === "number") return Number.isFinite(v) ? v : String(v);
209
+ if (Array.isArray(v)) return v.map(jsonSafe);
210
+ if (v !== null && typeof v === "object") {
211
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, jsonSafe(x)]));
212
+ }
213
+ return v;
214
+ };
215
+
216
+ /**
217
+ * A copy of `result` that survives JSON: `JSON.stringify` writes `Infinity` as
218
+ * `null`, which a reader of a CI artifact takes for "no ceiling" where the truth
219
+ * is "no limit". Every non-finite number becomes a string instead.
220
+ */
221
+ export function gateReport(result: GateResult): GateReport {
222
+ return jsonSafe(result) as GateReport;
223
+ }
224
+
192
225
  /**
193
226
  * Decide which cases regressed.
194
227
  *
@@ -220,8 +253,19 @@ export function gate(
220
253
  continue;
221
254
  }
222
255
  requireCounts(c.successes, c.trials, `gate(${c.id})`);
223
- if (c.baseline)
224
- requireCounts(c.baseline.successes, c.baseline.trials, `gate(${c.id}).baseline`);
256
+ if (!c.baseline) continue;
257
+ // Only the counts are read, so a baseline joined to the wrong case was gated against without a word.
258
+ if (c.baseline.caseId !== c.id) {
259
+ throw new PeeksafeError(
260
+ "PEEKSAFE_E_CONFIG",
261
+ `gate: case ${c.id} carries the baseline of case ${c.baseline.caseId}`,
262
+ {
263
+ detail: { id: c.id, baselineCaseId: c.baseline.caseId },
264
+ hint: "join baselines to cases by id, e.g. toBaselineMap(baselines).get(c.id)",
265
+ },
266
+ );
267
+ }
268
+ requireCounts(c.baseline.successes, c.baseline.trials, `gate(${c.id}).baseline`);
225
269
  }
226
270
 
227
271
  const hasBaseline = (c: GateCase | PairedGateCase): boolean =>
@@ -350,7 +394,7 @@ export function gate(
350
394
  .join(", ")}${regressed.length > 3 ? ", …" : ""})`) +
351
395
  (caveats.length > 0 ? `. Read with care: ${caveats.join("; ")}.` : "");
352
396
 
353
- return {
397
+ const result: GateResult = {
354
398
  verdict: regressed.length === 0 ? "PASS" : "FAIL",
355
399
  cases: verdicts,
356
400
  regressed,
@@ -360,4 +404,7 @@ export function gate(
360
404
  options: opts,
361
405
  headline,
362
406
  };
407
+ // Not enumerable, so equality checks and spreads see only the fields.
408
+ Object.defineProperty(result, "toJSON", { value: () => gateReport(result) });
409
+ return result;
363
410
  }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * ## 1. The decision
12
12
  *
13
13
  * gate(cases, options) which cases regressed, valid at any stopping time
14
+ * gateReport(result) the same verdict with Infinity kept through JSON
14
15
  *
15
16
  * You bring counts. It brings e-values and e-BH. Nothing here knows or cares how
16
17
  * you ran the evals, and nothing here will gate a case that has no baseline.
@@ -109,12 +110,16 @@ export {
109
110
  } from "./frontier.js";
110
111
  /* ── 1. the decision ─────────────────────────────────────────────────────── */
111
112
  export {
113
+ type CaseReport,
112
114
  type CaseVerdict,
113
115
  DEFAULT_GATE_OPTIONS,
114
116
  type GateCase,
115
117
  type GateOptions,
118
+ type GateReport,
116
119
  type GateResult,
117
120
  gate,
121
+ gateReport,
122
+ type JsonNumber,
118
123
  type PairedGateCase,
119
124
  } from "./gate.js";
120
125
  /* ── 2. the budget ───────────────────────────────────────────────────────── */
package/src/plan.ts CHANGED
@@ -32,7 +32,7 @@
32
32
  * it.
33
33
  */
34
34
 
35
- import { type BaselineStat, baselineNullRate, type CaseRef } from "./baseline.js";
35
+ import type { BaselineStat, CaseRef } from "./baseline.js";
36
36
  import {
37
37
  PeeksafeError,
38
38
  requireCounts,
@@ -44,7 +44,6 @@ import {
44
44
  requireUniqueCaseIds,
45
45
  } from "./errors.js";
46
46
  import {
47
- betaQuantile,
48
47
  ebhSoloThreshold,
49
48
  evidenceCeilingLogE,
50
49
  mcnemarSamplesForEvidence,
@@ -52,9 +51,8 @@ import {
52
51
  pairPhi,
53
52
  sampleSizeTwoProportion,
54
53
  samplesForEvidence,
55
- sprtExpectedN,
56
54
  } from "./stats.js";
57
- import { DEFAULT_STOP_OPTIONS, settleLogE, shouldStop } from "./stop.js";
55
+ import { shouldStop, shouldStopPaired } from "./stop.js";
58
56
 
59
57
  export interface PlanConfig {
60
58
  /** the drop we want to be able to detect, in probability units (0.15 = 15 points) */
@@ -164,11 +162,19 @@ export interface PlanCase {
164
162
  */
165
163
  design: "unpaired" | "paired" | null;
166
164
  /**
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.
165
+ * The smallest total baseline size (not an increment over the current one)
166
+ * at which the *unpaired* design would work, or null if it already works.
167
+ * A floor, not a threshold: see `baselineRunsToClear`. This is the
168
+ * actionable half of an "undetectable" verdict.
170
169
  */
171
170
  baselineRunsNeeded: number | null;
171
+ /**
172
+ * Expected runs this case costs in a pull request in which nothing moved, at
173
+ * the design the expected bill prices it at: `config.design`, else `design`,
174
+ * else the suite's `recommendedDesign` (unpaired when `planCase` is called on
175
+ * its own). The cases of a plan sum to `totals.typicalRuns`; 0 with no baseline.
176
+ */
177
+ typicalRuns: number;
172
178
  /** what a fixed-N two-proportion design would spend per arm, for comparison */
173
179
  fixedNPerArm: number;
174
180
  reason: string;
@@ -330,6 +336,9 @@ export function planCase(
330
336
  if (b) requireCounts(b.successes, b.trials, `planCase(${c.id}).baseline`);
331
337
  requireErrorRates(cfg.alpha, cfg.beta, "planCase");
332
338
  requirePrices(cfg, "planCase");
339
+ // The expected bill walks the batch grid at the stopping rule's level; a zero batch never ends.
340
+ requireTrialGrid(cfg, "planCase");
341
+ requireSettleConfidence(cfg, "planCase");
333
342
  const barLogE = Math.log(ebhSoloThreshold(m, cfg.fdr));
334
343
  const trials = b?.trials ?? 0;
335
344
  const successes = b?.successes ?? 0;
@@ -357,6 +366,7 @@ export function planCase(
357
366
  detectability: "IMPOSSIBLE",
358
367
  design: null,
359
368
  baselineRunsNeeded: null,
369
+ typicalRuns: 0,
360
370
  reason: "no baseline: nothing to compare against, record one first",
361
371
  };
362
372
  }
@@ -370,6 +380,8 @@ export function planCase(
370
380
  detectability: "IMPOSSIBLE",
371
381
  design: null,
372
382
  baselineRunsNeeded: null,
383
+ // Undecidable, but the gate still runs it until it stops, so the expected bill pays for it.
384
+ typicalRuns: typicalCaseRuns(b, cfg.design ?? "unpaired", cfg, m),
373
385
  reason:
374
386
  `baseline rate ${(rate * 100).toFixed(0)}% cannot lose ${(cfg.mde * 100).toFixed(0)} points, ` +
375
387
  `the effect is arithmetically impossible, not merely expensive`,
@@ -445,24 +457,40 @@ export function planCase(
445
457
  detectability,
446
458
  design,
447
459
  baselineRunsNeeded,
460
+ typicalRuns: typicalCaseRuns(b, cfg.design ?? design ?? "unpaired", cfg, m),
448
461
  reason,
449
462
  };
450
463
  }
451
464
 
465
+ // Each design is counted under the rule that stops it: an unpaired case in the runs `shouldStop` takes, a paired one in the pairs `shouldStopPaired` takes.
466
+ function typicalCaseRuns(
467
+ b: BaselineStat | undefined,
468
+ design: "unpaired" | "paired",
469
+ cfg: PlanConfig,
470
+ m: number,
471
+ ): number {
472
+ if (!b || b.trials === 0) return 0;
473
+ const rate = b.successes / b.trials;
474
+ return design === "paired"
475
+ ? 2 * expectedSettlePairs(rate, cfg.pairCoupling, cfg.mde, m, cfg)
476
+ : expectedStopRuns(b, rate, m, cfg);
477
+ }
478
+
452
479
  /**
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.
480
+ * Expected total samples for the sequential design: per case, the exact
481
+ * expected runs `shouldStop` takes to stop it when its candidate passes at
482
+ * `trueRate`, in a suite of `cases.length`, on the gate's own batch grid
483
+ * (`expectedStopRuns`). At the default rate this is the figure the plan bills
484
+ * an unpaired case at, `PlanCase.typicalRuns`.
456
485
  *
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
486
  * `samplesForEvidence`, the one behind `PlanCase.unpaired.runs`, answers a
460
487
  * 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
488
+ * that did not regress never gets there; it stops as futile or settled in a
489
+ * few batches. Pricing every case at the certification cost over-predicts the
463
490
  * bill.
464
491
  *
465
- * `trueRate` defaults to the case's own baseline rate, i.e. "nothing moved", * which is what almost every pull request looks like.
492
+ * `trueRate` defaults to the case's own baseline rate, i.e. "nothing moved",
493
+ * which is what almost every pull request looks like.
466
494
  */
467
495
  export function expectedSequentialSamples(
468
496
  cases: Array<CaseRef>,
@@ -472,6 +500,10 @@ export function expectedSequentialSamples(
472
500
  b.trials > 0 ? b.successes / b.trials : 0,
473
501
  ): { samples: number; perCase: Map<string, number> } {
474
502
  requireTrialGrid(cfg, "expectedSequentialSamples");
503
+ // Checked here so a bad value is refused under this name, not under the stopping rule's.
504
+ requireOpenProbability(cfg.mde, "mde", "expectedSequentialSamples");
505
+ requireOpenProbability(cfg.fdr, "fdr", "expectedSequentialSamples");
506
+ requireSettleConfidence(cfg, "expectedSequentialSamples");
475
507
  // A repeated id was added to `samples` once per listing but kept once in `perCase`, so the two disagreed.
476
508
  requireUniqueCaseIds(cases, "expectedSequentialSamples");
477
509
  const perCase = new Map<string, number>();
@@ -479,16 +511,12 @@ export function expectedSequentialSamples(
479
511
  for (const c of cases) {
480
512
  const b = baseline.get(c.id);
481
513
  if (!b || b.trials === 0) continue;
482
- const p0 = baselineNullRate(b, cfg.nullQuantile);
483
- const p1 = Math.max(0.005, p0 - cfg.mde);
484
514
  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;
515
+ // Wald's figure fell back to the cap on a NaN rate; the dynamic program would answer NaN.
516
+ requireProbability(p, "trueRate", "expectedSequentialSamples");
517
+ const runs = expectedStopRuns(b, p, cases.length, cfg);
518
+ perCase.set(c.id, runs);
519
+ total += runs;
492
520
  }
493
521
  return { samples: total, perCase };
494
522
  }
@@ -505,38 +533,101 @@ export function requireSettleConfidence(
505
533
  }
506
534
  }
507
535
 
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
- }
536
+ // The frontier prices the same paired cell once per baseline and screen, so a sweep would rerun one program dozens of times.
537
+ const settlePairsMemo = new Map<string, number>();
538
+ // Every case of a suite that shares a baseline, and every planCase call makePlan makes, runs the same program.
539
+ const stopRunsMemo = new Map<string, number>();
515
540
 
516
541
  /**
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.
542
+ * Expected pairs `shouldStopPaired` takes to stop a paired case that did not
543
+ * move, at coupling `rho`, checked at `minTrials` and then every `batchSize`
544
+ * pairs up to `maxTrials`, in a suite of `suiteSize`. Exact, as
545
+ * `expectedStopRuns` is: the decision depends only on the worse and better
546
+ * counts at a given pair count, so a dynamic program carries their joint
547
+ * distribution from one check to the next. Rounded up to a whole pair.
524
548
  */
525
549
  export function expectedSettlePairs(
526
550
  rate: number,
527
551
  rho: number,
528
552
  mde: number,
529
- cfg: TrialGrid & Pick<PlanConfig, "futilityConfidence">,
553
+ suiteSize: number,
554
+ cfg: TrialGrid & Pick<PlanConfig, "fdr" | "futilityConfidence">,
530
555
  ): number {
556
+ const key = [
557
+ rate,
558
+ rho,
559
+ mde,
560
+ suiteSize,
561
+ cfg.fdr,
562
+ cfg.futilityConfidence,
563
+ cfg.minTrials,
564
+ cfg.maxTrials,
565
+ cfg.batchSize,
566
+ ].join("|");
567
+ const known = settlePairsMemo.get(key);
568
+ if (known !== undefined) return known;
569
+ const options = {
570
+ suiteSize,
571
+ mde,
572
+ fdr: cfg.fdr,
573
+ futilityConfidence: cfg.futilityConfidence,
574
+ };
531
575
  // Unmoved arms disagree each way only when uncoupled: (1 - rho) p (1 - p).
532
576
  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);
577
+ const same = 1 - 2 * each;
578
+ // live[w * (top + 1) + b]: probability of w worse and b better pairs with the case still running.
579
+ let live = new Float64Array([1]);
580
+ // Mass under 1e-18 is dropped so the square stays as small as the spread of the counts; it cannot move the rounded answer.
581
+ let top = 0;
582
+ let n = 0;
583
+ let expected = 0;
584
+ for (let step = cfg.minTrials; ; step = Math.min(cfg.batchSize, cfg.maxTrials - n)) {
585
+ for (let i = 0; i < step; i++) {
586
+ const side = top + 2;
587
+ const next = new Float64Array(side * side);
588
+ for (let w = 0; w <= top; w++) {
589
+ for (let b = 0; b <= top; b++) {
590
+ const q = live[w * (top + 1) + b]!;
591
+ if (q === 0) continue;
592
+ next[w * side + b]! += q * same;
593
+ next[(w + 1) * side + b]! += q * each;
594
+ next[w * side + b + 1]! += q * each;
595
+ }
596
+ }
597
+ let reach = 0;
598
+ for (let at = 0; at < next.length; at++) {
599
+ if (next[at]! < 1e-18) next[at] = 0;
600
+ else reach = Math.max(reach, Math.floor(at / side), at % side);
601
+ }
602
+ live = new Float64Array((reach + 1) * (reach + 1));
603
+ for (let w = 0; w <= reach; w++) {
604
+ for (let b = 0; b <= reach; b++) live[w * (reach + 1) + b] = next[w * side + b]!;
605
+ }
606
+ top = reach;
607
+ n++;
608
+ }
609
+ let running = 0;
610
+ for (let w = 0; w <= top; w++) {
611
+ for (let b = 0; b <= top; b++) {
612
+ const at = w * (top + 1) + b;
613
+ const q = live[at]!;
614
+ if (q === 0) continue;
615
+ const counts = { bothPass: n - w - b, worse: w, better: b, bothFail: 0 };
616
+ if (n >= cfg.maxTrials || shouldStopPaired(counts, options).stop) {
617
+ expected += q * n;
618
+ live[at] = 0;
619
+ } else {
620
+ running += q;
621
+ }
622
+ }
623
+ }
624
+ if (running === 0) break;
538
625
  }
539
- return cfg.maxTrials;
626
+ // Float residue must not push an exact whole number up a pair.
627
+ const pairs = Math.ceil(expected - 1e-9);
628
+ if (settlePairsMemo.size >= 1024) settlePairsMemo.clear();
629
+ settlePairsMemo.set(key, pairs);
630
+ return pairs;
540
631
  }
541
632
 
542
633
  /**
@@ -547,12 +638,26 @@ export function expectedSettlePairs(
547
638
  * program carries the distribution of the success count from one check to the
548
639
  * next. Rounded up to a whole run; an expectation is not a grid point.
549
640
  */
550
- function expectedStopRuns(
641
+ export function expectedStopRuns(
551
642
  baseline: BaselineStat,
552
643
  rate: number,
553
644
  suiteSize: number,
554
645
  cfg: TrialGrid & Pick<PlanConfig, "mde" | "fdr" | "futilityConfidence">,
555
646
  ): number {
647
+ const key = [
648
+ baseline.successes,
649
+ baseline.trials,
650
+ rate,
651
+ suiteSize,
652
+ cfg.mde,
653
+ cfg.fdr,
654
+ cfg.futilityConfidence,
655
+ cfg.minTrials,
656
+ cfg.maxTrials,
657
+ cfg.batchSize,
658
+ ].join("|");
659
+ const known = stopRunsMemo.get(key);
660
+ if (known !== undefined) return known;
556
661
  const options = {
557
662
  suiteSize,
558
663
  mde: cfg.mde,
@@ -586,7 +691,10 @@ function expectedStopRuns(
586
691
  if (running === 0) break;
587
692
  }
588
693
  // Float residue must not push an exact whole number up a run.
589
- return Math.ceil(expected - 1e-9);
694
+ const runs = Math.ceil(expected - 1e-9);
695
+ if (stopRunsMemo.size >= 1024) stopRunsMemo.clear();
696
+ stopRunsMemo.set(key, runs);
697
+ return runs;
590
698
  }
591
699
 
592
700
  /**
@@ -669,6 +777,13 @@ export function makePlan(
669
777
  // Per case, like `bestRuns`: one suite-wide design billed a mixed suite as if every case ran it.
670
778
  const runsPerObs = (p: PlanCase) =>
671
779
  (cfg.design ?? p.design ?? recommendedDesign) === "paired" ? 2 : 1;
780
+ // `planCase` cannot see the suite, so a case with no design was priced unpaired there.
781
+ for (const p of planned) {
782
+ if (p.design === null && runsPerObs(p) === 2) {
783
+ const b = baseline.get(p.caseId);
784
+ p.typicalRuns = typicalCaseRuns(b, "paired", { ...cfg, pairCoupling: p.pairCoupling }, m);
785
+ }
786
+ }
672
787
  // `screenRuns` counts observations, so each case's pass costs its own design's runs.
673
788
  const screenPassRuns = planned.reduce((a, p) => a + cfg.screenRuns * runsPerObs(p), 0);
674
789
 
@@ -694,27 +809,14 @@ export function makePlan(
694
809
  // set from.
695
810
  // Both counts are *observations*. A paired observation is two runs, the
696
811
  // 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
- };
812
+ // screening run. Each case is counted under the rule that stops it
813
+ // (`PlanCase.typicalRuns`): a paired one in pairs to settle, an unpaired one
814
+ // in the runs `shouldStop` takes. Wald's SPRT figure, used for both before,
815
+ // runs a different rule, ignores the coupling and the suite size, and
816
+ // mispriced cases either way.
715
817
  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);
818
+ const typicalObservations = planned.reduce((a, p) => a + p.typicalRuns / runsPerObs(p), 0);
819
+ const typicalRuns = planned.reduce((a, p) => a + p.typicalRuns, 0);
718
820
  const runsPerObservation =
719
821
  typicalObservations > 0
720
822
  ? typicalRuns / typicalObservations
@@ -862,9 +964,7 @@ export function affordabilityGrid(
862
964
  ): AffordabilityCell[] {
863
965
  requireProbability(baselineRate, "baselineRate", "affordabilityGrid");
864
966
  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.
967
+ // `makePlan` refuses it, so one options object must not price here and fail there.
868
968
  requireOpenProbability(cfg.nullQuantile, "nullQuantile", "affordabilityGrid");
869
969
  // makePlan refuses both; unchecked, an fdr of 2 priced a grid cheaper than one at 0.05.
870
970
  requireOpenProbability(cfg.fdr, "fdr", "affordabilityGrid");
@@ -936,16 +1036,16 @@ export function affordabilityGrid(
936
1036
  // The ceiling above prices a pull request in which every case regressed.
937
1037
  // This prices one in which nothing did, which is what a pull request
938
1038
  // 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.
1039
+ // Each design is counted under the rule that stops it, as `makePlan` does; Wald's SPRT figure ignores the coupling and the suite size.
943
1040
  const perCaseTypical =
944
1041
  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;
1042
+ ? expectedSettlePairs(baselineRate, cfg.pairCoupling, mde, m, cfg)
1043
+ : expectedStopRuns(
1044
+ { caseId: "typical", successes: s, trials: baselineTrials },
1045
+ baselineRate,
1046
+ m,
1047
+ { ...cfg, mde },
1048
+ );
949
1049
  // Priced for the same design as the ceiling above (`runsPerObs`), or the
950
1050
  // cell quotes an unpaired price for a plan only pairing can execute.
951
1051
  const typicalRuns = Math.ceil(