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/stats.ts
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
import {
|
|
24
24
|
PeeksafeError,
|
|
25
25
|
requireCounts,
|
|
26
|
+
requireErrorRates,
|
|
26
27
|
requireOpenProbability,
|
|
28
|
+
requirePairedCounts,
|
|
27
29
|
requireProbability,
|
|
28
30
|
} from "./errors.js";
|
|
29
31
|
|
|
@@ -36,6 +38,16 @@ const LANCZOS = [
|
|
|
36
38
|
|
|
37
39
|
/** Lanczos approximation to log Γ(x), x > 0. ~15 significant digits. */
|
|
38
40
|
export function logGamma(x: number): number {
|
|
41
|
+
// Below 0 the reflection answered finite values at the poles, 37.78 at x = -1, and Infinity answered NaN.
|
|
42
|
+
if (!(x > 0) || x === Infinity) {
|
|
43
|
+
throw new PeeksafeError(
|
|
44
|
+
"PEEKSAFE_E_STAT_DOMAIN",
|
|
45
|
+
`logGamma: x must be finite and positive, got ${x}`,
|
|
46
|
+
{
|
|
47
|
+
detail: { x },
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
}
|
|
39
51
|
if (x < 0.5) {
|
|
40
52
|
// reflection: Γ(x)Γ(1-x) = π / sin(πx)
|
|
41
53
|
return Math.log(Math.PI / Math.abs(Math.sin(Math.PI * x))) - logGamma(1 - x);
|
|
@@ -86,7 +98,14 @@ function gammaQContinued(a: number, x: number): number {
|
|
|
86
98
|
|
|
87
99
|
/** Regularized lower incomplete gamma P(a,x), accurate to ~1e-15. */
|
|
88
100
|
export function gammaP(a: number, x: number): number {
|
|
101
|
+
// A shape at or below 0 answered a negative probability, and x = Infinity fed Infinity - Infinity to exp.
|
|
102
|
+
if (!(a > 0) || !Number.isFinite(a) || Number.isNaN(x)) {
|
|
103
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "gammaP: need finite a > 0 and a number x", {
|
|
104
|
+
detail: { a, x },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
89
107
|
if (x <= 0) return 0;
|
|
108
|
+
if (x === Infinity) return 1;
|
|
90
109
|
return x < a + 1 ? gammaPSeries(a, x) : 1 - gammaQContinued(a, x);
|
|
91
110
|
}
|
|
92
111
|
|
|
@@ -106,6 +125,16 @@ export const normalCdf = (x: number): number => 0.5 * (1 + erf(x / Math.SQRT2));
|
|
|
106
125
|
|
|
107
126
|
/** Φ⁻¹(p): Acklam's inverse normal CDF, refined by one Halley step. */
|
|
108
127
|
export function normalQuantile(p: number): number {
|
|
128
|
+
// Every branch below compares false for NaN, so it fell through to the tail formula and came back NaN.
|
|
129
|
+
if (Number.isNaN(p)) {
|
|
130
|
+
throw new PeeksafeError(
|
|
131
|
+
"PEEKSAFE_E_STAT_DOMAIN",
|
|
132
|
+
"normalQuantile: p must be a number, got NaN",
|
|
133
|
+
{
|
|
134
|
+
detail: { p },
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
}
|
|
109
138
|
if (p <= 0) return -Infinity;
|
|
110
139
|
if (p >= 1) return Infinity;
|
|
111
140
|
const a = [
|
|
@@ -244,6 +273,20 @@ export interface Interval {
|
|
|
244
273
|
observations?: number;
|
|
245
274
|
}
|
|
246
275
|
|
|
276
|
+
// A negative z swapped the bounds and a non-finite one made them NaN; checked before the empty-arm returns.
|
|
277
|
+
function requireZ(z: number, where: string): void {
|
|
278
|
+
if (!Number.isFinite(z) || z < 0) {
|
|
279
|
+
throw new PeeksafeError(
|
|
280
|
+
"PEEKSAFE_E_STAT_DOMAIN",
|
|
281
|
+
`${where}: z must be finite and ≥ 0, got ${z}`,
|
|
282
|
+
{
|
|
283
|
+
detail: { z, where },
|
|
284
|
+
hint: "a standard normal quantile, such as 1.96 for a 95% interval",
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
247
290
|
/**
|
|
248
291
|
* Wilson score interval for a binomial proportion.
|
|
249
292
|
* Correct at the boundaries (0/n and n/n) where Wald is degenerate, which is
|
|
@@ -251,6 +294,7 @@ export interface Interval {
|
|
|
251
294
|
*/
|
|
252
295
|
export function wilsonInterval(successes: number, n: number, z = 1.959963984540054): Interval {
|
|
253
296
|
requireCounts(successes, n, "wilsonInterval");
|
|
297
|
+
requireZ(z, "wilsonInterval");
|
|
254
298
|
// No data is not "p = 0.5". The interval is the whole line and the point
|
|
255
299
|
// estimate is NaN-free only because callers must check `n`, which is why
|
|
256
300
|
// `n` is echoed back in `observations`.
|
|
@@ -321,6 +365,12 @@ export function betaQuantile(alpha: number, beta: number, q: number): number {
|
|
|
321
365
|
},
|
|
322
366
|
);
|
|
323
367
|
}
|
|
368
|
+
// The clamps below are kept, but a NaN fails every comparison in the bisection and came back near 0.
|
|
369
|
+
if (Number.isNaN(q)) {
|
|
370
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "betaQuantile: q must be a number, got NaN", {
|
|
371
|
+
detail: { alpha, beta, q },
|
|
372
|
+
});
|
|
373
|
+
}
|
|
324
374
|
if (q <= 0) return 0;
|
|
325
375
|
if (q >= 1) return 1;
|
|
326
376
|
let lo = 0,
|
|
@@ -376,6 +426,8 @@ export function tQuantile(p: number, df: number): number {
|
|
|
376
426
|
|
|
377
427
|
/** Equal-tailed credible interval of a Beta posterior. */
|
|
378
428
|
export function betaCredibleInterval(post: BetaPosterior, level = 0.95): Interval {
|
|
429
|
+
// betaQuantile clamps q to [0, 1], so a level of 95 or -1 came back as [0, 1] or [1, 0].
|
|
430
|
+
requireOpenProbability(level, "level", "betaCredibleInterval");
|
|
379
431
|
const tail = (1 - level) / 2;
|
|
380
432
|
return {
|
|
381
433
|
low: betaQuantile(post.alpha, post.beta, tail),
|
|
@@ -416,6 +468,7 @@ export function diffInterval(
|
|
|
416
468
|
): Interval {
|
|
417
469
|
requireCounts(s1, n1, "diffInterval(arm 1)");
|
|
418
470
|
requireCounts(s2, n2, "diffInterval(arm 2)");
|
|
471
|
+
requireZ(z, "diffInterval");
|
|
419
472
|
// With an empty arm there is no difference to estimate. Saying so beats
|
|
420
473
|
// reporting `p2 − 0` as if the missing arm had scored zero.
|
|
421
474
|
if (n1 === 0 || n2 === 0) return { low: -1, high: 1, point: 0, observations: 0 };
|
|
@@ -551,8 +604,9 @@ export function sprtDecision(opts: SprtOpts): SprtResult {
|
|
|
551
604
|
// against a wall that is not a number. An alpha of 0 puts `upper` at
|
|
552
605
|
// Infinity, which no evidence ever crosses. Open at both ends: an error rate
|
|
553
606
|
// of 0 is a test that never decides and one of 1 is a test that always does.
|
|
554
|
-
|
|
555
|
-
|
|
607
|
+
// At `alpha + beta >= 1` the walls meet or cross, so a test at 0 runs and a
|
|
608
|
+
// log likelihood ratio of 0 already sat on the H1 wall and answered ACCEPT_H1.
|
|
609
|
+
requireErrorRates(alpha, beta, "sprtDecision");
|
|
556
610
|
const e = 1e-12;
|
|
557
611
|
const cl = (x: number) => Math.min(1 - e, Math.max(e, x));
|
|
558
612
|
const q0 = cl(p0),
|
|
@@ -630,8 +684,9 @@ export function sprtExpectedN(p: number, p0: number, p1: number, alpha = 0.05, b
|
|
|
630
684
|
// and quote `maxTrials` when that fails, which both answers do, so a plan
|
|
631
685
|
// built on an out-of-range error rate priced every case at the per-case cap
|
|
632
686
|
// and reported it as a budget, with nothing to say it was not a real answer.
|
|
633
|
-
|
|
634
|
-
|
|
687
|
+
// At `alpha + beta >= 1` the test decides before its first run, and this
|
|
688
|
+
// answered 0 or a small positive number instead of saying so.
|
|
689
|
+
requireErrorRates(alpha, beta, "sprtExpectedN");
|
|
635
690
|
const A = Math.log((1 - beta) / alpha);
|
|
636
691
|
const B = Math.log(beta / (1 - alpha));
|
|
637
692
|
const logR1 = Math.log(p1 / p0);
|
|
@@ -641,16 +696,6 @@ export function sprtExpectedN(p: number, p0: number, p1: number, alpha = 0.05, b
|
|
|
641
696
|
// p0 === p1 leaves every trial uninformative, so the drift and the curvature
|
|
642
697
|
// are both 0 and no number of runs ends the test.
|
|
643
698
|
if (second === 0) return Infinity;
|
|
644
|
-
// `A === B` is `alpha + beta === 1`: both walls sit at a log likelihood ratio
|
|
645
|
-
// of 0, which is where the test starts, so it decides before it has seen
|
|
646
|
-
// anything and the expected sample number is 0. The general form is
|
|
647
|
-
// `(L1·(A − B) + B) / drift`, which is 0 whatever `L1` is, but it computes
|
|
648
|
-
// `L1` as `(1 − B^h) / (A^h − B^h)`, a genuine 0/0 here, and so returned NaN
|
|
649
|
-
// for in-domain error rates: (0.5, 0.5) and (0.25, 0.75) both landed on it.
|
|
650
|
-
// The neighbouring configurations already converge to 0 from both sides.
|
|
651
|
-
// Only the pairs where both logs round to exactly 0 were affected, which is
|
|
652
|
-
// why this survived the error-rate guards above.
|
|
653
|
-
if (A === B) return 0;
|
|
654
699
|
// The ASN is 0/0 where the tilt vanishes, and that limit is the one place
|
|
655
700
|
// Wald's formula needs a different expression. What decides whether the
|
|
656
701
|
// quotient is really 0/0 is the *tilt*, not the drift: near the zero
|
|
@@ -684,9 +729,8 @@ export function sprtExpectedN(p: number, p0: number, p1: number, alpha = 0.05, b
|
|
|
684
729
|
* outside the two hypotheses, so the two powers are evaluated relative to
|
|
685
730
|
* whichever of them is largest. Written as plain `exp`, a tilt of −662, which is
|
|
686
731
|
* p = 0.0025 against p₀ = 0.999 and p₁ = 0.99, overflows `B^h` to Infinity and
|
|
687
|
-
* the ratio comes back NaN
|
|
688
|
-
*
|
|
689
|
-
* silently prices the case at `maxTrials`.
|
|
732
|
+
* the ratio comes back NaN, which a caller testing the result for `> 0` reads
|
|
733
|
+
* as no estimate at all.
|
|
690
734
|
*/
|
|
691
735
|
function sprtAcceptH1(h: number, A: number, B: number, drift: number): number {
|
|
692
736
|
// No tilt exists at p = 0 or p = 1: one outcome is impossible, so the drift
|
|
@@ -791,6 +835,41 @@ function requireConcentration(k: number, name: string, where: string): void {
|
|
|
791
835
|
}
|
|
792
836
|
}
|
|
793
837
|
|
|
838
|
+
/**
|
|
839
|
+
* The most runs a sample-size search may try. Below 1, or NaN, the doubling
|
|
840
|
+
* loop never ran and the search answered Infinity, "no number of runs will
|
|
841
|
+
* do", for a design that needs a few hundred.
|
|
842
|
+
*/
|
|
843
|
+
function requireSearchCap(cap: number, name: string, where: string): void {
|
|
844
|
+
if (!(cap >= 1) || !Number.isFinite(cap)) {
|
|
845
|
+
throw new PeeksafeError(
|
|
846
|
+
"PEEKSAFE_E_STAT_DOMAIN",
|
|
847
|
+
`${where}: ${name} must be finite and at least 1, got ${cap}`,
|
|
848
|
+
{
|
|
849
|
+
detail: { [name]: cap, where },
|
|
850
|
+
},
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* The log e-value bar a sample-size search sizes a study to. A NaN bar loses
|
|
857
|
+
* every comparison the searches make, so one answered a single pair and the
|
|
858
|
+
* other Infinity, "no number of runs will do", for the same mistake.
|
|
859
|
+
*/
|
|
860
|
+
function requireLogThreshold(logThreshold: number, where: string): void {
|
|
861
|
+
if (!Number.isFinite(logThreshold)) {
|
|
862
|
+
throw new PeeksafeError(
|
|
863
|
+
"PEEKSAFE_E_STAT_DOMAIN",
|
|
864
|
+
`${where}: logThreshold must be finite, got ${logThreshold}`,
|
|
865
|
+
{
|
|
866
|
+
detail: { logThreshold },
|
|
867
|
+
hint: "this is a log e-value bar, such as Math.log(ebhSoloThreshold(m, fdr))",
|
|
868
|
+
},
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
794
873
|
// Below 1 a shape piles alternative mass onto 0 or 1, which pushed type I error past alpha for thin baselines.
|
|
795
874
|
const ALT_SHAPE_FLOOR = 1;
|
|
796
875
|
|
|
@@ -907,8 +986,25 @@ export function ebhCorrect(evalues: number[], q = 0.05): EbhResult {
|
|
|
907
986
|
return { rejected, discoveries: kStar, threshold: kStar > 0 ? m / (q * kStar) : m / q };
|
|
908
987
|
}
|
|
909
988
|
|
|
910
|
-
/**
|
|
911
|
-
|
|
989
|
+
/**
|
|
990
|
+
* The e-value a case must reach to be certain of rejection whatever the others do.
|
|
991
|
+
*
|
|
992
|
+
* `q` is checked as `ebhCorrect` checks it, so a level of 0 answers Infinity.
|
|
993
|
+
*/
|
|
994
|
+
export function ebhSoloThreshold(m: number, q = 0.05): number {
|
|
995
|
+
if (!Number.isInteger(m) || m < 1) {
|
|
996
|
+
throw new PeeksafeError(
|
|
997
|
+
"PEEKSAFE_E_STAT_DOMAIN",
|
|
998
|
+
`ebhSoloThreshold: m must be a positive integer, got ${m}`,
|
|
999
|
+
{
|
|
1000
|
+
detail: { m },
|
|
1001
|
+
hint: "m is the number of cases in the e-BH family",
|
|
1002
|
+
},
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
requireProbability(q, "q", "ebhSoloThreshold");
|
|
1006
|
+
return m / q;
|
|
1007
|
+
}
|
|
912
1008
|
|
|
913
1009
|
/* ──────────────────────────────── planning ──────────────────────────── */
|
|
914
1010
|
|
|
@@ -937,6 +1033,16 @@ export function sampleSizeTwoProportion(
|
|
|
937
1033
|
// naive design costs.
|
|
938
1034
|
requireOpenProbability(alpha, "alpha", "sampleSizeTwoProportion");
|
|
939
1035
|
requireOpenProbability(beta, "beta", "sampleSizeTwoProportion");
|
|
1036
|
+
// Both comparisons below are false for NaN, so it fell through to the formula and came back NaN.
|
|
1037
|
+
if (Number.isNaN(delta)) {
|
|
1038
|
+
throw new PeeksafeError(
|
|
1039
|
+
"PEEKSAFE_E_STAT_DOMAIN",
|
|
1040
|
+
"sampleSizeTwoProportion: delta must be a number, got NaN",
|
|
1041
|
+
{
|
|
1042
|
+
detail: { p0, delta },
|
|
1043
|
+
},
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
940
1046
|
if (delta <= 0 || delta >= p0) return Infinity;
|
|
941
1047
|
const p1 = p0 - delta;
|
|
942
1048
|
const pbar = (p0 + p1) / 2;
|
|
@@ -1230,6 +1336,8 @@ export function samplesForEvidence(
|
|
|
1230
1336
|
maxN = 100_000,
|
|
1231
1337
|
altConcentration = 8,
|
|
1232
1338
|
): number {
|
|
1339
|
+
requireSearchCap(maxN, "maxN", "samplesForEvidence");
|
|
1340
|
+
requireLogThreshold(logThreshold, "samplesForEvidence");
|
|
1233
1341
|
const ceiling = evidenceCeilingLogE(
|
|
1234
1342
|
pTrue,
|
|
1235
1343
|
baselineSuccesses,
|
|
@@ -1373,7 +1481,10 @@ export interface PairedCounts {
|
|
|
1373
1481
|
bothFail: number;
|
|
1374
1482
|
}
|
|
1375
1483
|
|
|
1376
|
-
export
|
|
1484
|
+
export function discordant(p: PairedCounts): number {
|
|
1485
|
+
requirePairedCounts(p, "discordant");
|
|
1486
|
+
return p.worse + p.better;
|
|
1487
|
+
}
|
|
1377
1488
|
|
|
1378
1489
|
/**
|
|
1379
1490
|
* Phi coefficient of the 2×2 pair table, how well the pairing actually
|
|
@@ -1383,23 +1494,8 @@ export const discordant = (p: PairedCounts): number => p.worse + p.better;
|
|
|
1383
1494
|
* not assumed.
|
|
1384
1495
|
*/
|
|
1385
1496
|
export function pairPhi(p: PairedCounts): number {
|
|
1497
|
+
requirePairedCounts(p, "pairPhi");
|
|
1386
1498
|
const { bothPass: a, worse: b, better: cc, bothFail: d } = p;
|
|
1387
|
-
for (const [name, v] of [
|
|
1388
|
-
["bothPass", a],
|
|
1389
|
-
["worse", b],
|
|
1390
|
-
["better", cc],
|
|
1391
|
-
["bothFail", d],
|
|
1392
|
-
] as const) {
|
|
1393
|
-
if (!Number.isInteger(v) || v < 0) {
|
|
1394
|
-
throw new PeeksafeError(
|
|
1395
|
-
"PEEKSAFE_E_STAT_DOMAIN",
|
|
1396
|
-
`pairPhi: ${name} must be a non-negative integer, got ${v}`,
|
|
1397
|
-
{
|
|
1398
|
-
detail: { counts: p },
|
|
1399
|
-
},
|
|
1400
|
-
);
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
1499
|
const n = a + b + cc + d;
|
|
1404
1500
|
if (n === 0) return 0;
|
|
1405
1501
|
const den = Math.sqrt((a + b) * (cc + d) * (a + cc) * (b + d));
|
|
@@ -1488,21 +1584,8 @@ export function mcnemarSamplesForEvidence(
|
|
|
1488
1584
|
concentration = 6,
|
|
1489
1585
|
): number {
|
|
1490
1586
|
requireConcentration(concentration, "concentration", "mcnemarSamplesForEvidence");
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
// not fire, and the bisection returned its own starting point. The answer
|
|
1494
|
-
// came back as one pair, where the same design at a real 15pt effect needs
|
|
1495
|
-
// 172. `samplesForEvidence` writes its guard negated and so refuses instead.
|
|
1496
|
-
if (!Number.isFinite(logThreshold)) {
|
|
1497
|
-
throw new PeeksafeError(
|
|
1498
|
-
"PEEKSAFE_E_STAT_DOMAIN",
|
|
1499
|
-
`mcnemarSamplesForEvidence: logThreshold must be finite, got ${logThreshold}`,
|
|
1500
|
-
{
|
|
1501
|
-
detail: { logThreshold },
|
|
1502
|
-
hint: "this is a log e-value bar, such as Math.log(ebhSoloThreshold(m, fdr))",
|
|
1503
|
-
},
|
|
1504
|
-
);
|
|
1505
|
-
}
|
|
1587
|
+
requireSearchCap(maxPairs, "maxPairs", "mcnemarSamplesForEvidence");
|
|
1588
|
+
requireLogThreshold(logThreshold, "mcnemarSamplesForEvidence");
|
|
1506
1589
|
const { rate, theta } = pairedDiscordance(pBaseline, mde, rho);
|
|
1507
1590
|
if (rate <= 0 || theta <= 0.5) return Infinity;
|
|
1508
1591
|
const a1 = Math.max(0.35, concentration * theta);
|
package/src/stop.ts
CHANGED
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
requireEvidence,
|
|
61
61
|
toEvalue,
|
|
62
62
|
} from "./evidence.js";
|
|
63
|
-
import { cannotDropBy, DEFAULT_GATE_OPTIONS } from "./gate.js";
|
|
63
|
+
import { cannotDropBy, DEFAULT_GATE_OPTIONS, definedOptions } from "./gate.js";
|
|
64
64
|
import {
|
|
65
65
|
discordant,
|
|
66
66
|
ebhSoloThreshold,
|
|
@@ -160,7 +160,19 @@ const zFor = (confidence: number): number => {
|
|
|
160
160
|
},
|
|
161
161
|
);
|
|
162
162
|
}
|
|
163
|
-
|
|
163
|
+
const z = normalQuantile(1 - (1 - confidence) / 2);
|
|
164
|
+
// At the largest double below 1 the upper tail rounds to 1, and an infinite z reached the ceiling as NaN.
|
|
165
|
+
if (!Number.isFinite(z)) {
|
|
166
|
+
throw new PeeksafeError(
|
|
167
|
+
"PEEKSAFE_E_CONFIG",
|
|
168
|
+
`shouldStop: futilityConfidence ${confidence} is too close to 1 for a finite interval`,
|
|
169
|
+
{
|
|
170
|
+
detail: { futilityConfidence: confidence },
|
|
171
|
+
hint: "a two-sided confidence level such as 0.95; higher makes futility fire later",
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return z;
|
|
164
176
|
};
|
|
165
177
|
|
|
166
178
|
/**
|
|
@@ -180,7 +192,7 @@ export function shouldStop(
|
|
|
180
192
|
baseline: BaselineStat,
|
|
181
193
|
options: StopOptions,
|
|
182
194
|
): StopDecision {
|
|
183
|
-
const opts = { ...DEFAULT_STOP_OPTIONS, ...options };
|
|
195
|
+
const opts = { ...DEFAULT_STOP_OPTIONS, ...definedOptions(options) };
|
|
184
196
|
requireCounts(observed.successes, observed.trials, "shouldStop");
|
|
185
197
|
requireCounts(baseline.successes, baseline.trials, "shouldStop.baseline");
|
|
186
198
|
requireOpenProbability(opts.mde, "mde", "shouldStop");
|
|
@@ -363,7 +375,7 @@ export function shouldStopPaired(
|
|
|
363
375
|
counts: PairedCounts,
|
|
364
376
|
options: PairedStopOptions,
|
|
365
377
|
): PairedStopDecision {
|
|
366
|
-
const opts = { ...DEFAULT_STOP_OPTIONS, ...options };
|
|
378
|
+
const opts = { ...DEFAULT_STOP_OPTIONS, ...definedOptions(options) };
|
|
367
379
|
requirePairedCounts(counts, "shouldStopPaired");
|
|
368
380
|
requireOpenProbability(opts.mde, "mde", "shouldStopPaired");
|
|
369
381
|
requireOpenProbability(opts.fdr, "fdr", "shouldStopPaired");
|