peeksafe 0.1.0 → 0.3.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/plan.ts CHANGED
@@ -32,15 +32,18 @@
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
+ requireCounts,
38
39
  requireErrorRates,
39
40
  requireOpenProbability,
40
41
  requireProbability,
42
+ requireRunCeiling,
43
+ requireTrialGrid,
44
+ requireUniqueCaseIds,
41
45
  } from "./errors.js";
42
46
  import {
43
- betaQuantile,
44
47
  ebhSoloThreshold,
45
48
  evidenceCeilingLogE,
46
49
  mcnemarSamplesForEvidence,
@@ -48,8 +51,8 @@ import {
48
51
  pairPhi,
49
52
  sampleSizeTwoProportion,
50
53
  samplesForEvidence,
51
- sprtExpectedN,
52
54
  } from "./stats.js";
55
+ import { shouldStop, shouldStopPaired } from "./stop.js";
53
56
 
54
57
  export interface PlanConfig {
55
58
  /** the drop we want to be able to detect, in probability units (0.15 = 15 points) */
@@ -74,14 +77,14 @@ export interface PlanConfig {
74
77
  /** beyond this many runs for one case we stop calling it "affordable" */
75
78
  runsPerCaseCeiling: number;
76
79
  /**
77
- * Which design the *expected* bill is priced for. A paired observation is two
78
- * runs, so this doubles `typicalRuns`.
80
+ * Which design the *expected* bill and the screening pass are priced for. A
81
+ * paired observation is two runs, and a paired case's observation count is
82
+ * the pairs `shouldStopPaired` takes to settle it at its coupling; an
83
+ * unpaired case's is the runs `shouldStop` takes to stop it.
79
84
  *
80
- * `undefined` means "price the design this plan recommends", which is the
81
- * only self-consistent answer: `makePlan` recommends pairing whenever more
82
- * than a quarter of the suite has an unpaired evidence ceiling under the bar,
83
- * and quoting the *unpaired* bill underneath that recommendation would price
84
- * a design nobody runs.
85
+ * `undefined` prices each case at its own `PlanCase.design`, and a case with
86
+ * none at the suite's `recommendedDesign`. Setting it prices every case at
87
+ * that one design.
85
88
  */
86
89
  design?: "unpaired" | "paired";
87
90
 
@@ -96,6 +99,13 @@ export interface PlanConfig {
96
99
  maxTrials: number;
97
100
  batchSize: number;
98
101
  nullQuantile: number;
102
+ /**
103
+ * The `futilityConfidence` you run `shouldStop` and `shouldStopPaired` at.
104
+ * The expected bill counts the runs or pairs a case takes to stop at this
105
+ * level, so a higher one prices more. `undefined` takes the stopping rules'
106
+ * own default.
107
+ */
108
+ futilityConfidence?: number;
99
109
  }
100
110
 
101
111
  export const DEFAULT_PLAN: PlanConfig = {
@@ -157,6 +167,13 @@ export interface PlanCase {
157
167
  * "undetectable" verdict.
158
168
  */
159
169
  baselineRunsNeeded: number | null;
170
+ /**
171
+ * Expected runs this case costs in a pull request in which nothing moved, at
172
+ * the design the expected bill prices it at: `config.design`, else `design`,
173
+ * else the suite's `recommendedDesign` (unpaired when `planCase` is called on
174
+ * its own). The cases of a plan sum to `totals.typicalRuns`; 0 with no baseline.
175
+ */
176
+ typicalRuns: number;
160
177
  /** what a fixed-N two-proportion design would spend per arm, for comparison */
161
178
  fixedNPerArm: number;
162
179
  reason: string;
@@ -197,7 +214,11 @@ export interface PlanTotals {
197
214
  * over-predicts what the gate actually spends by 5 to 8×, and the numbers below
198
215
  * predict it to within about 10% when checked against real runs.
199
216
  */
200
- /** 1 unpaired, 2 paired, what one observation costs in runs */
217
+ /**
218
+ * What one observation of the expected bill costs in runs, averaged over its
219
+ * cases: 1 when every case runs unpaired, 2 when every case is paired, and in
220
+ * between for a suite that mixes the two.
221
+ */
201
222
  runsPerObservation: number;
202
223
  /** expected *runs* (not observations) for a pull request in which nothing moved */
203
224
  typicalRuns: number;
@@ -218,7 +239,13 @@ export interface Plan {
218
239
  pairedOnly: PlanCase[];
219
240
  /** cases whose baseline rate is too low to lose `mde` points at all */
220
241
  impossible: PlanCase[];
221
- /** the design the recommendation names, and the one `expectedCostUsd` prices */
242
+ /**
243
+ * The design the recommendation names: paired when more than a quarter of
244
+ * the suite is `PAIRED_ONLY` or most decidable cases' own `design` is
245
+ * paired. `expectedCostUsd` prices each case at
246
+ * its own `design` and falls back to this one only for a case with none,
247
+ * unless `config.design` fixes one design for the whole suite.
248
+ */
222
249
  recommendedDesign: "unpaired" | "paired";
223
250
  /** the cheapest design that decides every decidable case */
224
251
  recommendation: string;
@@ -286,12 +313,31 @@ function baselineRunsToClear(
286
313
  return hi;
287
314
  }
288
315
 
316
+ function requirePrices(cfg: PlanConfig, where: string): void {
317
+ if (!(cfg.costPerRunUsd >= 0) || !(cfg.msPerRun >= 0)) {
318
+ throw new PeeksafeError(
319
+ "PEEKSAFE_E_CONFIG",
320
+ `${where}: costPerRunUsd and msPerRun must be ≥ 0`,
321
+ {
322
+ detail: { costPerRunUsd: cfg.costPerRunUsd, msPerRun: cfg.msPerRun },
323
+ },
324
+ );
325
+ }
326
+ }
327
+
289
328
  export function planCase(
290
329
  c: CaseRef,
291
330
  b: BaselineStat | undefined,
292
331
  cfg: PlanConfig,
293
332
  m: number,
294
333
  ): PlanCase {
334
+ // 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.
335
+ if (b) requireCounts(b.successes, b.trials, `planCase(${c.id}).baseline`);
336
+ requireErrorRates(cfg.alpha, cfg.beta, "planCase");
337
+ requirePrices(cfg, "planCase");
338
+ // The expected bill walks the batch grid at the stopping rule's level; a zero batch never ends.
339
+ requireTrialGrid(cfg, "planCase");
340
+ requireSettleConfidence(cfg, "planCase");
295
341
  const barLogE = Math.log(ebhSoloThreshold(m, cfg.fdr));
296
342
  const trials = b?.trials ?? 0;
297
343
  const successes = b?.successes ?? 0;
@@ -319,6 +365,7 @@ export function planCase(
319
365
  detectability: "IMPOSSIBLE",
320
366
  design: null,
321
367
  baselineRunsNeeded: null,
368
+ typicalRuns: 0,
322
369
  reason: "no baseline: nothing to compare against, record one first",
323
370
  };
324
371
  }
@@ -332,6 +379,8 @@ export function planCase(
332
379
  detectability: "IMPOSSIBLE",
333
380
  design: null,
334
381
  baselineRunsNeeded: null,
382
+ // Undecidable, but the gate still runs it until it stops, so the expected bill pays for it.
383
+ typicalRuns: typicalCaseRuns(b, cfg.design ?? "unpaired", cfg, m),
335
384
  reason:
336
385
  `baseline rate ${(rate * 100).toFixed(0)}% cannot lose ${(cfg.mde * 100).toFixed(0)} points, ` +
337
386
  `the effect is arithmetically impossible, not merely expensive`,
@@ -407,23 +456,40 @@ export function planCase(
407
456
  detectability,
408
457
  design,
409
458
  baselineRunsNeeded,
459
+ typicalRuns: typicalCaseRuns(b, cfg.design ?? design ?? "unpaired", cfg, m),
410
460
  reason,
411
461
  };
412
462
  }
413
463
 
464
+ // 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.
465
+ function typicalCaseRuns(
466
+ b: BaselineStat | undefined,
467
+ design: "unpaired" | "paired",
468
+ cfg: PlanConfig,
469
+ m: number,
470
+ ): number {
471
+ if (!b || b.trials === 0) return 0;
472
+ const rate = b.successes / b.trials;
473
+ return design === "paired"
474
+ ? 2 * expectedSettlePairs(rate, cfg.pairCoupling, cfg.mde, m, cfg)
475
+ : expectedStopRuns(b, rate, m, cfg);
476
+ }
477
+
414
478
  /**
415
- * Expected total samples for the sequential design, priced under Wald's
416
- * expected sample number at a supplied "true" rate per case, and clamped into
417
- * the gate's own [minTrials, maxTrials] batch grid.
479
+ * Expected total samples for the sequential design: per case, the exact
480
+ * expected runs `shouldStop` takes to stop it when its candidate passes at
481
+ * `trueRate`, in a suite of `cases.length`, on the gate's own batch grid
482
+ * (`expectedStopRuns`). At the default rate this is the figure the plan bills
483
+ * an unpaired case at, `PlanCase.typicalRuns`.
418
484
  *
419
- * This is the number that predicts what the gate will actually spend.
420
485
  * `samplesForEvidence`, the one behind `PlanCase.unpaired.runs`, answers a
421
486
  * different question: how many runs it takes to *certify a regression*. A case
422
- * that did not regress never gets there; it hits the H₀ wall in a few batches
423
- * and stops. Pricing every case at the certification cost over-predicts the
487
+ * that did not regress never gets there; it stops as futile or settled in a
488
+ * few batches. Pricing every case at the certification cost over-predicts the
424
489
  * bill.
425
490
  *
426
- * `trueRate` defaults to the case's own baseline rate, i.e. "nothing moved", * which is what almost every pull request looks like.
491
+ * `trueRate` defaults to the case's own baseline rate, i.e. "nothing moved",
492
+ * which is what almost every pull request looks like.
427
493
  */
428
494
  export function expectedSequentialSamples(
429
495
  cases: Array<CaseRef>,
@@ -432,30 +498,204 @@ export function expectedSequentialSamples(
432
498
  trueRate: (caseId: string, b: BaselineStat) => number = (_id, b) =>
433
499
  b.trials > 0 ? b.successes / b.trials : 0,
434
500
  ): { samples: number; perCase: Map<string, number> } {
501
+ requireTrialGrid(cfg, "expectedSequentialSamples");
502
+ // Checked here so a bad value is refused under this name, not under the stopping rule's.
503
+ requireOpenProbability(cfg.mde, "mde", "expectedSequentialSamples");
504
+ requireOpenProbability(cfg.fdr, "fdr", "expectedSequentialSamples");
505
+ requireSettleConfidence(cfg, "expectedSequentialSamples");
506
+ // A repeated id was added to `samples` once per listing but kept once in `perCase`, so the two disagreed.
507
+ requireUniqueCaseIds(cases, "expectedSequentialSamples");
435
508
  const perCase = new Map<string, number>();
436
509
  let total = 0;
437
510
  for (const c of cases) {
438
511
  const b = baseline.get(c.id);
439
512
  if (!b || b.trials === 0) continue;
440
- const p0 = baselineNullRate(b, cfg.nullQuantile);
441
- const p1 = Math.max(0.005, p0 - cfg.mde);
442
513
  const p = Math.max(0, Math.min(1, trueRate(c.id, b)));
443
- const raw = sprtExpectedN(p, p0, p1, cfg.alpha, cfg.beta);
444
- const n =
445
- Number.isFinite(raw) && raw > 0
446
- ? Math.min(cfg.maxTrials, Math.max(cfg.minTrials, Math.ceil(raw)))
447
- : cfg.maxTrials;
448
- // The gate only ever adds whole batches past the minimum, so round the
449
- // prediction onto the same grid the gate samples on.
450
- const batched =
451
- cfg.minTrials + Math.ceil(Math.max(0, n - cfg.minTrials) / cfg.batchSize) * cfg.batchSize;
452
- const capped = Math.min(cfg.maxTrials, batched);
453
- perCase.set(c.id, capped);
454
- total += capped;
514
+ // Wald's figure fell back to the cap on a NaN rate; the dynamic program would answer NaN.
515
+ requireProbability(p, "trueRate", "expectedSequentialSamples");
516
+ const runs = expectedStopRuns(b, p, cases.length, cfg);
517
+ perCase.set(c.id, runs);
518
+ total += runs;
455
519
  }
456
520
  return { samples: total, perCase };
457
521
  }
458
522
 
523
+ type TrialGrid = Pick<PlanConfig, "minTrials" | "maxTrials" | "batchSize">;
524
+
525
+ /** Refuses a set `futilityConfidence` outside (0, 1), where the settle bar is 0 or Infinity. */
526
+ export function requireSettleConfidence(
527
+ cfg: Pick<PlanConfig, "futilityConfidence">,
528
+ where: string,
529
+ ): void {
530
+ if (cfg.futilityConfidence !== undefined) {
531
+ requireOpenProbability(cfg.futilityConfidence, "futilityConfidence", where);
532
+ }
533
+ }
534
+
535
+ // The frontier prices the same paired cell once per baseline and screen, so a sweep would rerun one program dozens of times.
536
+ const settlePairsMemo = new Map<string, number>();
537
+ // Every case of a suite that shares a baseline, and every planCase call makePlan makes, runs the same program.
538
+ const stopRunsMemo = new Map<string, number>();
539
+
540
+ /**
541
+ * Expected pairs `shouldStopPaired` takes to stop a paired case that did not
542
+ * move, at coupling `rho`, checked at `minTrials` and then every `batchSize`
543
+ * pairs up to `maxTrials`, in a suite of `suiteSize`. Exact, as
544
+ * `expectedStopRuns` is: the decision depends only on the worse and better
545
+ * counts at a given pair count, so a dynamic program carries their joint
546
+ * distribution from one check to the next. Rounded up to a whole pair.
547
+ */
548
+ export function expectedSettlePairs(
549
+ rate: number,
550
+ rho: number,
551
+ mde: number,
552
+ suiteSize: number,
553
+ cfg: TrialGrid & Pick<PlanConfig, "fdr" | "futilityConfidence">,
554
+ ): number {
555
+ const key = [
556
+ rate,
557
+ rho,
558
+ mde,
559
+ suiteSize,
560
+ cfg.fdr,
561
+ cfg.futilityConfidence,
562
+ cfg.minTrials,
563
+ cfg.maxTrials,
564
+ cfg.batchSize,
565
+ ].join("|");
566
+ const known = settlePairsMemo.get(key);
567
+ if (known !== undefined) return known;
568
+ const options = {
569
+ suiteSize,
570
+ mde,
571
+ fdr: cfg.fdr,
572
+ futilityConfidence: cfg.futilityConfidence,
573
+ };
574
+ // Unmoved arms disagree each way only when uncoupled: (1 - rho) p (1 - p).
575
+ const each = (1 - rho) * rate * (1 - rate);
576
+ const same = 1 - 2 * each;
577
+ // live[w * (top + 1) + b]: probability of w worse and b better pairs with the case still running.
578
+ let live = new Float64Array([1]);
579
+ // Mass under 1e-18 is dropped so the square stays as small as the spread of the counts; it cannot move the rounded answer.
580
+ let top = 0;
581
+ let n = 0;
582
+ let expected = 0;
583
+ for (let step = cfg.minTrials; ; step = Math.min(cfg.batchSize, cfg.maxTrials - n)) {
584
+ for (let i = 0; i < step; i++) {
585
+ const side = top + 2;
586
+ const next = new Float64Array(side * side);
587
+ for (let w = 0; w <= top; w++) {
588
+ for (let b = 0; b <= top; b++) {
589
+ const q = live[w * (top + 1) + b]!;
590
+ if (q === 0) continue;
591
+ next[w * side + b]! += q * same;
592
+ next[(w + 1) * side + b]! += q * each;
593
+ next[w * side + b + 1]! += q * each;
594
+ }
595
+ }
596
+ let reach = 0;
597
+ for (let at = 0; at < next.length; at++) {
598
+ if (next[at]! < 1e-18) next[at] = 0;
599
+ else reach = Math.max(reach, Math.floor(at / side), at % side);
600
+ }
601
+ live = new Float64Array((reach + 1) * (reach + 1));
602
+ for (let w = 0; w <= reach; w++) {
603
+ for (let b = 0; b <= reach; b++) live[w * (reach + 1) + b] = next[w * side + b]!;
604
+ }
605
+ top = reach;
606
+ n++;
607
+ }
608
+ let running = 0;
609
+ for (let w = 0; w <= top; w++) {
610
+ for (let b = 0; b <= top; b++) {
611
+ const at = w * (top + 1) + b;
612
+ const q = live[at]!;
613
+ if (q === 0) continue;
614
+ const counts = { bothPass: n - w - b, worse: w, better: b, bothFail: 0 };
615
+ if (n >= cfg.maxTrials || shouldStopPaired(counts, options).stop) {
616
+ expected += q * n;
617
+ live[at] = 0;
618
+ } else {
619
+ running += q;
620
+ }
621
+ }
622
+ }
623
+ if (running === 0) break;
624
+ }
625
+ // Float residue must not push an exact whole number up a pair.
626
+ const pairs = Math.ceil(expected - 1e-9);
627
+ if (settlePairsMemo.size >= 1024) settlePairsMemo.clear();
628
+ settlePairsMemo.set(key, pairs);
629
+ return pairs;
630
+ }
631
+
632
+ /**
633
+ * Expected runs `shouldStop` takes to stop an unpaired case whose candidate
634
+ * passes at `rate`, checked at `minTrials` and then every `batchSize` runs up
635
+ * to `maxTrials`, in a suite of `suiteSize`. Exact rather than simulated:
636
+ * the decision depends on the candidate only through its counts, so a dynamic
637
+ * program carries the distribution of the success count from one check to the
638
+ * next. Rounded up to a whole run; an expectation is not a grid point.
639
+ */
640
+ export function expectedStopRuns(
641
+ baseline: BaselineStat,
642
+ rate: number,
643
+ suiteSize: number,
644
+ cfg: TrialGrid & Pick<PlanConfig, "mde" | "fdr" | "futilityConfidence">,
645
+ ): number {
646
+ const key = [
647
+ baseline.successes,
648
+ baseline.trials,
649
+ rate,
650
+ suiteSize,
651
+ cfg.mde,
652
+ cfg.fdr,
653
+ cfg.futilityConfidence,
654
+ cfg.minTrials,
655
+ cfg.maxTrials,
656
+ cfg.batchSize,
657
+ ].join("|");
658
+ const known = stopRunsMemo.get(key);
659
+ if (known !== undefined) return known;
660
+ const options = {
661
+ suiteSize,
662
+ mde: cfg.mde,
663
+ fdr: cfg.fdr,
664
+ futilityConfidence: cfg.futilityConfidence,
665
+ };
666
+ // live[k]: probability of k successes in n runs with the case still running.
667
+ let live = new Float64Array([1]);
668
+ let n = 0;
669
+ let expected = 0;
670
+ for (let step = cfg.minTrials; ; step = Math.min(cfg.batchSize, cfg.maxTrials - n)) {
671
+ for (let i = 0; i < step; i++) {
672
+ const next = new Float64Array(live.length + 1);
673
+ for (let k = 0; k < live.length; k++) {
674
+ next[k]! += live[k]! * (1 - rate);
675
+ next[k + 1]! += live[k]! * rate;
676
+ }
677
+ live = next;
678
+ }
679
+ n += step;
680
+ let running = 0;
681
+ for (let k = 0; k <= n; k++) {
682
+ if (live[k] === 0) continue;
683
+ if (n >= cfg.maxTrials || shouldStop({ successes: k, trials: n }, baseline, options).stop) {
684
+ expected += live[k]! * n;
685
+ live[k] = 0;
686
+ } else {
687
+ running += live[k]!;
688
+ }
689
+ }
690
+ if (running === 0) break;
691
+ }
692
+ // Float residue must not push an exact whole number up a run.
693
+ const runs = Math.ceil(expected - 1e-9);
694
+ if (stopRunsMemo.size >= 1024) stopRunsMemo.clear();
695
+ stopRunsMemo.set(key, runs);
696
+ return runs;
697
+ }
698
+
459
699
  /**
460
700
  * Plan a suite. `pilots` holds a pair table per case from a pilot run of the
461
701
  * paired design, ideally with both arms on the same revision; a case with one
@@ -485,20 +725,17 @@ export function makePlan(
485
725
  // reaches it, and under that function's name for it, `q`. Checked here so
486
726
  // every planner entry point refuses it the same way and names it.
487
727
  requireOpenProbability(cfg.nullQuantile, "nullQuantile", "makePlan");
728
+ requireSettleConfidence(cfg, "makePlan");
729
+ requireRunCeiling(cfg.runsPerCaseCeiling, "makePlan");
730
+ requireTrialGrid(cfg, "makePlan");
488
731
  if (cases.length === 0) {
489
732
  throw new PeeksafeError("PEEKSAFE_E_SUITE_EMPTY", "makePlan: no cases to plan for", {
490
733
  hint: "pass at least one case; makePlan prices a suite, not an empty list",
491
734
  });
492
735
  }
493
- if (!(cfg.costPerRunUsd >= 0) || !(cfg.msPerRun >= 0)) {
494
- throw new PeeksafeError(
495
- "PEEKSAFE_E_CONFIG",
496
- "makePlan: costPerRunUsd and msPerRun must be ≥ 0",
497
- {
498
- detail: { costPerRunUsd: cfg.costPerRunUsd, msPerRun: cfg.msPerRun },
499
- },
500
- );
501
- }
736
+ // `gate` refuses a repeated id; planned, it was priced twice and counted twice in the suite size the bar is set from.
737
+ requireUniqueCaseIds(cases, "makePlan");
738
+ requirePrices(cfg, "makePlan");
502
739
 
503
740
  const m = cases.length;
504
741
  const planned = cases.map((c) => {
@@ -529,13 +766,25 @@ export function makePlan(
529
766
  );
530
767
 
531
768
  const pairedOnlyCount = planned.filter((p) => p.detectability === "PAIRED_ONLY").length;
532
- // The design the recommendation will name, computed here because the
533
- // *expected* bill has to be priced for it. `pairedOnly > 25%` is the same
534
- // condition the recommendation string uses; they cannot drift apart.
769
+ const pairedDesignCount = decidableAny.filter((p) => p.design === "paired").length;
770
+ // The recommendation string reads these same two flags, so it cannot drift from the design it names.
771
+ const manyPairedOnly = pairedOnlyCount > cases.length * 0.25;
772
+ const mostlyPaired = pairedDesignCount * 2 > decidableAny.length;
535
773
  const recommendedDesign: "unpaired" | "paired" =
536
774
  cfg.design ??
537
- (decidableAny.length > 0 && pairedOnlyCount > cases.length * 0.25 ? "paired" : "unpaired");
538
- const runsPerObservation = recommendedDesign === "paired" ? 2 : 1;
775
+ (decidableAny.length > 0 && (manyPairedOnly || mostlyPaired) ? "paired" : "unpaired");
776
+ // Per case, like `bestRuns`: one suite-wide design billed a mixed suite as if every case ran it.
777
+ const runsPerObs = (p: PlanCase) =>
778
+ (cfg.design ?? p.design ?? recommendedDesign) === "paired" ? 2 : 1;
779
+ // `planCase` cannot see the suite, so a case with no design was priced unpaired there.
780
+ for (const p of planned) {
781
+ if (p.design === null && runsPerObs(p) === 2) {
782
+ const b = baseline.get(p.caseId);
783
+ p.typicalRuns = typicalCaseRuns(b, "paired", { ...cfg, pairCoupling: p.pairCoupling }, m);
784
+ }
785
+ }
786
+ // `screenRuns` counts observations, so each case's pass costs its own design's runs.
787
+ const screenPassRuns = planned.reduce((a, p) => a + cfg.screenRuns * runsPerObs(p), 0);
539
788
 
540
789
  // Screening: everything gets `screenRuns`; only the cases the screen ranks as
541
790
  // plausibly moved go on to the sequential test. A clean PR moves nothing, so
@@ -550,21 +799,32 @@ export function makePlan(
550
799
  // which is `pairs * 2`. The expected total below reads `screenRuns` the same
551
800
  // way, and when this one did not, the two screened figures in `totals`
552
801
  // disagreed about the price of the same pass.
553
- const screenedRuns = m * cfg.screenRuns * runsPerObservation + screenSelected * meanBest;
802
+ const screenedRuns = screenPassRuns + screenSelected * meanBest;
554
803
 
555
804
  const fixedNRuns = planned.reduce((a, p) => a + finite(p.fixedNPerArm), 0);
556
805
 
557
806
  // The expected cost of a pull request that changed nothing, which is what a
558
807
  // pull request almost always is, and therefore the number a budget should be
559
808
  // set from.
560
- // `expectedSequentialSamples` counts *observations*. A paired observation is
561
- // two runs, the candidate and the reference revision on the same seed, and
562
- // so is a paired screening run.
563
- const typical = expectedSequentialSamples(cases, baseline, cfg);
564
- const typicalRuns = typical.samples * runsPerObservation;
565
- const meanTypical = typical.perCase.size > 0 ? typical.samples / typical.perCase.size : 0;
809
+ // Both counts are *observations*. A paired observation is two runs, the
810
+ // candidate and the reference revision on the same seed, and so is a paired
811
+ // screening run. Each case is counted under the rule that stops it
812
+ // (`PlanCase.typicalRuns`): a paired one in pairs to settle, an unpaired one
813
+ // in the runs `shouldStop` takes. Wald's SPRT figure, used for both before,
814
+ // runs a different rule, ignores the coupling and the suite size, and
815
+ // mispriced cases either way.
816
+ const priced = planned.filter((p) => (baseline.get(p.caseId)?.trials ?? 0) > 0).length;
817
+ const typicalObservations = planned.reduce((a, p) => a + p.typicalRuns / runsPerObs(p), 0);
818
+ const typicalRuns = planned.reduce((a, p) => a + p.typicalRuns, 0);
819
+ const runsPerObservation =
820
+ typicalObservations > 0
821
+ ? typicalRuns / typicalObservations
822
+ : recommendedDesign === "paired"
823
+ ? 2
824
+ : 1;
825
+ const meanTypical = priced > 0 ? typicalRuns / priced : 0;
566
826
  const typicalScreenedRuns = Math.ceil(
567
- (m * cfg.screenRuns + Math.ceil(m * screenContinueFraction) * meanTypical) * runsPerObservation,
827
+ screenPassRuns + Math.ceil(m * screenContinueFraction) * meanTypical,
568
828
  );
569
829
 
570
830
  const totals: PlanTotals = {
@@ -604,8 +864,12 @@ export function makePlan(
604
864
  decidableAny.length === 0
605
865
  ? `nothing is decidable at ${(cfg.mde * 100).toFixed(0)} points against this baseline, record more baseline runs or raise the MDE`
606
866
  : recommendedDesign === "paired"
607
- ? `pair the runs: ${pairedOnly.length}/${m} cases have an unpaired evidence ceiling under the bar, so no candidate budget can decide them` +
608
- ` (the costs below are priced at two runs an observation, which is what pairing costs)`
867
+ ? (manyPairedOnly
868
+ ? `pair the runs: ${pairedOnly.length}/${m} cases have an unpaired evidence ceiling under the bar, so no candidate budget can decide them`
869
+ : `pair the runs: ${pairedDesignCount}/${decidableAny.length} decidable cases are cheaper paired than against the stored baseline`) +
870
+ (cfg.design === undefined
871
+ ? ` (the costs below price each case at its own design, two runs an observation where it is paired)`
872
+ : ` (the costs below are priced at two runs an observation, which is what pairing costs)`)
609
873
  : totals.screenedCostUsd < totals.bestCostUsd
610
874
  ? `screen first, then run the sequential test on what the screen keeps ` +
611
875
  `($${totals.screenedCostUsd.toFixed(2)} vs $${totals.bestCostUsd.toFixed(2)})`
@@ -699,10 +963,32 @@ export function affordabilityGrid(
699
963
  ): AffordabilityCell[] {
700
964
  requireProbability(baselineRate, "baselineRate", "affordabilityGrid");
701
965
  requireErrorRates(cfg.alpha, cfg.beta, "affordabilityGrid");
702
- // The typical bill below takes this quantile of the baseline posterior
703
- // straight from `betaQuantile`, which answers 0 or 1 for a quantile outside
704
- // (0,1) and about 0 for NaN instead of refusing. `makePlan` refuses it.
966
+ // `makePlan` refuses it, so one options object must not price here and fail there.
705
967
  requireOpenProbability(cfg.nullQuantile, "nullQuantile", "affordabilityGrid");
968
+ // makePlan refuses both; unchecked, an fdr of 2 priced a grid cheaper than one at 0.05.
969
+ requireOpenProbability(cfg.fdr, "fdr", "affordabilityGrid");
970
+ requireSettleConfidence(cfg, "affordabilityGrid");
971
+ requireTrialGrid(cfg, "affordabilityGrid");
972
+ if (!(cfg.costPerRunUsd >= 0)) {
973
+ throw new PeeksafeError(
974
+ "PEEKSAFE_E_CONFIG",
975
+ `affordabilityGrid: costPerRunUsd must be ≥ 0, got ${cfg.costPerRunUsd}`,
976
+ {
977
+ detail: { costPerRunUsd: cfg.costPerRunUsd },
978
+ },
979
+ );
980
+ }
981
+ for (const m of caseCounts) {
982
+ if (!Number.isInteger(m) || m < 1) {
983
+ throw new PeeksafeError(
984
+ "PEEKSAFE_E_CONFIG",
985
+ `affordabilityGrid: every case count must be a positive integer, got ${m}`,
986
+ {
987
+ detail: { cases: m },
988
+ },
989
+ );
990
+ }
991
+ }
706
992
  // Every other planner entry point refuses a case with no baseline: `planCase`
707
993
  // returns IMPOSSIBLE with "no baseline", `evaluatePoint` throws on
708
994
  // `baselineRuns < 1`, and `gate` throws PEEKSAFE_E_BASELINE_MISSING. This one
@@ -749,13 +1035,16 @@ export function affordabilityGrid(
749
1035
  // The ceiling above prices a pull request in which every case regressed.
750
1036
  // This prices one in which nothing did, which is what a pull request
751
1037
  // almost always is.
752
- const p0 = betaQuantile(1 + s, 1 + baselineTrials - s, cfg.nullQuantile);
753
- const p1 = Math.max(0.005, p0 - mde);
754
- const rawN = sprtExpectedN(baselineRate, p0, p1, cfg.alpha, cfg.beta);
1038
+ // Each design is counted under the rule that stops it, as `makePlan` does; Wald's SPRT figure ignores the coupling and the suite size.
755
1039
  const perCaseTypical =
756
- Number.isFinite(rawN) && rawN > 0
757
- ? Math.min(cfg.maxTrials, Math.max(cfg.minTrials, Math.ceil(rawN)))
758
- : cfg.maxTrials;
1040
+ runsPerObs === 2
1041
+ ? expectedSettlePairs(baselineRate, cfg.pairCoupling, mde, m, cfg)
1042
+ : expectedStopRuns(
1043
+ { caseId: "typical", successes: s, trials: baselineTrials },
1044
+ baselineRate,
1045
+ m,
1046
+ { ...cfg, mde },
1047
+ );
759
1048
  // Priced for the same design as the ceiling above (`runsPerObs`), or the
760
1049
  // cell quotes an unpaired price for a plan only pairing can execute.
761
1050
  const typicalRuns = Math.ceil(
package/src/rand.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * It is not a fine thing to do by accident, which is why the constants are
21
21
  * spelled out here rather than imported.
22
22
  */
23
- import { PeeksafeError } from "./errors.js";
23
+ import { PeeksafeError, requireProbability } from "./errors.js";
24
24
 
25
25
  export interface Rand {
26
26
  (): number;
@@ -75,11 +75,27 @@ export function hash32(s: string): number {
75
75
  }
76
76
 
77
77
  export function makeRand(seed: number | string): Rand {
78
- const s = typeof seed === "string" ? hash32(seed) : seed >>> 0;
79
- const base = lcg(s === 0 ? 1 : s);
78
+ // `>>> 0` folded NaN, Infinity, 1.5 and 2^32 onto 0, and the old 0-to-1 remap put seed 1 there too.
79
+ if (typeof seed === "number" && !(Number.isInteger(seed) && seed >= 0 && seed < 0x100000000)) {
80
+ throw new PeeksafeError(
81
+ "PEEKSAFE_E_STAT_DOMAIN",
82
+ `makeRand: a numeric seed must be an integer in [0, 2^32), got ${seed}`,
83
+ {
84
+ detail: { seed },
85
+ hint: "pass a string to key a stream by name; it is hashed with hash32",
86
+ },
87
+ );
88
+ }
89
+ const s = typeof seed === "string" ? hash32(seed) : seed;
90
+ // Hull-Dobell: an odd increment and a multiplier of 1 mod 4 give a full period from every seed, 0 included.
91
+ const base = lcg(s);
80
92
  const next = (() => mix32(Math.floor(base() * 0x100000000)) / 0x100000000) as Rand;
81
93
 
82
- next.bernoulli = (p: number) => next() < p;
94
+ // `next() < p` is false for every draw at NaN, so a rate that failed to compute simulated a case that never passes.
95
+ next.bernoulli = (p: number) => {
96
+ requireProbability(p, "p", "rand.bernoulli");
97
+ return next() < p;
98
+ };
83
99
 
84
100
  next.int = (n: number) => {
85
101
  if (!Number.isInteger(n) || n < 1) {