peeksafe 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -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 +5 -3
- package/dist/frontier.d.ts.map +1 -1
- package/dist/frontier.js +78 -33
- 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 +42 -10
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +176 -32
- 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 +101 -42
- package/dist/stats.js.map +1 -1
- package/dist/stop.d.ts +1 -0
- package/dist/stop.d.ts.map +1 -1
- package/dist/stop.js +13 -5
- 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 +102 -47
- package/src/gate.ts +6 -1
- package/src/plan.ts +236 -46
- package/src/rand.ts +20 -4
- package/src/stats.ts +132 -48
- package/src/stop.ts +17 -5
package/src/plan.ts
CHANGED
|
@@ -35,9 +35,13 @@
|
|
|
35
35
|
import { type BaselineStat, baselineNullRate, type 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
47
|
betaQuantile,
|
|
@@ -50,6 +54,7 @@ import {
|
|
|
50
54
|
samplesForEvidence,
|
|
51
55
|
sprtExpectedN,
|
|
52
56
|
} from "./stats.js";
|
|
57
|
+
import { DEFAULT_STOP_OPTIONS, settleLogE, shouldStop } from "./stop.js";
|
|
53
58
|
|
|
54
59
|
export interface PlanConfig {
|
|
55
60
|
/** the drop we want to be able to detect, in probability units (0.15 = 15 points) */
|
|
@@ -74,14 +79,14 @@ export interface PlanConfig {
|
|
|
74
79
|
/** beyond this many runs for one case we stop calling it "affordable" */
|
|
75
80
|
runsPerCaseCeiling: number;
|
|
76
81
|
/**
|
|
77
|
-
* Which design the *expected* bill
|
|
78
|
-
* runs,
|
|
82
|
+
* Which design the *expected* bill and the screening pass are priced for. A
|
|
83
|
+
* paired observation is two runs, and a paired case's observation count is
|
|
84
|
+
* the pairs `shouldStopPaired` takes to settle it at its coupling; an
|
|
85
|
+
* unpaired case's is the runs `shouldStop` takes to stop it.
|
|
79
86
|
*
|
|
80
|
-
* `undefined`
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* and quoting the *unpaired* bill underneath that recommendation would price
|
|
84
|
-
* a design nobody runs.
|
|
87
|
+
* `undefined` prices each case at its own `PlanCase.design`, and a case with
|
|
88
|
+
* none at the suite's `recommendedDesign`. Setting it prices every case at
|
|
89
|
+
* that one design.
|
|
85
90
|
*/
|
|
86
91
|
design?: "unpaired" | "paired";
|
|
87
92
|
|
|
@@ -96,6 +101,13 @@ export interface PlanConfig {
|
|
|
96
101
|
maxTrials: number;
|
|
97
102
|
batchSize: number;
|
|
98
103
|
nullQuantile: number;
|
|
104
|
+
/**
|
|
105
|
+
* The `futilityConfidence` you run `shouldStop` and `shouldStopPaired` at.
|
|
106
|
+
* The expected bill counts the runs or pairs a case takes to stop at this
|
|
107
|
+
* level, so a higher one prices more. `undefined` takes the stopping rules'
|
|
108
|
+
* own default.
|
|
109
|
+
*/
|
|
110
|
+
futilityConfidence?: number;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
export const DEFAULT_PLAN: PlanConfig = {
|
|
@@ -197,7 +209,11 @@ export interface PlanTotals {
|
|
|
197
209
|
* over-predicts what the gate actually spends by 5 to 8×, and the numbers below
|
|
198
210
|
* predict it to within about 10% when checked against real runs.
|
|
199
211
|
*/
|
|
200
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* What one observation of the expected bill costs in runs, averaged over its
|
|
214
|
+
* cases: 1 when every case runs unpaired, 2 when every case is paired, and in
|
|
215
|
+
* between for a suite that mixes the two.
|
|
216
|
+
*/
|
|
201
217
|
runsPerObservation: number;
|
|
202
218
|
/** expected *runs* (not observations) for a pull request in which nothing moved */
|
|
203
219
|
typicalRuns: number;
|
|
@@ -218,7 +234,13 @@ export interface Plan {
|
|
|
218
234
|
pairedOnly: PlanCase[];
|
|
219
235
|
/** cases whose baseline rate is too low to lose `mde` points at all */
|
|
220
236
|
impossible: PlanCase[];
|
|
221
|
-
/**
|
|
237
|
+
/**
|
|
238
|
+
* The design the recommendation names: paired when more than a quarter of
|
|
239
|
+
* the suite is `PAIRED_ONLY` or most decidable cases' own `design` is
|
|
240
|
+
* paired. `expectedCostUsd` prices each case at
|
|
241
|
+
* its own `design` and falls back to this one only for a case with none,
|
|
242
|
+
* unless `config.design` fixes one design for the whole suite.
|
|
243
|
+
*/
|
|
222
244
|
recommendedDesign: "unpaired" | "paired";
|
|
223
245
|
/** the cheapest design that decides every decidable case */
|
|
224
246
|
recommendation: string;
|
|
@@ -286,12 +308,28 @@ function baselineRunsToClear(
|
|
|
286
308
|
return hi;
|
|
287
309
|
}
|
|
288
310
|
|
|
311
|
+
function requirePrices(cfg: PlanConfig, where: string): void {
|
|
312
|
+
if (!(cfg.costPerRunUsd >= 0) || !(cfg.msPerRun >= 0)) {
|
|
313
|
+
throw new PeeksafeError(
|
|
314
|
+
"PEEKSAFE_E_CONFIG",
|
|
315
|
+
`${where}: costPerRunUsd and msPerRun must be ≥ 0`,
|
|
316
|
+
{
|
|
317
|
+
detail: { costPerRunUsd: cfg.costPerRunUsd, msPerRun: cfg.msPerRun },
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
289
323
|
export function planCase(
|
|
290
324
|
c: CaseRef,
|
|
291
325
|
b: BaselineStat | undefined,
|
|
292
326
|
cfg: PlanConfig,
|
|
293
327
|
m: number,
|
|
294
328
|
): PlanCase {
|
|
329
|
+
// 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.
|
|
330
|
+
if (b) requireCounts(b.successes, b.trials, `planCase(${c.id}).baseline`);
|
|
331
|
+
requireErrorRates(cfg.alpha, cfg.beta, "planCase");
|
|
332
|
+
requirePrices(cfg, "planCase");
|
|
295
333
|
const barLogE = Math.log(ebhSoloThreshold(m, cfg.fdr));
|
|
296
334
|
const trials = b?.trials ?? 0;
|
|
297
335
|
const successes = b?.successes ?? 0;
|
|
@@ -416,7 +454,8 @@ export function planCase(
|
|
|
416
454
|
* expected sample number at a supplied "true" rate per case, and clamped into
|
|
417
455
|
* the gate's own [minTrials, maxTrials] batch grid.
|
|
418
456
|
*
|
|
419
|
-
*
|
|
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).
|
|
420
459
|
* `samplesForEvidence`, the one behind `PlanCase.unpaired.runs`, answers a
|
|
421
460
|
* different question: how many runs it takes to *certify a regression*. A case
|
|
422
461
|
* that did not regress never gets there; it hits the H₀ wall in a few batches
|
|
@@ -432,6 +471,9 @@ export function expectedSequentialSamples(
|
|
|
432
471
|
trueRate: (caseId: string, b: BaselineStat) => number = (_id, b) =>
|
|
433
472
|
b.trials > 0 ? b.successes / b.trials : 0,
|
|
434
473
|
): { samples: number; perCase: Map<string, number> } {
|
|
474
|
+
requireTrialGrid(cfg, "expectedSequentialSamples");
|
|
475
|
+
// A repeated id was added to `samples` once per listing but kept once in `perCase`, so the two disagreed.
|
|
476
|
+
requireUniqueCaseIds(cases, "expectedSequentialSamples");
|
|
435
477
|
const perCase = new Map<string, number>();
|
|
436
478
|
let total = 0;
|
|
437
479
|
for (const c of cases) {
|
|
@@ -441,21 +483,112 @@ export function expectedSequentialSamples(
|
|
|
441
483
|
const p1 = Math.max(0.005, p0 - cfg.mde);
|
|
442
484
|
const p = Math.max(0, Math.min(1, trueRate(c.id, b)));
|
|
443
485
|
const raw = sprtExpectedN(p, p0, p1, cfg.alpha, cfg.beta);
|
|
444
|
-
const
|
|
445
|
-
Number.isFinite(raw) && raw > 0
|
|
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);
|
|
486
|
+
const capped = onBatchGrid(
|
|
487
|
+
Number.isFinite(raw) && raw > 0 ? Math.ceil(raw) : cfg.maxTrials,
|
|
488
|
+
cfg,
|
|
489
|
+
);
|
|
453
490
|
perCase.set(c.id, capped);
|
|
454
491
|
total += capped;
|
|
455
492
|
}
|
|
456
493
|
return { samples: total, perCase };
|
|
457
494
|
}
|
|
458
495
|
|
|
496
|
+
type TrialGrid = Pick<PlanConfig, "minTrials" | "maxTrials" | "batchSize">;
|
|
497
|
+
|
|
498
|
+
/** Refuses a set `futilityConfidence` outside (0, 1), where the settle bar is 0 or Infinity. */
|
|
499
|
+
export function requireSettleConfidence(
|
|
500
|
+
cfg: Pick<PlanConfig, "futilityConfidence">,
|
|
501
|
+
where: string,
|
|
502
|
+
): void {
|
|
503
|
+
if (cfg.futilityConfidence !== undefined) {
|
|
504
|
+
requireOpenProbability(cfg.futilityConfidence, "futilityConfidence", where);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
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
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
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.
|
|
524
|
+
*/
|
|
525
|
+
export function expectedSettlePairs(
|
|
526
|
+
rate: number,
|
|
527
|
+
rho: number,
|
|
528
|
+
mde: number,
|
|
529
|
+
cfg: TrialGrid & Pick<PlanConfig, "futilityConfidence">,
|
|
530
|
+
): number {
|
|
531
|
+
// Unmoved arms disagree each way only when uncoupled: (1 - rho) p (1 - p).
|
|
532
|
+
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);
|
|
538
|
+
}
|
|
539
|
+
return cfg.maxTrials;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Expected runs `shouldStop` takes to stop an unpaired case whose candidate
|
|
544
|
+
* passes at `rate`, checked at `minTrials` and then every `batchSize` runs up
|
|
545
|
+
* to `maxTrials`, in a suite of `suiteSize`. Exact rather than simulated:
|
|
546
|
+
* the decision depends on the candidate only through its counts, so a dynamic
|
|
547
|
+
* program carries the distribution of the success count from one check to the
|
|
548
|
+
* next. Rounded up to a whole run; an expectation is not a grid point.
|
|
549
|
+
*/
|
|
550
|
+
function expectedStopRuns(
|
|
551
|
+
baseline: BaselineStat,
|
|
552
|
+
rate: number,
|
|
553
|
+
suiteSize: number,
|
|
554
|
+
cfg: TrialGrid & Pick<PlanConfig, "mde" | "fdr" | "futilityConfidence">,
|
|
555
|
+
): number {
|
|
556
|
+
const options = {
|
|
557
|
+
suiteSize,
|
|
558
|
+
mde: cfg.mde,
|
|
559
|
+
fdr: cfg.fdr,
|
|
560
|
+
futilityConfidence: cfg.futilityConfidence,
|
|
561
|
+
};
|
|
562
|
+
// live[k]: probability of k successes in n runs with the case still running.
|
|
563
|
+
let live = new Float64Array([1]);
|
|
564
|
+
let n = 0;
|
|
565
|
+
let expected = 0;
|
|
566
|
+
for (let step = cfg.minTrials; ; step = Math.min(cfg.batchSize, cfg.maxTrials - n)) {
|
|
567
|
+
for (let i = 0; i < step; i++) {
|
|
568
|
+
const next = new Float64Array(live.length + 1);
|
|
569
|
+
for (let k = 0; k < live.length; k++) {
|
|
570
|
+
next[k]! += live[k]! * (1 - rate);
|
|
571
|
+
next[k + 1]! += live[k]! * rate;
|
|
572
|
+
}
|
|
573
|
+
live = next;
|
|
574
|
+
}
|
|
575
|
+
n += step;
|
|
576
|
+
let running = 0;
|
|
577
|
+
for (let k = 0; k <= n; k++) {
|
|
578
|
+
if (live[k] === 0) continue;
|
|
579
|
+
if (n >= cfg.maxTrials || shouldStop({ successes: k, trials: n }, baseline, options).stop) {
|
|
580
|
+
expected += live[k]! * n;
|
|
581
|
+
live[k] = 0;
|
|
582
|
+
} else {
|
|
583
|
+
running += live[k]!;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (running === 0) break;
|
|
587
|
+
}
|
|
588
|
+
// Float residue must not push an exact whole number up a run.
|
|
589
|
+
return Math.ceil(expected - 1e-9);
|
|
590
|
+
}
|
|
591
|
+
|
|
459
592
|
/**
|
|
460
593
|
* Plan a suite. `pilots` holds a pair table per case from a pilot run of the
|
|
461
594
|
* paired design, ideally with both arms on the same revision; a case with one
|
|
@@ -485,20 +618,17 @@ export function makePlan(
|
|
|
485
618
|
// reaches it, and under that function's name for it, `q`. Checked here so
|
|
486
619
|
// every planner entry point refuses it the same way and names it.
|
|
487
620
|
requireOpenProbability(cfg.nullQuantile, "nullQuantile", "makePlan");
|
|
621
|
+
requireSettleConfidence(cfg, "makePlan");
|
|
622
|
+
requireRunCeiling(cfg.runsPerCaseCeiling, "makePlan");
|
|
623
|
+
requireTrialGrid(cfg, "makePlan");
|
|
488
624
|
if (cases.length === 0) {
|
|
489
625
|
throw new PeeksafeError("PEEKSAFE_E_SUITE_EMPTY", "makePlan: no cases to plan for", {
|
|
490
626
|
hint: "pass at least one case; makePlan prices a suite, not an empty list",
|
|
491
627
|
});
|
|
492
628
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
"makePlan: costPerRunUsd and msPerRun must be ≥ 0",
|
|
497
|
-
{
|
|
498
|
-
detail: { costPerRunUsd: cfg.costPerRunUsd, msPerRun: cfg.msPerRun },
|
|
499
|
-
},
|
|
500
|
-
);
|
|
501
|
-
}
|
|
629
|
+
// `gate` refuses a repeated id; planned, it was priced twice and counted twice in the suite size the bar is set from.
|
|
630
|
+
requireUniqueCaseIds(cases, "makePlan");
|
|
631
|
+
requirePrices(cfg, "makePlan");
|
|
502
632
|
|
|
503
633
|
const m = cases.length;
|
|
504
634
|
const planned = cases.map((c) => {
|
|
@@ -529,13 +659,18 @@ export function makePlan(
|
|
|
529
659
|
);
|
|
530
660
|
|
|
531
661
|
const pairedOnlyCount = planned.filter((p) => p.detectability === "PAIRED_ONLY").length;
|
|
532
|
-
|
|
533
|
-
//
|
|
534
|
-
|
|
662
|
+
const pairedDesignCount = decidableAny.filter((p) => p.design === "paired").length;
|
|
663
|
+
// The recommendation string reads these same two flags, so it cannot drift from the design it names.
|
|
664
|
+
const manyPairedOnly = pairedOnlyCount > cases.length * 0.25;
|
|
665
|
+
const mostlyPaired = pairedDesignCount * 2 > decidableAny.length;
|
|
535
666
|
const recommendedDesign: "unpaired" | "paired" =
|
|
536
667
|
cfg.design ??
|
|
537
|
-
(decidableAny.length > 0 &&
|
|
538
|
-
|
|
668
|
+
(decidableAny.length > 0 && (manyPairedOnly || mostlyPaired) ? "paired" : "unpaired");
|
|
669
|
+
// Per case, like `bestRuns`: one suite-wide design billed a mixed suite as if every case ran it.
|
|
670
|
+
const runsPerObs = (p: PlanCase) =>
|
|
671
|
+
(cfg.design ?? p.design ?? recommendedDesign) === "paired" ? 2 : 1;
|
|
672
|
+
// `screenRuns` counts observations, so each case's pass costs its own design's runs.
|
|
673
|
+
const screenPassRuns = planned.reduce((a, p) => a + cfg.screenRuns * runsPerObs(p), 0);
|
|
539
674
|
|
|
540
675
|
// Screening: everything gets `screenRuns`; only the cases the screen ranks as
|
|
541
676
|
// plausibly moved go on to the sequential test. A clean PR moves nothing, so
|
|
@@ -550,21 +685,45 @@ export function makePlan(
|
|
|
550
685
|
// which is `pairs * 2`. The expected total below reads `screenRuns` the same
|
|
551
686
|
// way, and when this one did not, the two screened figures in `totals`
|
|
552
687
|
// disagreed about the price of the same pass.
|
|
553
|
-
const screenedRuns =
|
|
688
|
+
const screenedRuns = screenPassRuns + screenSelected * meanBest;
|
|
554
689
|
|
|
555
690
|
const fixedNRuns = planned.reduce((a, p) => a + finite(p.fixedNPerArm), 0);
|
|
556
691
|
|
|
557
692
|
// The expected cost of a pull request that changed nothing, which is what a
|
|
558
693
|
// pull request almost always is, and therefore the number a budget should be
|
|
559
694
|
// set from.
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
695
|
+
// Both counts are *observations*. A paired observation is two runs, the
|
|
696
|
+
// 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
|
+
};
|
|
715
|
+
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);
|
|
718
|
+
const runsPerObservation =
|
|
719
|
+
typicalObservations > 0
|
|
720
|
+
? typicalRuns / typicalObservations
|
|
721
|
+
: recommendedDesign === "paired"
|
|
722
|
+
? 2
|
|
723
|
+
: 1;
|
|
724
|
+
const meanTypical = priced > 0 ? typicalRuns / priced : 0;
|
|
566
725
|
const typicalScreenedRuns = Math.ceil(
|
|
567
|
-
|
|
726
|
+
screenPassRuns + Math.ceil(m * screenContinueFraction) * meanTypical,
|
|
568
727
|
);
|
|
569
728
|
|
|
570
729
|
const totals: PlanTotals = {
|
|
@@ -604,8 +763,12 @@ export function makePlan(
|
|
|
604
763
|
decidableAny.length === 0
|
|
605
764
|
? `nothing is decidable at ${(cfg.mde * 100).toFixed(0)} points against this baseline, record more baseline runs or raise the MDE`
|
|
606
765
|
: recommendedDesign === "paired"
|
|
607
|
-
?
|
|
608
|
-
|
|
766
|
+
? (manyPairedOnly
|
|
767
|
+
? `pair the runs: ${pairedOnly.length}/${m} cases have an unpaired evidence ceiling under the bar, so no candidate budget can decide them`
|
|
768
|
+
: `pair the runs: ${pairedDesignCount}/${decidableAny.length} decidable cases are cheaper paired than against the stored baseline`) +
|
|
769
|
+
(cfg.design === undefined
|
|
770
|
+
? ` (the costs below price each case at its own design, two runs an observation where it is paired)`
|
|
771
|
+
: ` (the costs below are priced at two runs an observation, which is what pairing costs)`)
|
|
609
772
|
: totals.screenedCostUsd < totals.bestCostUsd
|
|
610
773
|
? `screen first, then run the sequential test on what the screen keeps ` +
|
|
611
774
|
`($${totals.screenedCostUsd.toFixed(2)} vs $${totals.bestCostUsd.toFixed(2)})`
|
|
@@ -703,6 +866,30 @@ export function affordabilityGrid(
|
|
|
703
866
|
// straight from `betaQuantile`, which answers 0 or 1 for a quantile outside
|
|
704
867
|
// (0,1) and about 0 for NaN instead of refusing. `makePlan` refuses it.
|
|
705
868
|
requireOpenProbability(cfg.nullQuantile, "nullQuantile", "affordabilityGrid");
|
|
869
|
+
// makePlan refuses both; unchecked, an fdr of 2 priced a grid cheaper than one at 0.05.
|
|
870
|
+
requireOpenProbability(cfg.fdr, "fdr", "affordabilityGrid");
|
|
871
|
+
requireSettleConfidence(cfg, "affordabilityGrid");
|
|
872
|
+
requireTrialGrid(cfg, "affordabilityGrid");
|
|
873
|
+
if (!(cfg.costPerRunUsd >= 0)) {
|
|
874
|
+
throw new PeeksafeError(
|
|
875
|
+
"PEEKSAFE_E_CONFIG",
|
|
876
|
+
`affordabilityGrid: costPerRunUsd must be ≥ 0, got ${cfg.costPerRunUsd}`,
|
|
877
|
+
{
|
|
878
|
+
detail: { costPerRunUsd: cfg.costPerRunUsd },
|
|
879
|
+
},
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
for (const m of caseCounts) {
|
|
883
|
+
if (!Number.isInteger(m) || m < 1) {
|
|
884
|
+
throw new PeeksafeError(
|
|
885
|
+
"PEEKSAFE_E_CONFIG",
|
|
886
|
+
`affordabilityGrid: every case count must be a positive integer, got ${m}`,
|
|
887
|
+
{
|
|
888
|
+
detail: { cases: m },
|
|
889
|
+
},
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
706
893
|
// Every other planner entry point refuses a case with no baseline: `planCase`
|
|
707
894
|
// returns IMPOSSIBLE with "no baseline", `evaluatePoint` throws on
|
|
708
895
|
// `baselineRuns < 1`, and `gate` throws PEEKSAFE_E_BASELINE_MISSING. This one
|
|
@@ -752,10 +939,13 @@ export function affordabilityGrid(
|
|
|
752
939
|
const p0 = betaQuantile(1 + s, 1 + baselineTrials - s, cfg.nullQuantile);
|
|
753
940
|
const p1 = Math.max(0.005, p0 - mde);
|
|
754
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.
|
|
755
943
|
const perCaseTypical =
|
|
756
|
-
|
|
757
|
-
?
|
|
758
|
-
:
|
|
944
|
+
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;
|
|
759
949
|
// Priced for the same design as the ceiling above (`runsPerObs`), or the
|
|
760
950
|
// cell quotes an unpaired price for a plan only pairing can execute.
|
|
761
951
|
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) {
|