peeksafe 0.2.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,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) */
@@ -169,6 +167,13 @@ export interface PlanCase {
169
167
  * "undetectable" verdict.
170
168
  */
171
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;
172
177
  /** what a fixed-N two-proportion design would spend per arm, for comparison */
173
178
  fixedNPerArm: number;
174
179
  reason: string;
@@ -330,6 +335,9 @@ export function planCase(
330
335
  if (b) requireCounts(b.successes, b.trials, `planCase(${c.id}).baseline`);
331
336
  requireErrorRates(cfg.alpha, cfg.beta, "planCase");
332
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");
333
341
  const barLogE = Math.log(ebhSoloThreshold(m, cfg.fdr));
334
342
  const trials = b?.trials ?? 0;
335
343
  const successes = b?.successes ?? 0;
@@ -357,6 +365,7 @@ export function planCase(
357
365
  detectability: "IMPOSSIBLE",
358
366
  design: null,
359
367
  baselineRunsNeeded: null,
368
+ typicalRuns: 0,
360
369
  reason: "no baseline: nothing to compare against, record one first",
361
370
  };
362
371
  }
@@ -370,6 +379,8 @@ export function planCase(
370
379
  detectability: "IMPOSSIBLE",
371
380
  design: null,
372
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),
373
384
  reason:
374
385
  `baseline rate ${(rate * 100).toFixed(0)}% cannot lose ${(cfg.mde * 100).toFixed(0)} points, ` +
375
386
  `the effect is arithmetically impossible, not merely expensive`,
@@ -445,24 +456,40 @@ export function planCase(
445
456
  detectability,
446
457
  design,
447
458
  baselineRunsNeeded,
459
+ typicalRuns: typicalCaseRuns(b, cfg.design ?? design ?? "unpaired", cfg, m),
448
460
  reason,
449
461
  };
450
462
  }
451
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
+
452
478
  /**
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.
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`.
456
484
  *
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
485
  * `samplesForEvidence`, the one behind `PlanCase.unpaired.runs`, answers a
460
486
  * 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
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
463
489
  * bill.
464
490
  *
465
- * `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.
466
493
  */
467
494
  export function expectedSequentialSamples(
468
495
  cases: Array<CaseRef>,
@@ -472,6 +499,10 @@ export function expectedSequentialSamples(
472
499
  b.trials > 0 ? b.successes / b.trials : 0,
473
500
  ): { samples: number; perCase: Map<string, number> } {
474
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");
475
506
  // A repeated id was added to `samples` once per listing but kept once in `perCase`, so the two disagreed.
476
507
  requireUniqueCaseIds(cases, "expectedSequentialSamples");
477
508
  const perCase = new Map<string, number>();
@@ -479,16 +510,12 @@ export function expectedSequentialSamples(
479
510
  for (const c of cases) {
480
511
  const b = baseline.get(c.id);
481
512
  if (!b || b.trials === 0) continue;
482
- const p0 = baselineNullRate(b, cfg.nullQuantile);
483
- const p1 = Math.max(0.005, p0 - cfg.mde);
484
513
  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;
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;
492
519
  }
493
520
  return { samples: total, perCase };
494
521
  }
@@ -505,38 +532,101 @@ export function requireSettleConfidence(
505
532
  }
506
533
  }
507
534
 
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
- }
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>();
515
539
 
516
540
  /**
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.
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.
524
547
  */
525
548
  export function expectedSettlePairs(
526
549
  rate: number,
527
550
  rho: number,
528
551
  mde: number,
529
- cfg: TrialGrid & Pick<PlanConfig, "futilityConfidence">,
552
+ suiteSize: number,
553
+ cfg: TrialGrid & Pick<PlanConfig, "fdr" | "futilityConfidence">,
530
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
+ };
531
574
  // Unmoved arms disagree each way only when uncoupled: (1 - rho) p (1 - p).
532
575
  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);
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;
538
624
  }
539
- return cfg.maxTrials;
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;
540
630
  }
541
631
 
542
632
  /**
@@ -547,12 +637,26 @@ export function expectedSettlePairs(
547
637
  * program carries the distribution of the success count from one check to the
548
638
  * next. Rounded up to a whole run; an expectation is not a grid point.
549
639
  */
