peeksafe 0.0.0-stage → 0.1.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 +146 -0
- package/LICENSE +190 -0
- package/NOTICE +78 -0
- package/README.md +492 -2
- package/RESEARCH-NOTES.md +274 -0
- package/dist/baseline.d.ts +45 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +49 -0
- package/dist/baseline.js.map +1 -0
- package/dist/check.d.ts +64 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +172 -0
- package/dist/check.js.map +1 -0
- package/dist/cluster.d.ts +328 -0
- package/dist/cluster.d.ts.map +1 -0
- package/dist/cluster.js +598 -0
- package/dist/cluster.js.map +1 -0
- package/dist/errors.d.ts +87 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +143 -0
- package/dist/errors.js.map +1 -0
- package/dist/evidence.d.ts +15 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +30 -0
- package/dist/evidence.js.map +1 -0
- package/dist/frontier.d.ts +269 -0
- package/dist/frontier.d.ts.map +1 -0
- package/dist/frontier.js +460 -0
- package/dist/frontier.js.map +1 -0
- package/dist/gate.d.ts +173 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +196 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +79 -0
- package/dist/index.js.map +1 -0
- package/dist/plan.d.ts +248 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +482 -0
- package/dist/plan.js.map +1 -0
- package/dist/rand.d.ts +17 -0
- package/dist/rand.d.ts.map +1 -0
- package/dist/rand.js +92 -0
- package/dist/rand.js.map +1 -0
- package/dist/stats.d.ts +397 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +1245 -0
- package/dist/stats.js.map +1 -0
- package/dist/stop.d.ts +163 -0
- package/dist/stop.d.ts.map +1 -0
- package/dist/stop.js +262 -0
- package/dist/stop.js.map +1 -0
- package/package.json +64 -4
- package/src/baseline.ts +72 -0
- package/src/check.ts +238 -0
- package/src/cluster.ts +830 -0
- package/src/errors.ts +208 -0
- package/src/evidence.ts +62 -0
- package/src/frontier.ts +782 -0
- package/src/gate.ts +358 -0
- package/src/index.ts +206 -0
- package/src/plan.ts +778 -0
- package/src/rand.ts +121 -0
- package/src/stats.ts +1579 -0
- package/src/stop.ts +452 -0
package/src/stop.ts
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stop.ts: should this case keep running?
|
|
3
|
+
*
|
|
4
|
+
* `gate` decides a whole suite from final counts. This decides one case, now,
|
|
5
|
+
* from the counts you have so far: the loop every caller of this library ends
|
|
6
|
+
* up writing, with the two ways of finishing early that are actually valid.
|
|
7
|
+
*
|
|
8
|
+
* ## Why there are two
|
|
9
|
+
*
|
|
10
|
+
* An e-value accumulates evidence *for* a regression, so a broken case reaches
|
|
11
|
+
* the bar quickly and stops as `regressed`. On its own that gives you nothing
|
|
12
|
+
* for a healthy case, which never accumulates evidence that it is healthy.
|
|
13
|
+
*
|
|
14
|
+
* The ceiling is what supplies the rest, and it is this package's own: the
|
|
15
|
+
* unpaired e-value has a finite limit set by how well the baseline was
|
|
16
|
+
* measured. Once that limit is under the bar, no number of further runs can
|
|
17
|
+
* certify this case, and continuing spends money on an outcome that cannot
|
|
18
|
+
* happen. Two different facts produce that, and they need different names:
|
|
19
|
+
*
|
|
20
|
+
* `futile` the BASELINE is too thin: an `mde`-sized drop could not be
|
|
21
|
+
* certified here however long you ran. Get more baseline runs.
|
|
22
|
+
* `settled` the baseline is fine and the CASE is not bad enough. The counts
|
|
23
|
+
* have ruled out a certifiable regression. This is a pass, and it
|
|
24
|
+
* is the early exit a healthy case would otherwise never get.
|
|
25
|
+
*
|
|
26
|
+
* ## Futility is deliberately hard to trigger
|
|
27
|
+
*
|
|
28
|
+
* The ceiling depends on how bad the regression actually is, and early on that
|
|
29
|
+
* is barely known: for a 60-run baseline the ceiling ranges from 2.9 at a rate
|
|
30
|
+
* of 0.75 to 62 at a rate of 0.20. Declaring futility off a noisy point
|
|
31
|
+
* estimate would abandon cases that were about to be certified: a missed
|
|
32
|
+
* regression, reported as a green check, which is the exact failure this
|
|
33
|
+
* library exists to prevent.
|
|
34
|
+
*
|
|
35
|
+
* So futility is judged at the **most pessimistic rate the data still permits**
|
|
36
|
+
* (the lower end of a Wilson interval on the candidate). The question it asks
|
|
37
|
+
* is "even if the truth is as bad as these counts plausibly allow, could more
|
|
38
|
+
* runs ever clear the bar?" Only a no stops the case. The cost of that choice
|
|
39
|
+
* is that futility fires late and rarely; the benefit is that it cannot fire on
|
|
40
|
+
* a case that was going to be caught. A catastrophic regression is never
|
|
41
|
+
* futile, at any sample size, which `test/stop.test.ts` pins.
|
|
42
|
+
*
|
|
43
|
+
* If you want to know a case is undetectable *before* spending anything, that
|
|
44
|
+
* is `makePlan`'s job, and `PlanCase.baselineRunsNeeded` tells you what to do
|
|
45
|
+
* about it. This is the safety net, not the plan.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import type { BaselineStat } from "./baseline.js";
|
|
49
|
+
import {
|
|
50
|
+
PeeksafeError,
|
|
51
|
+
requireAltConcentration,
|
|
52
|
+
requireCounts,
|
|
53
|
+
requireOpenProbability,
|
|
54
|
+
requirePairedCounts,
|
|
55
|
+
} from "./errors.js";
|
|
56
|
+
import {
|
|
57
|
+
ceilingLogEvidence,
|
|
58
|
+
type Evidence,
|
|
59
|
+
logEvidence,
|
|
60
|
+
requireEvidence,
|
|
61
|
+
toEvalue,
|
|
62
|
+
} from "./evidence.js";
|
|
63
|
+
import { cannotDropBy, DEFAULT_GATE_OPTIONS } from "./gate.js";
|
|
64
|
+
import {
|
|
65
|
+
discordant,
|
|
66
|
+
ebhSoloThreshold,
|
|
67
|
+
normalQuantile,
|
|
68
|
+
type PairedCounts,
|
|
69
|
+
pairedLogE,
|
|
70
|
+
wilsonInterval,
|
|
71
|
+
} from "./stats.js";
|
|
72
|
+
|
|
73
|
+
export type StopReason =
|
|
74
|
+
/** certified on its own evidence: the e-value cleared `m / fdr` */
|
|
75
|
+
| "regressed"
|
|
76
|
+
/**
|
|
77
|
+
* The case is not regressing hard enough to ever be certified, and the data
|
|
78
|
+
* is now good enough to say so. Good news, and an early exit for a healthy
|
|
79
|
+
* case, which an e-value alone cannot give you.
|
|
80
|
+
*/
|
|
81
|
+
| "settled"
|
|
82
|
+
/**
|
|
83
|
+
* The baseline is too thin to certify an `mde`-sized drop at any candidate
|
|
84
|
+
* budget. Nothing about this run can fix that; more baseline runs can.
|
|
85
|
+
*/
|
|
86
|
+
| "futile"
|
|
87
|
+
/**
|
|
88
|
+
* The baseline passes at or below `mde`, so an `mde`-sized drop cannot
|
|
89
|
+
* happen and the case is not tested. `gate` fixes its e-value at 1.
|
|
90
|
+
*/
|
|
91
|
+
| "impossible"
|
|
92
|
+
/** `maxTrials` reached without a decision */
|
|
93
|
+
| "budget"
|
|
94
|
+
/** keep running */
|
|
95
|
+
| "continue";
|
|
96
|
+
|
|
97
|
+
export interface StopDecision {
|
|
98
|
+
stop: boolean;
|
|
99
|
+
reason: StopReason;
|
|
100
|
+
/** evidence so far. Larger is more evidence against the null. */
|
|
101
|
+
evalue: number;
|
|
102
|
+
logE: number;
|
|
103
|
+
/** what this case must reach on its own evidence, whatever the others do */
|
|
104
|
+
bar: number;
|
|
105
|
+
/**
|
|
106
|
+
* The most evidence this case could ever produce, judged at the most
|
|
107
|
+
* pessimistic rate the counts still permit. Below `bar` it stops the case,
|
|
108
|
+
* as `futile` when the baseline is what caps it and `settled` when the
|
|
109
|
+
* case's own counts are.
|
|
110
|
+
*/
|
|
111
|
+
ceiling: number;
|
|
112
|
+
trials: number;
|
|
113
|
+
/** one line saying why, suitable for a log */
|
|
114
|
+
detail: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface StopOptions {
|
|
118
|
+
/**
|
|
119
|
+
* How many cases are being gated. The bar a case must clear on its own
|
|
120
|
+
* evidence is `suiteSize / fdr`, so this has to be the size of the family
|
|
121
|
+
* you will pass to `gate`, not the number still running.
|
|
122
|
+
*/
|
|
123
|
+
suiteSize: number;
|
|
124
|
+
/** The drop, in rate, that counts as a regression. */
|
|
125
|
+
mde?: number;
|
|
126
|
+
/** Target false discovery rate across the suite. */
|
|
127
|
+
fdr?: number;
|
|
128
|
+
/** Stop at this many trials whatever the evidence says. */
|
|
129
|
+
maxTrials?: number;
|
|
130
|
+
altConcentration?: number;
|
|
131
|
+
/** Which e-value decides; pass the same one you pass to `gate`. */
|
|
132
|
+
evidence?: Evidence;
|
|
133
|
+
/**
|
|
134
|
+
* Confidence level for the pessimistic rate `futile` and `settled` are
|
|
135
|
+
* judged at, strictly between 0 and 1. Higher is more conservative:
|
|
136
|
+
* futility fires later and less often. The default of
|
|
137
|
+
* 0.95 is already conservative; lowering it trades a missed-regression risk
|
|
138
|
+
* for runs saved, and you should have a reason.
|
|
139
|
+
*/
|
|
140
|
+
futilityConfidence?: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const DEFAULT_STOP_OPTIONS = {
|
|
144
|
+
mde: DEFAULT_GATE_OPTIONS.mde,
|
|
145
|
+
fdr: DEFAULT_GATE_OPTIONS.fdr,
|
|
146
|
+
altConcentration: DEFAULT_GATE_OPTIONS.altConcentration,
|
|
147
|
+
evidence: DEFAULT_GATE_OPTIONS.evidence,
|
|
148
|
+
futilityConfidence: 0.95,
|
|
149
|
+
} as const;
|
|
150
|
+
|
|
151
|
+
/** z for a two-sided interval at the given confidence. 0.95 → 1.959964. */
|
|
152
|
+
const zFor = (confidence: number): number => {
|
|
153
|
+
if (!(confidence > 0 && confidence < 1)) {
|
|
154
|
+
throw new PeeksafeError(
|
|
155
|
+
"PEEKSAFE_E_CONFIG",
|
|
156
|
+
`shouldStop: futilityConfidence must be strictly between 0 and 1, got ${confidence}`,
|
|
157
|
+
{
|
|
158
|
+
detail: { futilityConfidence: confidence },
|
|
159
|
+
hint: "a two-sided confidence level such as 0.95; higher makes futility fire later",
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return normalQuantile(1 - (1 - confidence) / 2);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Decide whether to keep running one case.
|
|
168
|
+
*
|
|
169
|
+
* Call it after each batch. The guarantee `gate` gives does not depend on the
|
|
170
|
+
* rule you used to stop, so you may also ignore this entirely and stop on a
|
|
171
|
+
* hunch.
|
|
172
|
+
*
|
|
173
|
+
* ```ts
|
|
174
|
+
* const d = shouldStop(state, baseline[id], { suiteSize: ids.length, maxTrials: 120 });
|
|
175
|
+
* if (d.stop) { state.stopped = d.reason; log.info(d.detail); }
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
export function shouldStop(
|
|
179
|
+
observed: { successes: number; trials: number },
|
|
180
|
+
baseline: BaselineStat,
|
|
181
|
+
options: StopOptions,
|
|
182
|
+
): StopDecision {
|
|
183
|
+
const opts = { ...DEFAULT_STOP_OPTIONS, ...options };
|
|
184
|
+
requireCounts(observed.successes, observed.trials, "shouldStop");
|
|
185
|
+
requireCounts(baseline.successes, baseline.trials, "shouldStop.baseline");
|
|
186
|
+
requireOpenProbability(opts.mde, "mde", "shouldStop");
|
|
187
|
+
requireOpenProbability(opts.fdr, "fdr", "shouldStop");
|
|
188
|
+
requireEvidence(opts.evidence, "shouldStop");
|
|
189
|
+
requireAltConcentration(opts.altConcentration, opts.evidence, "shouldStop");
|
|
190
|
+
if (!Number.isInteger(opts.suiteSize) || opts.suiteSize < 1) {
|
|
191
|
+
throw new PeeksafeError(
|
|
192
|
+
"PEEKSAFE_E_CONFIG",
|
|
193
|
+
`shouldStop: suiteSize must be a positive integer, got ${opts.suiteSize}`,
|
|
194
|
+
{
|
|
195
|
+
detail: { suiteSize: opts.suiteSize },
|
|
196
|
+
hint: "this is the number of cases in the family you will pass to gate(), which sets the bar m / fdr",
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (opts.maxTrials !== undefined && (!Number.isInteger(opts.maxTrials) || opts.maxTrials < 1)) {
|
|
201
|
+
throw new PeeksafeError(
|
|
202
|
+
"PEEKSAFE_E_CONFIG",
|
|
203
|
+
`shouldStop: maxTrials must be a positive integer, got ${opts.maxTrials}`,
|
|
204
|
+
{
|
|
205
|
+
detail: { maxTrials: opts.maxTrials },
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (baseline.trials === 0) {
|
|
210
|
+
throw new PeeksafeError(
|
|
211
|
+
"PEEKSAFE_E_BASELINE_MISSING",
|
|
212
|
+
"shouldStop: this case has no baseline, so there is nothing to test against",
|
|
213
|
+
{
|
|
214
|
+
detail: { trials: observed.trials },
|
|
215
|
+
hint: "a case with no baseline is not gated at all; gate() returns it in newCases; do not run it against an invented null",
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Read before the early returns and before the `trials === 0` branch below.
|
|
221
|
+
// It used to be read only where the Wilson interval is taken, so the first
|
|
222
|
+
// call of a loop accepted a level that every later call threw on, and an
|
|
223
|
+
// `impossible` case never validated it at all.
|
|
224
|
+
const z = zFor(opts.futilityConfidence);
|
|
225
|
+
|
|
226
|
+
const bar = ebhSoloThreshold(opts.suiteSize, opts.fdr);
|
|
227
|
+
const logBar = Math.log(bar);
|
|
228
|
+
|
|
229
|
+
if (cannotDropBy(baseline, opts.mde)) {
|
|
230
|
+
return {
|
|
231
|
+
stop: true,
|
|
232
|
+
reason: "impossible",
|
|
233
|
+
evalue: 1,
|
|
234
|
+
logE: 0,
|
|
235
|
+
bar,
|
|
236
|
+
ceiling: 1,
|
|
237
|
+
trials: observed.trials,
|
|
238
|
+
detail:
|
|
239
|
+
`impossible: the baseline passes ${baseline.successes}/${baseline.trials}, which cannot drop by ` +
|
|
240
|
+
`${(opts.mde * 100).toFixed(0)}pts. Not tested; lower mde for this case if smaller drops matter.`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const logE = logEvidence(
|
|
245
|
+
opts.evidence,
|
|
246
|
+
observed.successes,
|
|
247
|
+
observed.trials,
|
|
248
|
+
baseline.successes,
|
|
249
|
+
baseline.trials,
|
|
250
|
+
opts.mde,
|
|
251
|
+
opts.altConcentration,
|
|
252
|
+
);
|
|
253
|
+
const evalue = toEvalue(logE);
|
|
254
|
+
|
|
255
|
+
// The most pessimistic rate the counts still permit. With no trials the
|
|
256
|
+
// Wilson interval is the whole line, so `low` is 0, the ceiling is enormous,
|
|
257
|
+
// and futility cannot fire before there is data. That is the intended
|
|
258
|
+
// behaviour: futility is a statement about evidence, not about intentions.
|
|
259
|
+
const low =
|
|
260
|
+
observed.trials === 0 ? 0 : wilsonInterval(observed.successes, observed.trials, z).low;
|
|
261
|
+
const pessimistic = Math.max(1e-6, Math.min(1 - 1e-6, low));
|
|
262
|
+
const ceilingLog = ceilingLogEvidence(
|
|
263
|
+
opts.evidence,
|
|
264
|
+
pessimistic,
|
|
265
|
+
baseline.successes,
|
|
266
|
+
baseline.trials,
|
|
267
|
+
opts.mde,
|
|
268
|
+
opts.altConcentration,
|
|
269
|
+
);
|
|
270
|
+
const ceiling = Math.exp(ceilingLog);
|
|
271
|
+
|
|
272
|
+
const base = { evalue, logE, bar, ceiling, trials: observed.trials };
|
|
273
|
+
|
|
274
|
+
if (logE >= logBar) {
|
|
275
|
+
return {
|
|
276
|
+
...base,
|
|
277
|
+
stop: true,
|
|
278
|
+
reason: "regressed",
|
|
279
|
+
detail: `certified: e=${evalue.toExponential(2)} cleared the bar of ${bar} after ${observed.trials} trials`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (ceilingLog < logBar) {
|
|
283
|
+
// Two very different facts wear the same shape here, and an operator needs
|
|
284
|
+
// to be told which one. Ask the baseline-only question: could an
|
|
285
|
+
// *mde*-sized drop be certified against this baseline at all? If not, the
|
|
286
|
+
// baseline is the problem and no run of any length fixes it. If it could,
|
|
287
|
+
// then the baseline is fine and what stopped this case is that its own
|
|
288
|
+
// counts are not bad enough, which is a pass, not a defect.
|
|
289
|
+
const bRate = baseline.successes / baseline.trials;
|
|
290
|
+
const pAlt = Math.max(1e-6, Math.min(1 - 1e-6, bRate - opts.mde));
|
|
291
|
+
const baselineLimited =
|
|
292
|
+
ceilingLogEvidence(
|
|
293
|
+
opts.evidence,
|
|
294
|
+
pAlt,
|
|
295
|
+
baseline.successes,
|
|
296
|
+
baseline.trials,
|
|
297
|
+
opts.mde,
|
|
298
|
+
opts.altConcentration,
|
|
299
|
+
) < logBar;
|
|
300
|
+
|
|
301
|
+
return baselineLimited
|
|
302
|
+
? {
|
|
303
|
+
...base,
|
|
304
|
+
stop: true,
|
|
305
|
+
reason: "futile",
|
|
306
|
+
detail:
|
|
307
|
+
`futile: a ${(opts.mde * 100).toFixed(0)}pt drop could not be certified against a baseline of ` +
|
|
308
|
+
`${baseline.trials} runs at any candidate budget (ceiling ${ceiling.toExponential(2)}, bar ${bar}). ` +
|
|
309
|
+
`More baseline runs, not more candidate runs.`,
|
|
310
|
+
}
|
|
311
|
+
: {
|
|
312
|
+
...base,
|
|
313
|
+
stop: true,
|
|
314
|
+
reason: "settled",
|
|
315
|
+
detail:
|
|
316
|
+
`settled: even at a rate of ${pessimistic.toFixed(3)}, the worst these counts still permit, this case ` +
|
|
317
|
+
`could only reach ${ceiling.toExponential(2)} against a bar of ${bar}. It is not regressing enough to ` +
|
|
318
|
+
`be certified, so further runs cannot change the verdict.`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (opts.maxTrials !== undefined && observed.trials >= opts.maxTrials) {
|
|
322
|
+
return {
|
|
323
|
+
...base,
|
|
324
|
+
stop: true,
|
|
325
|
+
reason: "budget",
|
|
326
|
+
detail: `budget: ${observed.trials} trials reached with e=${evalue.toExponential(2)}, short of the bar of ${bar}`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
...base,
|
|
331
|
+
stop: false,
|
|
332
|
+
reason: "continue",
|
|
333
|
+
detail: `continue: e=${evalue.toExponential(2)} of a required ${bar}, ceiling ${ceiling.toExponential(2)}`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface PairedStopOptions {
|
|
338
|
+
/** The size of the family you will pass to `gate`, which sets the bar `suiteSize / fdr`. */
|
|
339
|
+
suiteSize: number;
|
|
340
|
+
/** The drop, in rate, that `settled` rules out. */
|
|
341
|
+
mde?: number;
|
|
342
|
+
fdr?: number;
|
|
343
|
+
/** Stop at this many pairs whatever the evidence says. */
|
|
344
|
+
maxPairs?: number;
|
|
345
|
+
/** A case that really dropped by `mde` or more is settled with probability at most `1 - futilityConfidence`. */
|
|
346
|
+
futilityConfidence?: number;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export type PairedStopReason = "regressed" | "settled" | "budget" | "continue";
|
|
350
|
+
|
|
351
|
+
export interface PairedStopDecision extends Omit<StopDecision, "reason" | "ceiling" | "trials"> {
|
|
352
|
+
reason: PairedStopReason;
|
|
353
|
+
pairs: number;
|
|
354
|
+
/** Evidence that the drop is smaller than `mde`; `settled` fires when it reaches `1 / (1 - futilityConfidence)`. */
|
|
355
|
+
settleEvalue: number;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* `shouldStop` for a paired case. With no ceiling to stop a healthy case, `settled` comes from a
|
|
360
|
+
* second e-process against "dropped by at least `mde`", valid under repeated looks like `regressed`.
|
|
361
|
+
*/
|
|
362
|
+
export function shouldStopPaired(
|
|
363
|
+
counts: PairedCounts,
|
|
364
|
+
options: PairedStopOptions,
|
|
365
|
+
): PairedStopDecision {
|
|
366
|
+
const opts = { ...DEFAULT_STOP_OPTIONS, ...options };
|
|
367
|
+
requirePairedCounts(counts, "shouldStopPaired");
|
|
368
|
+
requireOpenProbability(opts.mde, "mde", "shouldStopPaired");
|
|
369
|
+
requireOpenProbability(opts.fdr, "fdr", "shouldStopPaired");
|
|
370
|
+
if (!Number.isInteger(opts.suiteSize) || opts.suiteSize < 1) {
|
|
371
|
+
throw new PeeksafeError(
|
|
372
|
+
"PEEKSAFE_E_CONFIG",
|
|
373
|
+
`shouldStopPaired: suiteSize must be a positive integer, got ${opts.suiteSize}`,
|
|
374
|
+
{
|
|
375
|
+
detail: { suiteSize: opts.suiteSize },
|
|
376
|
+
hint: "this is the number of cases in the family you will pass to gate(), which sets the bar m / fdr",
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (opts.maxPairs !== undefined && (!Number.isInteger(opts.maxPairs) || opts.maxPairs < 1)) {
|
|
381
|
+
throw new PeeksafeError(
|
|
382
|
+
"PEEKSAFE_E_CONFIG",
|
|
383
|
+
`shouldStopPaired: maxPairs must be a positive integer, got ${opts.maxPairs}`,
|
|
384
|
+
{
|
|
385
|
+
detail: { maxPairs: opts.maxPairs },
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (!(opts.futilityConfidence > 0 && opts.futilityConfidence < 1)) {
|
|
390
|
+
throw new PeeksafeError(
|
|
391
|
+
"PEEKSAFE_E_CONFIG",
|
|
392
|
+
`shouldStopPaired: futilityConfidence must be strictly between 0 and 1, got ${opts.futilityConfidence}`,
|
|
393
|
+
{
|
|
394
|
+
detail: { futilityConfidence: opts.futilityConfidence },
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const bar = ebhSoloThreshold(opts.suiteSize, opts.fdr);
|
|
400
|
+
const pairs = counts.bothPass + counts.worse + counts.better + counts.bothFail;
|
|
401
|
+
const logE = pairedLogE(counts.worse, discordant(counts));
|
|
402
|
+
const evalue = toEvalue(logE);
|
|
403
|
+
const logSettle = settleLogE(counts, opts.mde);
|
|
404
|
+
const settleEvalue = toEvalue(logSettle);
|
|
405
|
+
const base = { evalue, logE, bar, pairs, settleEvalue };
|
|
406
|
+
|
|
407
|
+
if (logE >= Math.log(bar)) {
|
|
408
|
+
return {
|
|
409
|
+
...base,
|
|
410
|
+
stop: true,
|
|
411
|
+
reason: "regressed",
|
|
412
|
+
detail: `certified: e=${evalue.toExponential(2)} cleared the bar of ${bar} after ${pairs} pairs`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
if (logSettle >= -Math.log(1 - opts.futilityConfidence)) {
|
|
416
|
+
return {
|
|
417
|
+
...base,
|
|
418
|
+
stop: true,
|
|
419
|
+
reason: "settled",
|
|
420
|
+
detail: `settled: after ${pairs} pairs a ${(opts.mde * 100).toFixed(0)}pt drop is ruled out at ${opts.futilityConfidence} confidence`,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (opts.maxPairs !== undefined && pairs >= opts.maxPairs) {
|
|
424
|
+
return {
|
|
425
|
+
...base,
|
|
426
|
+
stop: true,
|
|
427
|
+
reason: "budget",
|
|
428
|
+
detail: `budget: ${pairs} pairs reached with e=${evalue.toExponential(2)}, short of the bar of ${bar}`,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
...base,
|
|
433
|
+
stop: false,
|
|
434
|
+
reason: "continue",
|
|
435
|
+
detail: `continue: e=${evalue.toExponential(2)} of a required ${bar} after ${pairs} pairs`,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Mean of prod(1 - lambda (D - mde)) over fixed bets, D = +1 worse, -1 better; a supermartingale whenever E[D] >= mde.
|
|
440
|
+
function settleLogE(c: PairedCounts, mde: number): number {
|
|
441
|
+
const terms: number[] = [];
|
|
442
|
+
for (let k = 1; k <= 9; k++) {
|
|
443
|
+
const lambda = k / 10 / (1 - mde);
|
|
444
|
+
terms.push(
|
|
445
|
+
c.worse * Math.log1p(-lambda * (1 - mde)) +
|
|
446
|
+
c.better * Math.log1p(lambda * (1 + mde)) +
|
|
447
|
+
(c.bothPass + c.bothFail) * Math.log1p(lambda * mde),
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
const top = Math.max(...terms);
|
|
451
|
+
return top + Math.log(terms.reduce((acc, t) => acc + Math.exp(t - top), 0) / terms.length);
|
|
452
|
+
}
|