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