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/CHANGELOG.md +220 -1
- package/README.md +3 -5
- package/dist/cluster.d.ts.map +1 -1
- package/dist/cluster.js +8 -0
- package/dist/cluster.js.map +1 -1
- package/dist/errors.d.ts +24 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +52 -3
- package/dist/errors.js.map +1 -1
- package/dist/frontier.d.ts +9 -6
- package/dist/frontier.d.ts.map +1 -1
- package/dist/frontier.js +99 -55
- package/dist/frontier.js.map +1 -1
- package/dist/gate.d.ts +2 -0
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +5 -1
- package/dist/gate.js.map +1 -1
- package/dist/plan.d.ts +65 -17
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +289 -51
- package/dist/plan.js.map +1 -1
- package/dist/rand.d.ts.map +1 -1
- package/dist/rand.js +16 -4
- package/dist/rand.js.map +1 -1
- package/dist/stats.d.ts +7 -3
- package/dist/stats.d.ts.map +1 -1
- package/dist/stats.js +103 -45
- package/dist/stats.js.map +1 -1
- package/dist/stop.d.ts.map +1 -1
- package/dist/stop.js +12 -4
- package/dist/stop.js.map +1 -1
- package/package.json +10 -11
- package/src/cluster.ts +12 -0
- package/src/errors.ts +70 -3
- package/src/frontier.ts +131 -71
- package/src/gate.ts +6 -1
- package/src/plan.ts +355 -66
- package/src/rand.ts +20 -4
- package/src/stats.ts +134 -51
- package/src/stop.ts +16 -4
package/src/plan.ts
CHANGED
|
@@ -32,15 +32,18 @@
|
|
|
32
32
|
* it.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import {
|
|
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
|
|
78
|
-
* runs,
|
|
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`
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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,
|
|
416
|
-
* expected
|
|
417
|
-
* the gate's own
|
|
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
|
|
423
|
-
*
|
|
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",
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
533
|
-
//
|
|
534
|
-
|
|
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 &&
|
|
538
|
-
|
|
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 =
|
|
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
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
608
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
757
|
-
?
|
|
758
|
-
:
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
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) {
|