550
- function expectedStopRuns(
640
+ export function expectedStopRuns(
551
641
  baseline: BaselineStat,
552
642
  rate: number,
553
643
  suiteSize: number,
554
644
  cfg: TrialGrid & Pick<PlanConfig, "mde" | "fdr" | "futilityConfidence">,
555
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;
556
660
  const options = {
557
661
  suiteSize,
558
662
  mde: cfg.mde,
@@ -586,7 +690,10 @@ function expectedStopRuns(
586
690
  if (running === 0) break;
587
691
  }
588
692
  // Float residue must not push an exact whole number up a run.
589
- return Math.ceil(expected - 1e-9);
693
+ const runs = Math.ceil(expected - 1e-9);
694
+ if (stopRunsMemo.size >= 1024) stopRunsMemo.clear();
695
+ stopRunsMemo.set(key, runs);
696
+ return runs;
590
697
  }
591
698
 
592
699
  /**
@@ -669,6 +776,13 @@ export function makePlan(
669
776
  // Per case, like `bestRuns`: one suite-wide design billed a mixed suite as if every case ran it.
670
777
  const runsPerObs = (p: PlanCase) =>
671
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
+ }
672
786
  // `screenRuns` counts observations, so each case's pass costs its own design's runs.
673
787
  const screenPassRuns = planned.reduce((a, p) => a + cfg.screenRuns * runsPerObs(p), 0);
674
788
 
@@ -694,27 +808,14 @@ export function makePlan(
694
808
  // set from.
695
809
  // Both counts are *observations*. A paired observation is two runs, the
696
810
  // 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
- };
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.
715
816
  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);
817
+ const typicalObservations = planned.reduce((a, p) => a + p.typicalRuns / runsPerObs(p), 0);
818
+ const typicalRuns = planned.reduce((a, p) => a + p.typicalRuns, 0);
718
819
  const runsPerObservation =
719
820
  typicalObservations > 0
720
821
  ? typicalRuns / typicalObservations
@@ -862,9 +963,7 @@ export function affordabilityGrid(
862
963
  ): AffordabilityCell[] {
863
964
  requireProbability(baselineRate, "baselineRate", "affordabilityGrid");
864
965
  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.
966
+ // `makePlan` refuses it, so one options object must not price here and fail there.
868
967
  requireOpenProbability(cfg.nullQuantile, "nullQuantile", "affordabilityGrid");
869
968
  // makePlan refuses both; unchecked, an fdr of 2 priced a grid cheaper than one at 0.05.
870
969
  requireOpenProbability(cfg.fdr, "fdr", "affordabilityGrid");
@@ -936,16 +1035,16 @@ export function affordabilityGrid(
936
1035
  // The ceiling above prices a pull request in which every case regressed.
937
1036
  // This prices one in which nothing did, which is what a pull request
938
1037
  // 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.
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.
943
1039
  const perCaseTypical =
944
1040
  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;
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
+ );
949
1048
  // Priced for the same design as the ceiling above (`runsPerObs`), or the
950
1049
  // cell quotes an unpaired price for a plan only pairing can execute.
951
1050
  const typicalRuns = Math.ceil(
package/src/stats.ts CHANGED
@@ -729,9 +729,8 @@ export function sprtExpectedN(p: number, p0: number, p1: number, alpha = 0.05, b
729
729
  * outside the two hypotheses, so the two powers are evaluated relative to
730
730
  * whichever of them is largest. Written as plain `exp`, a tilt of −662, which is
731
731
  * p = 0.0025 against p₀ = 0.999 and p₁ = 0.99, overflows `B^h` to Infinity and
732
- * the ratio comes back NaN; `expectedSequentialSamples` and
733
- * `typicalObservationsPerCase` both test the result for `> 0`, so a NaN there
734
- * silently prices the case at `maxTrials`.
732
+ * the ratio comes back NaN, which a caller testing the result for `> 0` reads
733
+ * as no estimate at all.
735
734
  */
736
735
  function sprtAcceptH1(h: number, A: number, B: number, drift: number): number {
737
736
  // No tilt exists at p = 0 or p = 1: one outcome is impossible, so the drift
package/src/stop.ts CHANGED
@@ -449,7 +449,7 @@ export function shouldStopPaired(
449
449
  }
450
450
 
451
451
  // Mean of prod(1 - lambda (D - mde)) over fixed bets, D = +1 worse, -1 better; a supermartingale whenever E[D] >= mde.
452
- export function settleLogE(c: PairedCounts, mde: number): number {
452
+ function settleLogE(c: PairedCounts, mde: number): number {
453
453
  const terms: number[] = [];
454
454
  for (let k = 1; k <= 9; k++) {
455
455
  const lambda = k / 10 / (1 - mde);