peeksafe 0.0.0-stage → 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 +325 -0
- package/LICENSE +190 -0
- package/NOTICE +78 -0
- package/README.md +490 -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 +606 -0
- package/dist/cluster.js.map +1 -0
- package/dist/errors.d.ts +108 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +192 -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 +271 -0
- package/dist/frontier.d.ts.map +1 -0
- package/dist/frontier.js +505 -0
- package/dist/frontier.js.map +1 -0
- package/dist/gate.d.ts +175 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +200 -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 +280 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +626 -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 +104 -0
- package/dist/rand.js.map +1 -0
- package/dist/stats.d.ts +401 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +1304 -0
- package/dist/stats.js.map +1 -0
- package/dist/stop.d.ts +164 -0
- package/dist/stop.d.ts.map +1 -0
- package/dist/stop.js +270 -0
- package/dist/stop.js.map +1 -0
- package/package.json +62 -3
- package/src/baseline.ts +72 -0
- package/src/check.ts +238 -0
- package/src/cluster.ts +842 -0
- package/src/errors.ts +275 -0
- package/src/evidence.ts +62 -0
- package/src/frontier.ts +837 -0
- package/src/gate.ts +363 -0
- package/src/index.ts +206 -0
- package/src/plan.ts +968 -0
- package/src/rand.ts +137 -0
- package/src/stats.ts +1663 -0
- package/src/stop.ts +464 -0
package/dist/stats.js
ADDED
|
@@ -0,0 +1,1304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stats.ts: the statistical core of peeksafe, implemented from scratch.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here calls a library. Every function is a pure function of numbers so
|
|
5
|
+
* it can be checked against closed-form or hand-computed values in test/.
|
|
6
|
+
*
|
|
7
|
+
* Contents:
|
|
8
|
+
* special functions logGamma, logBeta, erf, normalCdf, normalQuantile, ibeta
|
|
9
|
+
* estimation wilsonInterval, betaPosterior, betaCredibleInterval
|
|
10
|
+
* effect size diffInterval (Newcombe hybrid score), cohensH
|
|
11
|
+
* testing twoProportionZTest, fisherExact2x2, sprtDecision
|
|
12
|
+
* correction bhCorrect, ebhCorrect
|
|
13
|
+
* evidence twoSampleLogE, pairedLogE, probabilityMoved
|
|
14
|
+
* planning sampleSizeTwoProportion, samplesForEvidence, mcnemarSamplesForEvidence
|
|
15
|
+
*
|
|
16
|
+
* **Domain discipline.** Every entry point that can be handed nonsense
|
|
17
|
+
* (successes > trials, a negative count, a probability outside [0,1]) throws a
|
|
18
|
+
* `PeeksafeError` with code `PEEKSAFE_E_STAT_DOMAIN` rather than returning NaN
|
|
19
|
+
* or a plausible-looking number. Returning `Infinity` is reserved for questions
|
|
20
|
+
* whose honest answer is "no finite sample size will do", see
|
|
21
|
+
* `sampleSizeTwoProportion`.
|
|
22
|
+
*/
|
|
23
|
+
import { PeeksafeError, requireCounts, requireErrorRates, requireOpenProbability, requirePairedCounts, requireProbability, } from "./errors.js";
|
|
24
|
+
/* ────────────────────────── special functions ────────────────────────── */
|
|
25
|
+
const LANCZOS = [
|
|
26
|
+
676.5203681218851, -1259.1392167224028, 771.32342877765313, -176.61502916214059,
|
|
27
|
+
12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
|
|
28
|
+
];
|
|
29
|
+
/** Lanczos approximation to log Γ(x), x > 0. ~15 significant digits. */
|
|
30
|
+
export function logGamma(x) {
|
|
31
|
+
// Below 0 the reflection answered finite values at the poles, 37.78 at x = -1, and Infinity answered NaN.
|
|
32
|
+
if (!(x > 0) || x === Infinity) {
|
|
33
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `logGamma: x must be finite and positive, got ${x}`, {
|
|
34
|
+
detail: { x },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (x < 0.5) {
|
|
38
|
+
// reflection: Γ(x)Γ(1-x) = π / sin(πx)
|
|
39
|
+
return Math.log(Math.PI / Math.abs(Math.sin(Math.PI * x))) - logGamma(1 - x);
|
|
40
|
+
}
|
|
41
|
+
const z = x - 1;
|
|
42
|
+
let a = 0.99999999999980993;
|
|
43
|
+
for (let i = 0; i < LANCZOS.length; i++)
|
|
44
|
+
a += LANCZOS[i] / (z + i + 1);
|
|
45
|
+
const t = z + LANCZOS.length - 0.5;
|
|
46
|
+
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(a);
|
|
47
|
+
}
|
|
48
|
+
export const logBeta = (a, b) => logGamma(a) + logGamma(b) - logGamma(a + b);
|
|
49
|
+
/** Series expansion of the regularized lower incomplete gamma P(a,x), x < a+1. */
|
|
50
|
+
function gammaPSeries(a, x) {
|
|
51
|
+
let term = 1 / a;
|
|
52
|
+
let sum = term;
|
|
53
|
+
for (let n = 1; n < 500; n++) {
|
|
54
|
+
term *= x / (a + n);
|
|
55
|
+
sum += term;
|
|
56
|
+
if (Math.abs(term) < Math.abs(sum) * 1e-17)
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
return sum * Math.exp(-x + a * Math.log(x) - logGamma(a));
|
|
60
|
+
}
|
|
61
|
+
/** Lentz continued fraction for the regularized upper incomplete gamma Q(a,x). */
|
|
62
|
+
function gammaQContinued(a, x) {
|
|
63
|
+
const TINY = 1e-300;
|
|
64
|
+
let b = x + 1 - a;
|
|
65
|
+
let cf = 1 / TINY;
|
|
66
|
+
let d = 1 / b;
|
|
67
|
+
let h = d;
|
|
68
|
+
for (let i = 1; i < 500; i++) {
|
|
69
|
+
const an = -i * (i - a);
|
|
70
|
+
b += 2;
|
|
71
|
+
d = an * d + b;
|
|
72
|
+
if (Math.abs(d) < TINY)
|
|
73
|
+
d = TINY;
|
|
74
|
+
cf = b + an / cf;
|
|
75
|
+
if (Math.abs(cf) < TINY)
|
|
76
|
+
cf = TINY;
|
|
77
|
+
d = 1 / d;
|
|
78
|
+
const del = d * cf;
|
|
79
|
+
h *= del;
|
|
80
|
+
if (Math.abs(del - 1) < 1e-16)
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
return Math.exp(-x + a * Math.log(x) - logGamma(a)) * h;
|
|
84
|
+
}
|
|
85
|
+
/** Regularized lower incomplete gamma P(a,x), accurate to ~1e-15. */
|
|
86
|
+
export function gammaP(a, x) {
|
|
87
|
+
// A shape at or below 0 answered a negative probability, and x = Infinity fed Infinity - Infinity to exp.
|
|
88
|
+
if (!(a > 0) || !Number.isFinite(a) || Number.isNaN(x)) {
|
|
89
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "gammaP: need finite a > 0 and a number x", {
|
|
90
|
+
detail: { a, x },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (x <= 0)
|
|
94
|
+
return 0;
|
|
95
|
+
if (x === Infinity)
|
|
96
|
+
return 1;
|
|
97
|
+
return x < a + 1 ? gammaPSeries(a, x) : 1 - gammaQContinued(a, x);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* erf(x) = sign(x)·P(½, x²).
|
|
101
|
+
* Built on the incomplete gamma rather than a rational fit: the usual 7-digit
|
|
102
|
+
* approximation is visible in a p-value and we compare p-values to thresholds.
|
|
103
|
+
*/
|
|
104
|
+
export function erf(x) {
|
|
105
|
+
if (x === 0)
|
|
106
|
+
return 0;
|
|
107
|
+
const p = gammaP(0.5, x * x);
|
|
108
|
+
return x > 0 ? p : -p;
|
|
109
|
+
}
|
|
110
|
+
/** Φ(x): standard normal CDF. */
|
|
111
|
+
export const normalCdf = (x) => 0.5 * (1 + erf(x / Math.SQRT2));
|
|
112
|
+
/** Φ⁻¹(p): Acklam's inverse normal CDF, refined by one Halley step. */
|
|
113
|
+
export function normalQuantile(p) {
|
|
114
|
+
// Every branch below compares false for NaN, so it fell through to the tail formula and came back NaN.
|
|
115
|
+
if (Number.isNaN(p)) {
|
|
116
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "normalQuantile: p must be a number, got NaN", {
|
|
117
|
+
detail: { p },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (p <= 0)
|
|
121
|
+
return -Infinity;
|
|
122
|
+
if (p >= 1)
|
|
123
|
+
return Infinity;
|
|
124
|
+
const a = [
|
|
125
|
+
-3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, 1.38357751867269e2,
|
|
126
|
+
-3.066479806614716e1, 2.506628277459239,
|
|
127
|
+
];
|
|
128
|
+
const b = [
|
|
129
|
+
-5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, 6.680131188771972e1,
|
|
130
|
+
-1.328068155288572e1,
|
|
131
|
+
];
|
|
132
|
+
const cc = [
|
|
133
|
+
-7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, -2.549732539343734,
|
|
134
|
+
4.374664141464968, 2.938163982698783,
|
|
135
|
+
];
|
|
136
|
+
const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, 3.754408661907416];
|
|
137
|
+
const pl = 0.02425;
|
|
138
|
+
let x;
|
|
139
|
+
if (p < pl) {
|
|
140
|
+
const q = Math.sqrt(-2 * Math.log(p));
|
|
141
|
+
x =
|
|
142
|
+
(((((cc[0] * q + cc[1]) * q + cc[2]) * q + cc[3]) * q + cc[4]) * q + cc[5]) /
|
|
143
|
+
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
144
|
+
}
|
|
145
|
+
else if (p <= 1 - pl) {
|
|
146
|
+
const q = p - 0.5;
|
|
147
|
+
const r = q * q;
|
|
148
|
+
x =
|
|
149
|
+
((((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q) /
|
|
150
|
+
(((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
const q = Math.sqrt(-2 * Math.log(1 - p));
|
|
154
|
+
x =
|
|
155
|
+
-(((((cc[0] * q + cc[1]) * q + cc[2]) * q + cc[3]) * q + cc[4]) * q + cc[5]) /
|
|
156
|
+
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
|
|
157
|
+
}
|
|
158
|
+
// One Halley refinement against our own Φ. Skipped in the far tails, where
|
|
159
|
+
// exp(x²/2) overflows to Infinity and the refinement returns NaN, the
|
|
160
|
+
// Acklam value is already good to ~1e-9 there.
|
|
161
|
+
const half = (x * x) / 2;
|
|
162
|
+
if (half > 300)
|
|
163
|
+
return x;
|
|
164
|
+
const e = normalCdf(x) - p;
|
|
165
|
+
const u = e * Math.sqrt(2 * Math.PI) * Math.exp(half);
|
|
166
|
+
const refined = x - u / (1 + (x * u) / 2);
|
|
167
|
+
return Number.isFinite(refined) ? refined : x;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Smallest magnitude a Lentz denominator is allowed to take.
|
|
171
|
+
*
|
|
172
|
+
* The recurrence divides by both running terms, so a coefficient that lands on
|
|
173
|
+
* zero would produce an infinity that never recovers. Clamping to a tiny
|
|
174
|
+
* non-zero value instead is the standard remedy and costs nothing in accuracy.
|
|
175
|
+
*/
|
|
176
|
+
const LENTZ_FLOOR = 1e-30;
|
|
177
|
+
/** Keep a Lentz denominator away from zero. */
|
|
178
|
+
const awayFromZero = (v) => (Math.abs(v) < LENTZ_FLOOR ? LENTZ_FLOOR : v);
|
|
179
|
+
/**
|
|
180
|
+
* The continued fraction for the regularized incomplete beta.
|
|
181
|
+
*
|
|
182
|
+
* The fraction itself is Abramowitz & Stegun 26.5.8; it is evaluated by the
|
|
183
|
+
* modified Lentz algorithm (Lentz 1976, as amended by Thompson & Barnett 1986),
|
|
184
|
+
* which advances the numerator and denominator convergents together so the
|
|
185
|
+
* fraction can be truncated as soon as one factor is within tolerance of 1.
|
|
186
|
+
*
|
|
187
|
+
* Both are published mathematics rather than anybody's source code, and this is
|
|
188
|
+
* written from the recurrence: the even and odd coefficients below are A&S
|
|
189
|
+
* 26.5.8 verbatim, and the loop is the textbook Lentz update. See NOTICE.
|
|
190
|
+
*/
|
|
191
|
+
function betaContinuedFraction(a, b, x) {
|
|
192
|
+
const sum = a + b;
|
|
193
|
+
const aUp = a + 1;
|
|
194
|
+
const aDown = a - 1;
|
|
195
|
+
// Lentz starts from the zeroth convergent and multiplies in one factor per
|
|
196
|
+
// half-step; `frac` is the running value, `cTerm` and `dTerm` the convergents.
|
|
197
|
+
let cTerm = 1;
|
|
198
|
+
let dTerm = 1 / awayFromZero(1 - (sum * x) / aUp);
|
|
199
|
+
let frac = dTerm;
|
|
200
|
+
for (let j = 1; j <= 300; j++) {
|
|
201
|
+
const twoJ = 2 * j;
|
|
202
|
+
// even coefficient d_{2j}
|
|
203
|
+
const even = (j * (b - j) * x) / ((aDown + twoJ) * (a + twoJ));
|
|
204
|
+
dTerm = 1 / awayFromZero(1 + even * dTerm);
|
|
205
|
+
cTerm = awayFromZero(1 + even / cTerm);
|
|
206
|
+
frac *= dTerm * cTerm;
|
|
207
|
+
// odd coefficient d_{2j+1}
|
|
208
|
+
const odd = (-(a + j) * (sum + j) * x) / ((a + twoJ) * (aUp + twoJ));
|
|
209
|
+
dTerm = 1 / awayFromZero(1 + odd * dTerm);
|
|
210
|
+
cTerm = awayFromZero(1 + odd / cTerm);
|
|
211
|
+
const step = dTerm * cTerm;
|
|
212
|
+
frac *= step;
|
|
213
|
+
// Converged once a half-step multiplies the fraction by 1 to float64.
|
|
214
|
+
if (Math.abs(step - 1) < 3e-16)
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
return frac;
|
|
218
|
+
}
|
|
219
|
+
/** Regularized incomplete beta Iₓ(a,b) = P(Beta(a,b) ≤ x). */
|
|
220
|
+
export function ibeta(a, b, x) {
|
|
221
|
+
if (!(a > 0) || !(b > 0) || !Number.isFinite(a) || !Number.isFinite(b) || !Number.isFinite(x)) {
|
|
222
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "ibeta: need finite a > 0, b > 0 and finite x", {
|
|
223
|
+
detail: { a, b, x },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (x <= 0)
|
|
227
|
+
return 0;
|
|
228
|
+
if (x >= 1)
|
|
229
|
+
return 1;
|
|
230
|
+
const front = Math.exp(a * Math.log(x) + b * Math.log(1 - x) - logBeta(a, b));
|
|
231
|
+
return x < (a + 1) / (a + b + 2)
|
|
232
|
+
? (front * betaContinuedFraction(a, b, x)) / a
|
|
233
|
+
: 1 -
|
|
234
|
+
(Math.exp(b * Math.log(1 - x) + a * Math.log(x) - logBeta(b, a)) *
|
|
235
|
+
betaContinuedFraction(b, a, 1 - x)) /
|
|
236
|
+
b;
|
|
237
|
+
}
|
|
238
|
+
// A negative z swapped the bounds and a non-finite one made them NaN; checked before the empty-arm returns.
|
|
239
|
+
function requireZ(z, where) {
|
|
240
|
+
if (!Number.isFinite(z) || z < 0) {
|
|
241
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `${where}: z must be finite and ≥ 0, got ${z}`, {
|
|
242
|
+
detail: { z, where },
|
|
243
|
+
hint: "a standard normal quantile, such as 1.96 for a 95% interval",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Wilson score interval for a binomial proportion.
|
|
249
|
+
* Correct at the boundaries (0/n and n/n) where Wald is degenerate, which is
|
|
250
|
+
* exactly where eval cases live.
|
|
251
|
+
*/
|
|
252
|
+
export function wilsonInterval(successes, n, z = 1.959963984540054) {
|
|
253
|
+
requireCounts(successes, n, "wilsonInterval");
|
|
254
|
+
requireZ(z, "wilsonInterval");
|
|
255
|
+
// No data is not "p = 0.5". The interval is the whole line and the point
|
|
256
|
+
// estimate is NaN-free only because callers must check `n`, which is why
|
|
257
|
+
// `n` is echoed back in `observations`.
|
|
258
|
+
if (n === 0)
|
|
259
|
+
return { low: 0, high: 1, point: 0.5, observations: 0 };
|
|
260
|
+
const p = successes / n;
|
|
261
|
+
const z2 = z * z;
|
|
262
|
+
const denom = 1 + z2 / n;
|
|
263
|
+
const centre = (p + z2 / (2 * n)) / denom;
|
|
264
|
+
const margin = (z / denom) * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n));
|
|
265
|
+
return {
|
|
266
|
+
low: Math.max(0, centre - margin),
|
|
267
|
+
high: Math.min(1, centre + margin),
|
|
268
|
+
point: p,
|
|
269
|
+
observations: n,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/** Conjugate Beta posterior for a per-case latent success probability. */
|
|
273
|
+
export function betaPosterior(successes, failures, prior = { alpha: 1, beta: 1 }) {
|
|
274
|
+
requireCounts(successes, successes + failures, "betaPosterior");
|
|
275
|
+
if (!(prior.alpha > 0) || !(prior.beta > 0)) {
|
|
276
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "betaPosterior: prior parameters must be positive", {
|
|
277
|
+
detail: { prior },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const alpha = prior.alpha + successes;
|
|
281
|
+
const beta = prior.beta + failures;
|
|
282
|
+
const s = alpha + beta;
|
|
283
|
+
return {
|
|
284
|
+
alpha,
|
|
285
|
+
beta,
|
|
286
|
+
mean: alpha / s,
|
|
287
|
+
mode: alpha > 1 && beta > 1 ? (alpha - 1) / (s - 2) : alpha / s,
|
|
288
|
+
variance: (alpha * beta) / (s * s * (s + 1)),
|
|
289
|
+
sd: Math.sqrt((alpha * beta) / (s * s * (s + 1))),
|
|
290
|
+
n: successes + failures,
|
|
291
|
+
successes,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/** Inverse of the Beta CDF by bisection on ibeta (monotone, so this is exact to 1e-10). */
|
|
295
|
+
export function betaQuantile(alpha, beta, q) {
|
|
296
|
+
if (!(alpha > 0) || !(beta > 0) || !Number.isFinite(alpha) || !Number.isFinite(beta)) {
|
|
297
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "betaQuantile: alpha and beta must be finite and positive", {
|
|
298
|
+
detail: { alpha, beta, q },
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
// The clamps below are kept, but a NaN fails every comparison in the bisection and came back near 0.
|
|
302
|
+
if (Number.isNaN(q)) {
|
|
303
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "betaQuantile: q must be a number, got NaN", {
|
|
304
|
+
detail: { alpha, beta, q },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (q <= 0)
|
|
308
|
+
return 0;
|
|
309
|
+
if (q >= 1)
|
|
310
|
+
return 1;
|
|
311
|
+
let lo = 0, hi = 1;
|
|
312
|
+
for (let i = 0; i < 200; i++) {
|
|
313
|
+
const mid = (lo + hi) / 2;
|
|
314
|
+
if (ibeta(alpha, beta, mid) < q)
|
|
315
|
+
lo = mid;
|
|
316
|
+
else
|
|
317
|
+
hi = mid;
|
|
318
|
+
if (hi - lo < 1e-12)
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
return (lo + hi) / 2;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Student-t quantile, from the Beta inverse CDF.
|
|
325
|
+
*
|
|
326
|
+
* Needed because the cluster-robust suite interval has a *small* number of
|
|
327
|
+
* degrees of freedom, one per case family, not one per case, and at 39 df the
|
|
328
|
+
* difference between t and z is already 3.2% of the half-width. Using z there
|
|
329
|
+
* would reintroduce, in miniature, exactly the too-narrow-interval bug that
|
|
330
|
+
* clustering exists to fix.
|
|
331
|
+
*
|
|
332
|
+
* The identity is the standard one: for T ~ t_ν and t > 0,
|
|
333
|
+
* P(|T| > t) = I_x(ν/2, ½) with x = ν/(ν + t²)
|
|
334
|
+
* so inverting `ibeta` in x inverts the t CDF. Exact to the ~1e-10 of
|
|
335
|
+
* `betaQuantile`, and it degenerates to `normalQuantile` as ν → ∞.
|
|
336
|
+
*/
|
|
337
|
+
export function tQuantile(p, df) {
|
|
338
|
+
if (!Number.isFinite(p) || p <= 0 || p >= 1) {
|
|
339
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `tQuantile: p must be in (0,1), got ${p}`, {
|
|
340
|
+
detail: { p, df },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (!Number.isFinite(df) || df <= 0) {
|
|
344
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `tQuantile: df must be finite and positive, got ${df}`, {
|
|
345
|
+
detail: { p, df },
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
if (p === 0.5)
|
|
349
|
+
return 0;
|
|
350
|
+
if (p < 0.5)
|
|
351
|
+
return -tQuantile(1 - p, df);
|
|
352
|
+
// Past this the Beta inversion loses to floating point long before the normal
|
|
353
|
+
// approximation is wrong by anything a reader could see.
|
|
354
|
+
if (df > 1e7)
|
|
355
|
+
return normalQuantile(p);
|
|
356
|
+
const twoSidedTail = 2 * (1 - p);
|
|
357
|
+
const x = betaQuantile(df / 2, 0.5, twoSidedTail);
|
|
358
|
+
if (!(x > 0))
|
|
359
|
+
return Infinity;
|
|
360
|
+
return Math.sqrt((df * (1 - x)) / x);
|
|
361
|
+
}
|
|
362
|
+
/** Equal-tailed credible interval of a Beta posterior. */
|
|
363
|
+
export function betaCredibleInterval(post, level = 0.95) {
|
|
364
|
+
// betaQuantile clamps q to [0, 1], so a level of 95 or -1 came back as [0, 1] or [1, 0].
|
|
365
|
+
requireOpenProbability(level, "level", "betaCredibleInterval");
|
|
366
|
+
const tail = (1 - level) / 2;
|
|
367
|
+
return {
|
|
368
|
+
low: betaQuantile(post.alpha, post.beta, tail),
|
|
369
|
+
high: betaQuantile(post.alpha, post.beta, 1 - tail),
|
|
370
|
+
point: post.mean,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/** P(a < θ < b) under a Beta posterior. */
|
|
374
|
+
export function betaMassBetween(post, a, b) {
|
|
375
|
+
requireProbability(a, "a", "betaMassBetween");
|
|
376
|
+
requireProbability(b, "b", "betaMassBetween");
|
|
377
|
+
if (a > b) {
|
|
378
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `betaMassBetween: need a ≤ b, got [${a}, ${b}]`, {
|
|
379
|
+
detail: { a, b },
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return ibeta(post.alpha, post.beta, b) - ibeta(post.alpha, post.beta, a);
|
|
383
|
+
}
|
|
384
|
+
/* ─────────────────────────────── effect size ─────────────────────────── */
|
|
385
|
+
/**
|
|
386
|
+
* Newcombe's hybrid-score interval for a difference of proportions (p₂ − p₁).
|
|
387
|
+
* Built from the two Wilson intervals, so it stays inside [-1,1] and behaves at
|
|
388
|
+
* the boundaries. This is the "effect size with a CI" that replaces "-4.5%".
|
|
389
|
+
*/
|
|
390
|
+
export function diffInterval(s1, n1, s2, n2, z = 1.959963984540054) {
|
|
391
|
+
requireCounts(s1, n1, "diffInterval(arm 1)");
|
|
392
|
+
requireCounts(s2, n2, "diffInterval(arm 2)");
|
|
393
|
+
requireZ(z, "diffInterval");
|
|
394
|
+
// With an empty arm there is no difference to estimate. Saying so beats
|
|
395
|
+
// reporting `p2 − 0` as if the missing arm had scored zero.
|
|
396
|
+
if (n1 === 0 || n2 === 0)
|
|
397
|
+
return { low: -1, high: 1, point: 0, observations: 0 };
|
|
398
|
+
const w1 = wilsonInterval(s1, n1, z);
|
|
399
|
+
const w2 = wilsonInterval(s2, n2, z);
|
|
400
|
+
const p1 = n1 > 0 ? s1 / n1 : 0;
|
|
401
|
+
const p2 = n2 > 0 ? s2 / n2 : 0;
|
|
402
|
+
const lower = p2 - p1 - Math.sqrt((p2 - w2.low) ** 2 + (w1.high - p1) ** 2);
|
|
403
|
+
const upper = p2 - p1 + Math.sqrt((w2.high - p2) ** 2 + (p1 - w1.low) ** 2);
|
|
404
|
+
return {
|
|
405
|
+
low: Math.max(-1, lower),
|
|
406
|
+
high: Math.min(1, upper),
|
|
407
|
+
point: p2 - p1,
|
|
408
|
+
observations: n1 + n2,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/** Cohen's h, the scale-free effect size for proportions (arcsine transform). */
|
|
412
|
+
export function cohensH(p1, p2) {
|
|
413
|
+
requireProbability(p1, "p1", "cohensH");
|
|
414
|
+
requireProbability(p2, "p2", "cohensH");
|
|
415
|
+
return 2 * Math.asin(Math.sqrt(p2)) - 2 * Math.asin(Math.sqrt(p1));
|
|
416
|
+
}
|
|
417
|
+
/** Pooled two-proportion z-test, two-sided. */
|
|
418
|
+
export function twoProportionZTest(s1, n1, s2, n2) {
|
|
419
|
+
requireCounts(s1, n1, "twoProportionZTest(arm 1)");
|
|
420
|
+
requireCounts(s2, n2, "twoProportionZTest(arm 2)");
|
|
421
|
+
if (n1 === 0 || n2 === 0)
|
|
422
|
+
return { statistic: 0, p: 1 };
|
|
423
|
+
const p1 = s1 / n1, p2 = s2 / n2;
|
|
424
|
+
const pool = (s1 + s2) / (n1 + n2);
|
|
425
|
+
const se = Math.sqrt(pool * (1 - pool) * (1 / n1 + 1 / n2));
|
|
426
|
+
if (se === 0)
|
|
427
|
+
return { statistic: 0, p: 1 };
|
|
428
|
+
const z = (p2 - p1) / se;
|
|
429
|
+
return { statistic: z, p: 2 * (1 - normalCdf(Math.abs(z))) };
|
|
430
|
+
}
|
|
431
|
+
const logChoose = (n, k) => logGamma(n + 1) - logGamma(k + 1) - logGamma(n - k + 1);
|
|
432
|
+
/**
|
|
433
|
+
* Fisher's exact test on a 2×2 table [[a,b],[c,d]], two-sided by the
|
|
434
|
+
* sum-of-small-probabilities convention. Exact, which matters because eval
|
|
435
|
+
* cases are run 8 to 30 times, not 8000.
|
|
436
|
+
*/
|
|
437
|
+
export function fisherExact2x2(a, b, cc, d) {
|
|
438
|
+
for (const [name, v] of [
|
|
439
|
+
["a", a],
|
|
440
|
+
["b", b],
|
|
441
|
+
["c", cc],
|
|
442
|
+
["d", d],
|
|
443
|
+
]) {
|
|
444
|
+
if (!Number.isInteger(v) || v < 0) {
|
|
445
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `fisherExact2x2: cell ${name} must be a non-negative integer, got ${v}`, {
|
|
446
|
+
detail: { a, b, c: cc, d },
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const r1 = a + b, r2 = cc + d, c1 = a + cc, n = r1 + r2;
|
|
451
|
+
if (n === 0)
|
|
452
|
+
return { statistic: 1, p: 1 };
|
|
453
|
+
const logP = (x) => logChoose(r1, x) + logChoose(r2, c1 - x) - logChoose(n, c1);
|
|
454
|
+
const pObs = Math.exp(logP(a));
|
|
455
|
+
const lo = Math.max(0, c1 - r2), hi = Math.min(r1, c1);
|
|
456
|
+
let p = 0;
|
|
457
|
+
for (let x = lo; x <= hi; x++) {
|
|
458
|
+
const px = Math.exp(logP(x));
|
|
459
|
+
if (px <= pObs * (1 + 1e-7))
|
|
460
|
+
p += px;
|
|
461
|
+
}
|
|
462
|
+
const oddsRatio = b * cc === 0 ? Infinity : (a * d) / (b * cc);
|
|
463
|
+
return { statistic: oddsRatio, p: Math.min(1, p) };
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Wald's Sequential Probability Ratio Test for a Bernoulli stream.
|
|
467
|
+
*
|
|
468
|
+
* logLR = s·log(p₁/p₀) + (n−s)·log((1−p₁)/(1−p₀))
|
|
469
|
+
* accept H₁ when logLR ≥ log((1−β)/α); accept H₀ when logLR ≤ log(β/(1−α)).
|
|
470
|
+
*
|
|
471
|
+
* The whole point: it is evaluated after *every* batch, so a case that is
|
|
472
|
+
* obviously fine (or obviously broken) stops consuming samples immediately.
|
|
473
|
+
*/
|
|
474
|
+
export function sprtDecision(opts) {
|
|
475
|
+
const { successes: s, trials: n, p0, p1 } = opts;
|
|
476
|
+
requireCounts(s, n, "sprtDecision");
|
|
477
|
+
requireProbability(p0, "p0", "sprtDecision");
|
|
478
|
+
requireProbability(p1, "p1", "sprtDecision");
|
|
479
|
+
const alpha = opts.alpha ?? 0.05;
|
|
480
|
+
const beta = opts.beta ?? 0.1;
|
|
481
|
+
// The two walls are `log((1-beta)/alpha)` and `log(beta/(1-alpha))`, and
|
|
482
|
+
// these were the arguments nothing checked. An alpha at or above 1 makes the
|
|
483
|
+
// second one the log of a negative number, so `lower` came back NaN, every
|
|
484
|
+
// comparison against it was false, and the test reported CONTINUE for ever
|
|
485
|
+
// against a wall that is not a number. An alpha of 0 puts `upper` at
|
|
486
|
+
// Infinity, which no evidence ever crosses. Open at both ends: an error rate
|
|
487
|
+
// of 0 is a test that never decides and one of 1 is a test that always does.
|
|
488
|
+
// At `alpha + beta >= 1` the walls meet or cross, so a test at 0 runs and a
|
|
489
|
+
// log likelihood ratio of 0 already sat on the H1 wall and answered ACCEPT_H1.
|
|
490
|
+
requireErrorRates(alpha, beta, "sprtDecision");
|
|
491
|
+
const e = 1e-12;
|
|
492
|
+
const cl = (x) => Math.min(1 - e, Math.max(e, x));
|
|
493
|
+
const q0 = cl(p0), q1 = cl(p1);
|
|
494
|
+
const logLR = s * Math.log(q1 / q0) + (n - s) * Math.log((1 - q1) / (1 - q0));
|
|
495
|
+
const upper = Math.log((1 - beta) / alpha);
|
|
496
|
+
const lower = Math.log(beta / (1 - alpha));
|
|
497
|
+
const decision = logLR >= upper ? "ACCEPT_H1" : logLR <= lower ? "ACCEPT_H0" : "CONTINUE";
|
|
498
|
+
const progress = logLR >= 0 ? logLR / upper : -(logLR / lower);
|
|
499
|
+
return { decision, logLR, upper, lower, progress: Math.max(-1, Math.min(1, progress)) };
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* The exponent that tilts the likelihood ratio into a martingale at rate `p`:
|
|
503
|
+
* the one `h != 0` with `E_p[λ^h] = 1`, where `λ` is the per-trial likelihood
|
|
504
|
+
* ratio. Wald's operating characteristic is a closed form in it.
|
|
505
|
+
*
|
|
506
|
+
* `g(h) = p·r₁^h + (1−p)·r₀^h − 1` is convex with `g(0) = 0` and
|
|
507
|
+
* `g'(0) = E_p[log λ]`, the drift, so the second root sits on the side opposite
|
|
508
|
+
* the drift's sign and bisection finds it. `h` is exactly −1 at `p1` and exactly
|
|
509
|
+
* +1 at `p0`, which is what pins the OC to `1 − β` and `α` there.
|
|
510
|
+
*
|
|
511
|
+
* Returns ±Infinity when the bracket reaches 2^20 without finding a root. That
|
|
512
|
+
* is exactly `p = 0` or `p = 1`, where one outcome is impossible so the test
|
|
513
|
+
* cannot be wrong about which wall it reaches. The cap is safe for the rest
|
|
514
|
+
* because the operating characteristic has saturated to 1 or 0 to machine
|
|
515
|
+
* precision long before `|h|` gets there, so the ±Infinity branch returns the
|
|
516
|
+
* answer the bisection would have.
|
|
517
|
+
*/
|
|
518
|
+
function sprtTilt(p, logR1, logR0, drift) {
|
|
519
|
+
const g = (h) => p * Math.exp(h * logR1) + (1 - p) * Math.exp(h * logR0) - 1;
|
|
520
|
+
const side = drift > 0 ? -1 : 1;
|
|
521
|
+
let hi = 1;
|
|
522
|
+
while (hi <= 1 << 20 && !(g(side * hi) > 0))
|
|
523
|
+
hi *= 2;
|
|
524
|
+
if (!(g(side * hi) > 0))
|
|
525
|
+
return side * Infinity;
|
|
526
|
+
let lo = 0;
|
|
527
|
+
for (let i = 0; i < 200 && hi - lo > Math.max(1e-16, hi * 1e-16); i++) {
|
|
528
|
+
const mid = (lo + hi) / 2;
|
|
529
|
+
if (g(side * mid) > 0)
|
|
530
|
+
hi = mid;
|
|
531
|
+
else
|
|
532
|
+
lo = mid;
|
|
533
|
+
}
|
|
534
|
+
return (side * (lo + hi)) / 2;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Expected sample number for Wald's SPRT under a true rate p (Wald's
|
|
538
|
+
* approximation).
|
|
539
|
+
*
|
|
540
|
+
* `(L₁·log A + (1−L₁)·log B) / E_p[log λ]`, where `L₁` is the probability of
|
|
541
|
+
* finishing at the H₁ wall. Exactness here is about `L₁` and not about the
|
|
542
|
+
* result: the formula treats the test as stopping exactly on a wall, where a
|
|
543
|
+
* real one overshoots it, so the answer under-states the true expected sample
|
|
544
|
+
* number. `L₁` is Wald's operating characteristic in exact form,
|
|
545
|
+
* `(1 − B^h) / (A^h − B^h)` at the tilt `h` above, rather than the
|
|
546
|
+
* textbook three-point interpolation: both the numerator and the drift have to
|
|
547
|
+
* change sign at the same rate, and an interpolation that misses that rate by
|
|
548
|
+
* even a little returns a *negative* number of runs. The version this replaces
|
|
549
|
+
* interpolated `L₁` as `(p − p1)/(p0 − p1)`, which rises from 0 at `p1` to 1 at
|
|
550
|
+
* `p0` where the OC falls from `1 − β` to `α`, so it matched neither hypothesis
|
|
551
|
+
* and came out negative over most of the corridor. Every caller here tests the
|
|
552
|
+
* result for `> 0` and falls back to `maxTrials`, so a case landing in the
|
|
553
|
+
* corridor was priced at the per-case cap: a 9/30 baseline, whose posterior
|
|
554
|
+
* median sits above its own rate, costs 32 runs and was quoted at 96.
|
|
555
|
+
* `test/exports.test.ts` pins both the positivity and that case.
|
|
556
|
+
*/
|
|
557
|
+
export function sprtExpectedN(p, p0, p1, alpha = 0.05, beta = 0.1) {
|
|
558
|
+
requireProbability(p, "p", "sprtExpectedN");
|
|
559
|
+
requireProbability(p0, "p0", "sprtExpectedN");
|
|
560
|
+
requireProbability(p1, "p1", "sprtExpectedN");
|
|
561
|
+
// Same two walls as `sprtDecision`, and the same gap. The consequence here is
|
|
562
|
+
// quieter and worse: an alpha at or above 1 makes `B` the log of a negative
|
|
563
|
+
// number and the quotient NaN, and an alpha of 0 sends `A` to Infinity.
|
|
564
|
+
// `plan.ts` and `frontier.ts` both gate on `Number.isFinite(raw) && raw > 0`
|
|
565
|
+
// and quote `maxTrials` when that fails, which both answers do, so a plan
|
|
566
|
+
// built on an out-of-range error rate priced every case at the per-case cap
|
|
567
|
+
// and reported it as a budget, with nothing to say it was not a real answer.
|
|
568
|
+
// At `alpha + beta >= 1` the test decides before its first run, and this
|
|
569
|
+
// answered 0 or a small positive number instead of saying so.
|
|
570
|
+
requireErrorRates(alpha, beta, "sprtExpectedN");
|
|
571
|
+
const A = Math.log((1 - beta) / alpha);
|
|
572
|
+
const B = Math.log(beta / (1 - alpha));
|
|
573
|
+
const logR1 = Math.log(p1 / p0);
|
|
574
|
+
const logR0 = Math.log((1 - p1) / (1 - p0));
|
|
575
|
+
const drift = p * logR1 + (1 - p) * logR0;
|
|
576
|
+
const second = p * logR1 * logR1 + (1 - p) * logR0 * logR0;
|
|
577
|
+
// p0 === p1 leaves every trial uninformative, so the drift and the curvature
|
|
578
|
+
// are both 0 and no number of runs ends the test.
|
|
579
|
+
if (second === 0)
|
|
580
|
+
return Infinity;
|
|
581
|
+
// The ASN is 0/0 where the tilt vanishes, and that limit is the one place
|
|
582
|
+
// Wald's formula needs a different expression. What decides whether the
|
|
583
|
+
// quotient is really 0/0 is the *tilt*, not the drift: near the zero
|
|
584
|
+
// `h = −2·drift / second`, and `second` scales with the square of the gap
|
|
585
|
+
// between the hypotheses, so one fixed threshold on `|drift|` means a
|
|
586
|
+
// different threshold on `h` for every pair of them. A guard of
|
|
587
|
+
// `|drift| < 1e-6` covered the whole corridor of any pair closer together
|
|
588
|
+
// than about an `mde` of 1e-3, including the two hypotheses themselves, where
|
|
589
|
+
// `h` is exactly −1 and +1 and the general form reproduces Wald's endpoint
|
|
590
|
+
// values rather than merely approaching them: for (0.5, 0.4999) it returned
|
|
591
|
+
// 1.37x Wald's value at `p1` and 1.63x at `p0`, which is the one property
|
|
592
|
+
// this function is documented to preserve. Scaling by `second` fires on a
|
|
593
|
+
// vanishing tilt instead.
|
|
594
|
+
//
|
|
595
|
+
// The constant is a minimax between the two forms. The limit is first order
|
|
596
|
+
// in the tilt, so its error is of order `|h|` and wants the threshold small;
|
|
597
|
+
// the general form differences exponentials that vanish with the drift, twice
|
|
598
|
+
// over, so its error grows as `|h|` shrinks and wants it large. They cross
|
|
599
|
+
// near 3e-4: `test/exports.test.ts` walks the band in tilt across every pair
|
|
600
|
+
// it checks and pins the gap either side of the seam under 2e-3.
|
|
601
|
+
if (2 * Math.abs(drift) < 3e-4 * second)
|
|
602
|
+
return (-A * B) / second;
|
|
603
|
+
const h = sprtTilt(p, logR1, logR0, drift);
|
|
604
|
+
return (sprtAcceptH1(h, A, B, drift) * (A - B) + B) / drift;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Wald's operating characteristic at the tilt `h`: the probability the test
|
|
608
|
+
* finishes at the H₁ wall, `(1 − B^h) / (A^h − B^h)`.
|
|
609
|
+
*
|
|
610
|
+
* `A` and `B` arrive as logs, and `|h|` reaches the hundreds for a rate far
|
|
611
|
+
* outside the two hypotheses, so the two powers are evaluated relative to
|
|
612
|
+
* whichever of them is largest. Written as plain `exp`, a tilt of −662, which is
|
|
613
|
+
* p = 0.0025 against p₀ = 0.999 and p₁ = 0.99, overflows `B^h` to Infinity and
|
|
614
|
+
* the ratio comes back NaN; `expectedSequentialSamples` and
|
|
615
|
+
* `typicalObservationsPerCase` both test the result for `> 0`, so a NaN there
|
|
616
|
+
* silently prices the case at `maxTrials`.
|
|
617
|
+
*/
|
|
618
|
+
function sprtAcceptH1(h, A, B, drift) {
|
|
619
|
+
// No tilt exists at p = 0 or p = 1: one outcome is impossible, so the drift
|
|
620
|
+
// never reverses and the wall it reaches is certain.
|
|
621
|
+
if (!Number.isFinite(h))
|
|
622
|
+
return drift > 0 ? 1 : 0;
|
|
623
|
+
const a = h * A;
|
|
624
|
+
const b = h * B;
|
|
625
|
+
const m = Math.max(0, a, b);
|
|
626
|
+
return (Math.exp(-m) - Math.exp(b - m)) / (Math.exp(a - m) - Math.exp(b - m));
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Benjamini-Hochberg FDR control.
|
|
630
|
+
* With 200 cases at α=0.05 you expect ~10 false alarms per clean run; BH is why
|
|
631
|
+
* a green suite stays green.
|
|
632
|
+
*/
|
|
633
|
+
export function bhCorrect(pvalues, q = 0.05) {
|
|
634
|
+
// A NaN here does not throw, it silently corrupts the sort and therefore the
|
|
635
|
+
// whole step-up, so it is caught rather than propagated.
|
|
636
|
+
for (let i = 0; i < pvalues.length; i++) {
|
|
637
|
+
requireProbability(pvalues[i], `p[${i}]`, "bhCorrect");
|
|
638
|
+
}
|
|
639
|
+
requireProbability(q, "q", "bhCorrect");
|
|
640
|
+
const m = pvalues.length;
|
|
641
|
+
const rejected = new Array(m).fill(false);
|
|
642
|
+
const adjusted = new Array(m).fill(1);
|
|
643
|
+
if (m === 0)
|
|
644
|
+
return { rejected, adjusted, cutoff: 0, discoveries: 0 };
|
|
645
|
+
const order = pvalues.map((p, i) => ({ p, i })).sort((a, b) => a.p - b.p);
|
|
646
|
+
let kMax = -1;
|
|
647
|
+
for (let k = 0; k < m; k++)
|
|
648
|
+
if (order[k].p <= ((k + 1) / m) * q)
|
|
649
|
+
kMax = k;
|
|
650
|
+
for (let k = 0; k <= kMax; k++)
|
|
651
|
+
rejected[order[k].i] = true;
|
|
652
|
+
// step-up monotone adjustment: q(k) = min_{j ≥ k} ( m/j · p(j) )
|
|
653
|
+
let running = 1;
|
|
654
|
+
for (let k = m - 1; k >= 0; k--) {
|
|
655
|
+
running = Math.min(running, (m / (k + 1)) * order[k].p);
|
|
656
|
+
adjusted[order[k].i] = Math.min(1, running);
|
|
657
|
+
}
|
|
658
|
+
return {
|
|
659
|
+
rejected,
|
|
660
|
+
adjusted,
|
|
661
|
+
cutoff: kMax >= 0 ? order[kMax].p : 0,
|
|
662
|
+
discoveries: kMax + 1,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Log marginal likelihood of s successes in n Bernoulli trials when the rate
|
|
667
|
+
* carries a Beta(a,b) prior, the beta-binomial, minus the C(n,s) term (which
|
|
668
|
+
* cancels in every ratio we take).
|
|
669
|
+
*/
|
|
670
|
+
export function logMarginalBetaBinomial(s, n, a, b) {
|
|
671
|
+
// `s` may be fractional here, the planner evaluates the mean trajectory, so
|
|
672
|
+
// this checks the range rather than integrality.
|
|
673
|
+
if (!Number.isFinite(s) || !Number.isFinite(n) || s < 0 || n < 0 || s > n) {
|
|
674
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `logMarginalBetaBinomial: need 0 ≤ s ≤ n, got ${s}/${n}`, {
|
|
675
|
+
detail: { s, n },
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
if (!(a > 0) || !(b > 0) || !Number.isFinite(a) || !Number.isFinite(b)) {
|
|
679
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "logMarginalBetaBinomial: prior parameters must be finite and positive", {
|
|
680
|
+
detail: { a, b },
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
return logBeta(a + s, b + n - s) - logBeta(a, b);
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* A prior concentration, which multiplies a rate to make Beta shapes. Every
|
|
687
|
+
* caller floors the product, so a non-positive concentration does not
|
|
688
|
+
* produce a bad shape, it produces a floored Beta and a plausible number
|
|
689
|
+
* from a prior nobody asked for.
|
|
690
|
+
*/
|
|
691
|
+
function requireConcentration(k, name, where) {
|
|
692
|
+
if (!(k > 0) || !Number.isFinite(k)) {
|
|
693
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `${where}: ${name} must be finite and positive`, {
|
|
694
|
+
detail: { [name]: k },
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* The most runs a sample-size search may try. Below 1, or NaN, the doubling
|
|
700
|
+
* loop never ran and the search answered Infinity, "no number of runs will
|
|
701
|
+
* do", for a design that needs a few hundred.
|
|
702
|
+
*/
|
|
703
|
+
function requireSearchCap(cap, name, where) {
|
|
704
|
+
if (!(cap >= 1) || !Number.isFinite(cap)) {
|
|
705
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `${where}: ${name} must be finite and at least 1, got ${cap}`, {
|
|
706
|
+
detail: { [name]: cap, where },
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* The log e-value bar a sample-size search sizes a study to. A NaN bar loses
|
|
712
|
+
* every comparison the searches make, so one answered a single pair and the
|
|
713
|
+
* other Infinity, "no number of runs will do", for the same mistake.
|
|
714
|
+
*/
|
|
715
|
+
function requireLogThreshold(logThreshold, where) {
|
|
716
|
+
if (!Number.isFinite(logThreshold)) {
|
|
717
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `${where}: logThreshold must be finite, got ${logThreshold}`, {
|
|
718
|
+
detail: { logThreshold },
|
|
719
|
+
hint: "this is a log e-value bar, such as Math.log(ebhSoloThreshold(m, fdr))",
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// Below 1 a shape piles alternative mass onto 0 or 1, which pushed type I error past alpha for thin baselines.
|
|
724
|
+
const ALT_SHAPE_FLOOR = 1;
|
|
725
|
+
/**
|
|
726
|
+
* A **two-sample e-value** for "the candidate is worse than the baseline".
|
|
727
|
+
*
|
|
728
|
+
* The one-sample alternative, test the candidate against the baseline's point
|
|
729
|
+
* estimate, quietly assumes the baseline rate is known. It is not: it came
|
|
730
|
+
* from 24 noisy runs, and a baseline that got lucky manufactures regressions
|
|
731
|
+
* that were never there.
|
|
732
|
+
*
|
|
733
|
+
* So the null keeps the baseline's uncertainty: p ~ Beta(1+s_b, 1+f_b), and the
|
|
734
|
+
* alternative is the same amount of information centred `mde` lower. The ratio
|
|
735
|
+
* of the two marginal likelihoods is a Bayes factor, and a Bayes factor between
|
|
736
|
+
* two joint distributions of the same sequence is a non-negative martingale
|
|
737
|
+
* with mean 1 under the null's own marginal, here the shared rate drawn from
|
|
738
|
+
* the uniform prior. At a fixed rate the mean can exceed 1, so the frequentist
|
|
739
|
+
* type I error is checked exactly in `test/error-control.test.ts` instead.
|
|
740
|
+
*/
|
|
741
|
+
export function twoSampleLogE(candidateSuccesses, candidateTrials, baselineSuccesses, baselineTrials, mde,
|
|
742
|
+
/**
|
|
743
|
+
* Concentration of the alternative prior. Low values spread the alternative
|
|
744
|
+
* over "anything materially worse", which is what actually happens when a
|
|
745
|
+
* change breaks a case, it rarely lands exactly `mde` below.
|
|
746
|
+
*/
|
|
747
|
+
altConcentration = 8) {
|
|
748
|
+
// The central evidence primitive, and the one every verdict rests on, so it
|
|
749
|
+
// gets the same domain discipline as everything else in this file rather
|
|
750
|
+
// than turning `5 successes in 3 trials` into a plausible-looking Bayes
|
|
751
|
+
// factor. (Found in adversarial review; it was the one function that skipped
|
|
752
|
+
// the contract its own module header states.)
|
|
753
|
+
requireCounts(candidateSuccesses, candidateTrials, "twoSampleLogE(candidate)");
|
|
754
|
+
requireCounts(baselineSuccesses, baselineTrials, "twoSampleLogE(baseline)");
|
|
755
|
+
// Open, not closed: `requireProbability` here accepted an mde of 0 and 1,
|
|
756
|
+
// which `gate`, `shouldStop`, `makePlan` and `twoSamplePriors` all refuse, so
|
|
757
|
+
// the primitive was looser than every entry point that calls it.
|
|
758
|
+
requireOpenProbability(mde, "mde", "twoSampleLogE");
|
|
759
|
+
requireConcentration(altConcentration, "altConcentration", "twoSampleLogE");
|
|
760
|
+
const a0 = 1 + baselineSuccesses;
|
|
761
|
+
const b0 = 1 + baselineTrials - baselineSuccesses;
|
|
762
|
+
const kappa = a0 + b0;
|
|
763
|
+
const shifted = Math.min(0.995, Math.max(0.005, a0 / kappa - mde));
|
|
764
|
+
const k1 = altConcentration;
|
|
765
|
+
const a1 = Math.max(ALT_SHAPE_FLOOR, k1 * shifted);
|
|
766
|
+
const b1 = Math.max(ALT_SHAPE_FLOOR, k1 * (1 - shifted));
|
|
767
|
+
const logE = logMarginalBetaBinomial(candidateSuccesses, candidateTrials, a1, b1) -
|
|
768
|
+
logMarginalBetaBinomial(candidateSuccesses, candidateTrials, a0, b0);
|
|
769
|
+
return atOrAboveBaseline(candidateSuccesses / candidateTrials, baselineSuccesses, baselineTrials)
|
|
770
|
+
? Math.min(0, logE)
|
|
771
|
+
: logE;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* The alternative is wider than the null, so the Bayes factor alone also grows
|
|
775
|
+
* for a candidate far ABOVE its baseline: 96/96 against 30/60 scored e^19.5 and
|
|
776
|
+
* gate certified that improvement as a regression. A candidate observed at or
|
|
777
|
+
* above the baseline's rate is therefore held to e ≤ 1. Lowering an e-value
|
|
778
|
+
* never breaks one, so every guarantee above still holds.
|
|
779
|
+
*/
|
|
780
|
+
const atOrAboveBaseline = (candidateRate, baselineSuccesses, baselineTrials) => baselineTrials > 0 && candidateRate >= baselineSuccesses / baselineTrials;
|
|
781
|
+
export function ebhCorrect(evalues, q = 0.05) {
|
|
782
|
+
for (let i = 0; i < evalues.length; i++) {
|
|
783
|
+
const e = evalues[i];
|
|
784
|
+
if (!Number.isFinite(e) || e < 0) {
|
|
785
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `ebhCorrect: e[${i}] must be finite and ≥ 0, got ${e}`, {
|
|
786
|
+
detail: { index: i, value: e },
|
|
787
|
+
hint: "an e-value is a non-negative random variable with mean ≤ 1 under the null; Infinity and NaN corrupt the ranking",
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
requireProbability(q, "q", "ebhCorrect");
|
|
792
|
+
const m = evalues.length;
|
|
793
|
+
const rejected = new Array(m).fill(false);
|
|
794
|
+
if (m === 0)
|
|
795
|
+
return { rejected, discoveries: 0, threshold: Infinity };
|
|
796
|
+
const order = evalues.map((e, i) => ({ e, i })).sort((a, b) => b.e - a.e);
|
|
797
|
+
let kStar = 0;
|
|
798
|
+
for (let k = 1; k <= m; k++)
|
|
799
|
+
if (order[k - 1].e >= m / (q * k))
|
|
800
|
+
kStar = k;
|
|
801
|
+
for (let k = 0; k < kStar; k++)
|
|
802
|
+
rejected[order[k].i] = true;
|
|
803
|
+
return { rejected, discoveries: kStar, threshold: kStar > 0 ? m / (q * kStar) : m / q };
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* The e-value a case must reach to be certain of rejection whatever the others do.
|
|
807
|
+
*
|
|
808
|
+
* `q` is checked as `ebhCorrect` checks it, so a level of 0 answers Infinity.
|
|
809
|
+
*/
|
|
810
|
+
export function ebhSoloThreshold(m, q = 0.05) {
|
|
811
|
+
if (!Number.isInteger(m) || m < 1) {
|
|
812
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `ebhSoloThreshold: m must be a positive integer, got ${m}`, {
|
|
813
|
+
detail: { m },
|
|
814
|
+
hint: "m is the number of cases in the e-BH family",
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
requireProbability(q, "q", "ebhSoloThreshold");
|
|
818
|
+
return m / q;
|
|
819
|
+
}
|
|
820
|
+
/* ──────────────────────────────── planning ──────────────────────────── */
|
|
821
|
+
/**
|
|
822
|
+
* Fixed-N sample size per arm for detecting a `delta` drop from `p0` at the
|
|
823
|
+
* given error rates. This is the "naive approach" peeksafe is measured against.
|
|
824
|
+
*
|
|
825
|
+
* Returns **Infinity** when the effect is one the case cannot suffer, a case
|
|
826
|
+
* whose baseline rate is 10% cannot lose 15 points, and no number of samples
|
|
827
|
+
* will find a drop that is arithmetically impossible. The old version clamped
|
|
828
|
+
* `p1` to 1e-6 and answered "44 runs", which is a plausible-looking lie
|
|
829
|
+
* and exactly the sort of thing a budget planner must not do.
|
|
830
|
+
*/
|
|
831
|
+
export function sampleSizeTwoProportion(p0, delta, alpha = 0.05, beta = 0.1) {
|
|
832
|
+
requireProbability(p0, "p0", "sampleSizeTwoProportion");
|
|
833
|
+
// Checked before the Infinity branch, so the same error rate is refused
|
|
834
|
+
// whatever the effect. An alpha of 1 sets `zA` to Φ⁻¹(0.5) = 0, which drops
|
|
835
|
+
// the type I error term out of the formula and answers a run count for a
|
|
836
|
+
// design with no error control in it. This is the comparator the README
|
|
837
|
+
// measures peeksafe against, so a plausible number here understates what the
|
|
838
|
+
// naive design costs.
|
|
839
|
+
requireOpenProbability(alpha, "alpha", "sampleSizeTwoProportion");
|
|
840
|
+
requireOpenProbability(beta, "beta", "sampleSizeTwoProportion");
|
|
841
|
+
// Both comparisons below are false for NaN, so it fell through to the formula and came back NaN.
|
|
842
|
+
if (Number.isNaN(delta)) {
|
|
843
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "sampleSizeTwoProportion: delta must be a number, got NaN", {
|
|
844
|
+
detail: { p0, delta },
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
if (delta <= 0 || delta >= p0)
|
|
848
|
+
return Infinity;
|
|
849
|
+
const p1 = p0 - delta;
|
|
850
|
+
const pbar = (p0 + p1) / 2;
|
|
851
|
+
const zA = normalQuantile(1 - alpha / 2);
|
|
852
|
+
const zB = normalQuantile(1 - beta);
|
|
853
|
+
const n = (zA * Math.sqrt(2 * pbar * (1 - pbar)) + zB * Math.sqrt(p0 * (1 - p0) + p1 * (1 - p1))) ** 2 /
|
|
854
|
+
(delta * delta);
|
|
855
|
+
return Math.ceil(n);
|
|
856
|
+
}
|
|
857
|
+
/** Shannon entropy in bits of a Bernoulli(p), used as a raw instability signal. */
|
|
858
|
+
export function bernoulliEntropy(p) {
|
|
859
|
+
requireProbability(p, "p", "bernoulliEntropy");
|
|
860
|
+
return p <= 0 || p >= 1 ? 0 : -(p * Math.log2(p) + (1 - p) * Math.log2(1 - p));
|
|
861
|
+
}
|
|
862
|
+
/* ═══════════════════ power economics: what will this cost? ════════════════
|
|
863
|
+
*
|
|
864
|
+
* Everything below exists to answer one question before any money is spent:
|
|
865
|
+
* "given this baseline, how many runs does detecting an `mde`-point drop take,
|
|
866
|
+
* and are there cases where the answer is *no number*?"
|
|
867
|
+
*/
|
|
868
|
+
/** log of the Beta(a,b) density at p. */
|
|
869
|
+
export function logBetaPdf(p, a, b) {
|
|
870
|
+
requireProbability(p, "p", "logBetaPdf");
|
|
871
|
+
if (p <= 0 || p >= 1) {
|
|
872
|
+
// density is 0 or ∞ at the boundary depending on the shape; the planner
|
|
873
|
+
// only ever evaluates strictly inside, so clamp rather than return ±∞.
|
|
874
|
+
p = Math.min(1 - 1e-12, Math.max(1e-12, p));
|
|
875
|
+
}
|
|
876
|
+
return (a - 1) * Math.log(p) + (b - 1) * Math.log(1 - p) - logBeta(a, b);
|
|
877
|
+
}
|
|
878
|
+
/** The two prior shapes `twoSampleLogE` compares. Exposed so the planner can
|
|
879
|
+
* reason about the *limit* of the evidence, not just its value at some n. */
|
|
880
|
+
export function twoSamplePriors(baselineSuccesses, baselineTrials, mde, altConcentration = 8) {
|
|
881
|
+
requireCounts(baselineSuccesses, baselineTrials, "twoSamplePriors");
|
|
882
|
+
// The floor below is what makes an unguarded concentration dangerous rather
|
|
883
|
+
// than merely wrong: at `altConcentration <= 0` both shapes collapse onto it
|
|
884
|
+
// and the caller gets a decision from Beta(1, 1) instead of an error.
|
|
885
|
+
// `twoSampleLogE` guarded itself and this did not, so the same option threw
|
|
886
|
+
// on `evidence: 'bayes'` and silently moved the verdict on `'universal'`.
|
|
887
|
+
requireConcentration(altConcentration, "altConcentration", "twoSamplePriors");
|
|
888
|
+
// `mde` was the same gap one argument over, and the clamp below hides it the
|
|
889
|
+
// same way: `Math.min(0.995, Math.max(0.005, ...))` turns a negative mde into
|
|
890
|
+
// an alternative centred *above* the baseline, so asking to detect a drop of
|
|
891
|
+
// -15 points returned evidence rather than a refusal. The endpoints are out
|
|
892
|
+
// too, because mde is an effect and not a rate: a drop of 0 is one every
|
|
893
|
+
// design answers "no" to for reasons that look like a ceiling problem.
|
|
894
|
+
requireOpenProbability(mde, "mde", "twoSamplePriors");
|
|
895
|
+
const a0 = 1 + baselineSuccesses;
|
|
896
|
+
const b0 = 1 + baselineTrials - baselineSuccesses;
|
|
897
|
+
const kappa = a0 + b0;
|
|
898
|
+
const shifted = Math.min(0.995, Math.max(0.005, a0 / kappa - mde));
|
|
899
|
+
return {
|
|
900
|
+
nullPrior: { a: a0, b: b0 },
|
|
901
|
+
altPrior: {
|
|
902
|
+
a: Math.max(ALT_SHAPE_FLOOR, altConcentration * shifted),
|
|
903
|
+
b: Math.max(ALT_SHAPE_FLOOR, altConcentration * (1 - shifted)),
|
|
904
|
+
},
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* The **evidence ceiling** of the unpaired two-sample e-value.
|
|
909
|
+
*
|
|
910
|
+
* This is the single most useful number the planner has, and it is not
|
|
911
|
+
* obvious: the two-sample Bayes factor does *not* grow without bound in the
|
|
912
|
+
* number of candidate runs. Both marginals are the same binomial likelihood
|
|
913
|
+
* under different priors, so by Laplace approximation
|
|
914
|
+
*
|
|
915
|
+
* log m_a(s, n) = n·h(p̂) + log f_a(p̂) + ½log(2π p̂(1−p̂)/n) + O(1/n)
|
|
916
|
+
*
|
|
917
|
+
* and every term except `log f(p̂)` is common to numerator and denominator.
|
|
918
|
+
* The ratio therefore converges to the **prior density ratio at the observed
|
|
919
|
+
* rate**:
|
|
920
|
+
*
|
|
921
|
+
* lim_{n→∞} log E = log f_alt(p) − log f_null(p)
|
|
922
|
+
*
|
|
923
|
+
* Consequences `gate` and `makePlan` report directly:
|
|
924
|
+
* - a case whose ceiling sits below the e-BH bar `m/(q·k)` is **undetectable
|
|
925
|
+
* at any budget**: buying more candidate runs cannot certify it;
|
|
926
|
+
* - the remedy is more *baseline* runs, which sharpen f_null and raise the
|
|
927
|
+
* ceiling, that is the amortised baseline, quantified;
|
|
928
|
+
* - the paired design (`pairedLogE`) has no such ceiling, because its null is
|
|
929
|
+
* an exact point mass at ½ rather than an estimated prior.
|
|
930
|
+
*/
|
|
931
|
+
export function evidenceCeilingLogE(pTrue, baselineSuccesses, baselineTrials, mde, altConcentration = 8) {
|
|
932
|
+
requireProbability(pTrue, "pTrue", "evidenceCeilingLogE");
|
|
933
|
+
const { nullPrior, altPrior } = twoSamplePriors(baselineSuccesses, baselineTrials, mde, altConcentration);
|
|
934
|
+
const ceiling = logBetaPdf(pTrue, altPrior.a, altPrior.b) - logBetaPdf(pTrue, nullPrior.a, nullPrior.b);
|
|
935
|
+
return atOrAboveBaseline(pTrue, baselineSuccesses, baselineTrials)
|
|
936
|
+
? Math.min(0, ceiling)
|
|
937
|
+
: ceiling;
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* The **rate** at which the evidence ceiling grows in baseline runs: the
|
|
941
|
+
* Kullback-Leibler divergence `KL(p_b ‖ p)` in nats, with the *baseline* rate
|
|
942
|
+
* as the first argument.
|
|
943
|
+
*
|
|
944
|
+
* This is not the obvious direction, and getting it the wrong way round gives a
|
|
945
|
+
* materially different number (at 85% losing 15 points: 0.0611 the right way,
|
|
946
|
+
* 0.0720 the wrong way). It falls out of the Laplace expansion of the null
|
|
947
|
+
* prior's own density, see `evidenceCeilingAsymptotic`.
|
|
948
|
+
*/
|
|
949
|
+
export function evidenceCeilingSlope(pBaseline, pCandidate) {
|
|
950
|
+
requireProbability(pBaseline, "pBaseline", "evidenceCeilingSlope");
|
|
951
|
+
requireProbability(pCandidate, "pCandidate", "evidenceCeilingSlope");
|
|
952
|
+
const a = Math.min(1 - 1e-12, Math.max(1e-12, pBaseline));
|
|
953
|
+
const b = Math.min(1 - 1e-12, Math.max(1e-12, pCandidate));
|
|
954
|
+
return a * Math.log(a / b) + (1 - a) * Math.log((1 - a) / (1 - b));
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* The evidence ceiling in closed form, to O(1/n_b).
|
|
958
|
+
*
|
|
959
|
+
* `evidenceCeilingLogE` evaluates the exact prior density ratio. This says what
|
|
960
|
+
* that ratio *is*, as a function of the baseline size, and it is the useful
|
|
961
|
+
* form because it inverts:
|
|
962
|
+
*
|
|
963
|
+
* ceiling(n_b) ≈ n_b · KL(p̄_b ‖ p) + log f_alt(p) + ½·log(2π p̄_b(1−p̄_b)/n_b)
|
|
964
|
+
*
|
|
965
|
+
* The leading term comes from Stirling on the null prior's normaliser:
|
|
966
|
+
* `f_null` is a Beta posterior, its log density at `p` is
|
|
967
|
+
* `−n_b·KL(p̄_b ‖ p) − ½log(2π p̄_b(1−p̄_b)/n_b) + O(1/n_b)`, and the alternative
|
|
968
|
+
* prior does not move with `n_b` at all.
|
|
969
|
+
*
|
|
970
|
+
* Two consequences that are the practical point of the whole ceiling result:
|
|
971
|
+
*
|
|
972
|
+
* - **the ceiling grows linearly in baseline runs**, at a rate that is a
|
|
973
|
+
* property of the case's pass rate and the MDE and of nothing else;
|
|
974
|
+
* - so the baseline size a case needs to be detectable at all is roughly
|
|
975
|
+
* `log(m/q) / KL(p̄_b ‖ p̄_b − δ)`, an O(1) formula where `plan.ts` searches.
|
|
976
|
+
* It is a *lower* bound: the two lower-order terms are negative at realistic
|
|
977
|
+
* `n_b`, so the honest quote is that rule of thumb plus about 20%, and
|
|
978
|
+
* `makePlan` keeps reporting the exact search.
|
|
979
|
+
*
|
|
980
|
+
* Matches `evidenceCeilingLogE` to better than 0.1% for `n_b ≥ 240`; checked in
|
|
981
|
+
* `test/paper.test.ts`.
|
|
982
|
+
*/
|
|
983
|
+
export function evidenceCeilingAsymptotic(baselineSuccesses, baselineTrials, pTrue, mde, altConcentration = 8) {
|
|
984
|
+
requireCounts(baselineSuccesses, baselineTrials, "evidenceCeilingAsymptotic");
|
|
985
|
+
requireProbability(pTrue, "pTrue", "evidenceCeilingAsymptotic");
|
|
986
|
+
if (baselineTrials === 0) {
|
|
987
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "evidenceCeilingAsymptotic: needs at least one baseline trial", {
|
|
988
|
+
detail: { baselineTrials },
|
|
989
|
+
hint: "with no baseline there is no null to be uncertain about; use evidenceCeilingLogE, which handles the prior directly",
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
// The expansion is a Laplace approximation around an *interior* rate. A
|
|
993
|
+
// baseline of all passes or all failures has none, and the ½·log(2π p̄(1−p̄)/n)
|
|
994
|
+
// term goes to −∞, which came back as a finite-looking -Infinity rather than
|
|
995
|
+
// an error. `evidenceCeilingLogE` handles those cases exactly; this does not.
|
|
996
|
+
if (baselineSuccesses === 0 || baselineSuccesses === baselineTrials) {
|
|
997
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `evidenceCeilingAsymptotic: the expansion needs an interior baseline rate, got ${baselineSuccesses}/${baselineTrials}`, {
|
|
998
|
+
detail: { baselineSuccesses, baselineTrials },
|
|
999
|
+
hint: "use evidenceCeilingLogE, which evaluates the prior density ratio exactly and is defined at the boundary",
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
// Around the observed baseline rate s/n, with n the baseline trial count. The
|
|
1003
|
+
// null prior is Beta(1+s, 1+n−s), so its shape parameters minus one are
|
|
1004
|
+
// exactly (s, n−s) and the Stirling expansion closes on s/n, shifting to the
|
|
1005
|
+
// posterior mean (s+1)/(n+2) instead leaves a residual that does *not* vanish.
|
|
1006
|
+
const pBar = baselineSuccesses / baselineTrials;
|
|
1007
|
+
const n = baselineTrials;
|
|
1008
|
+
const { altPrior } = twoSamplePriors(baselineSuccesses, baselineTrials, mde, altConcentration);
|
|
1009
|
+
return (n * evidenceCeilingSlope(pBar, pTrue) +
|
|
1010
|
+
logBetaPdf(pTrue, altPrior.a, altPrior.b) +
|
|
1011
|
+
0.5 * Math.log((2 * Math.PI * pBar * (1 - pBar)) / n));
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* The two-sample log e-value along the **mean trajectory**: the value it takes
|
|
1015
|
+
* when the candidate scores exactly its expected `pTrue·n` successes in `n`
|
|
1016
|
+
* runs. Fractional successes are legal, the beta-binomial marginal is a ratio
|
|
1017
|
+
* of Beta functions, which is defined on the reals.
|
|
1018
|
+
*
|
|
1019
|
+
* This is a plug-in for E[log E], not E[log E] itself. `expectedLogEExact`
|
|
1020
|
+
* computes the true expectation by summing over the binomial.
|
|
1021
|
+
*/
|
|
1022
|
+
export function expectedLogE(pTrue, n, baselineSuccesses, baselineTrials, mde, altConcentration = 8) {
|
|
1023
|
+
requireProbability(pTrue, "pTrue", "expectedLogE");
|
|
1024
|
+
if (!(n >= 0) || !Number.isFinite(n)) {
|
|
1025
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "expectedLogE: n must be finite and ≥ 0", {
|
|
1026
|
+
detail: { n },
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
const { nullPrior: n0, altPrior: a1 } = twoSamplePriors(baselineSuccesses, baselineTrials, mde, altConcentration);
|
|
1030
|
+
const s = pTrue * n;
|
|
1031
|
+
const logE = logBeta(a1.a + s, a1.b + n - s) -
|
|
1032
|
+
logBeta(a1.a, a1.b) -
|
|
1033
|
+
(logBeta(n0.a + s, n0.b + n - s) - logBeta(n0.a, n0.b));
|
|
1034
|
+
return n > 0 && atOrAboveBaseline(pTrue, baselineSuccesses, baselineTrials)
|
|
1035
|
+
? Math.min(0, logE)
|
|
1036
|
+
: logE;
|
|
1037
|
+
}
|
|
1038
|
+
/** E[log E] computed exactly, by summing over every binomial outcome. O(n). */
|
|
1039
|
+
export function expectedLogEExact(pTrue, n, baselineSuccesses, baselineTrials, mde, altConcentration = 8) {
|
|
1040
|
+
requireProbability(pTrue, "pTrue", "expectedLogEExact");
|
|
1041
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
1042
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "expectedLogEExact: n must be a non-negative integer", { detail: { n } });
|
|
1043
|
+
}
|
|
1044
|
+
let acc = 0;
|
|
1045
|
+
for (let s = 0; s <= n; s++) {
|
|
1046
|
+
const logW = logGamma(n + 1) -
|
|
1047
|
+
logGamma(s + 1) -
|
|
1048
|
+
logGamma(n - s + 1) +
|
|
1049
|
+
s * Math.log(Math.max(1e-300, pTrue)) +
|
|
1050
|
+
(n - s) * Math.log(Math.max(1e-300, 1 - pTrue));
|
|
1051
|
+
acc +=
|
|
1052
|
+
Math.exp(logW) *
|
|
1053
|
+
twoSampleLogE(s, n, baselineSuccesses, baselineTrials, mde, altConcentration);
|
|
1054
|
+
}
|
|
1055
|
+
return acc;
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Smallest number of candidate runs whose mean-trajectory log e-value clears
|
|
1059
|
+
* `logThreshold`. Returns **Infinity** when the ceiling is below the threshold
|
|
1060
|
+
*, the honest answer to "how many runs would it take", when the answer is
|
|
1061
|
+
* that no number of runs will do.
|
|
1062
|
+
*/
|
|
1063
|
+
export function samplesForEvidence(pTrue, baselineSuccesses, baselineTrials, mde, logThreshold, maxN = 100_000, altConcentration = 8) {
|
|
1064
|
+
requireSearchCap(maxN, "maxN", "samplesForEvidence");
|
|
1065
|
+
requireLogThreshold(logThreshold, "samplesForEvidence");
|
|
1066
|
+
const ceiling = evidenceCeilingLogE(pTrue, baselineSuccesses, baselineTrials, mde, altConcentration);
|
|
1067
|
+
if (!(ceiling > logThreshold))
|
|
1068
|
+
return Infinity;
|
|
1069
|
+
const f = (n) => expectedLogE(pTrue, n, baselineSuccesses, baselineTrials, mde, altConcentration);
|
|
1070
|
+
// exponential search then bisection; f is monotone in n below the ceiling
|
|
1071
|
+
let hi = 1;
|
|
1072
|
+
while (hi < maxN && f(hi) < logThreshold)
|
|
1073
|
+
hi *= 2;
|
|
1074
|
+
if (f(hi) < logThreshold)
|
|
1075
|
+
return Infinity;
|
|
1076
|
+
let lo = Math.floor(hi / 2);
|
|
1077
|
+
while (hi - lo > 1) {
|
|
1078
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
1079
|
+
if (f(mid) >= logThreshold)
|
|
1080
|
+
hi = mid;
|
|
1081
|
+
else
|
|
1082
|
+
lo = mid;
|
|
1083
|
+
}
|
|
1084
|
+
return hi;
|
|
1085
|
+
}
|
|
1086
|
+
/* ──────────────── the always-valid two-sample e-value ───────────────────
|
|
1087
|
+
*
|
|
1088
|
+
* `twoSampleLogE` has mean 1 averaged over the shared rate, not at every fixed
|
|
1089
|
+
* rate. This one is valid at every rate by construction (universal inference,
|
|
1090
|
+
* Wasserman, Ramdas and Balakrishnan 2020): the numerator is a single joint
|
|
1091
|
+
* distribution of baseline and candidate, the uniform mixture for the baseline
|
|
1092
|
+
* followed by the same alternative `twoSampleLogE` uses for the candidate, and
|
|
1093
|
+
* the denominator is the largest likelihood any null rate pair gives the data.
|
|
1094
|
+
* For every null pair the numerator over that pair's likelihood is a test
|
|
1095
|
+
* martingale, and the maximum only lowers it, so Ville's inequality holds.
|
|
1096
|
+
*
|
|
1097
|
+
* It equals `twoSampleLogE` times the uniform mixture of all the data over its
|
|
1098
|
+
* maximum likelihood, so it is never larger, and the price is roughly
|
|
1099
|
+
* ½·log(runs) of evidence. Blocked designs (Turner, Ly and Grünwald's 2x2
|
|
1100
|
+
* e-values) avoid that price but need both arms in every block; a stored
|
|
1101
|
+
* baseline followed by candidate-only runs gives them nothing to work with.
|
|
1102
|
+
*/
|
|
1103
|
+
const xlogy = (x, y) => (x === 0 ? 0 : x * Math.log(y));
|
|
1104
|
+
const logLik = (s, n, p) => xlogy(s, p) + xlogy(n - s, 1 - p);
|
|
1105
|
+
/** Largest log likelihood of the data under the null "candidate rate ≥ baseline rate". */
|
|
1106
|
+
function logNullSup(cs, cn, bs, bn) {
|
|
1107
|
+
const pb = bs / bn;
|
|
1108
|
+
const pc = cn > 0 ? cs / cn : 1;
|
|
1109
|
+
return pc >= pb
|
|
1110
|
+
? logLik(bs, bn, pb) + logLik(cs, cn, pc)
|
|
1111
|
+
: logLik(bs + cs, bn + cn, (bs + cs) / (bn + cn));
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* The two-sample e-value for "the candidate is worse than the baseline", valid
|
|
1115
|
+
* at every pair of rates and at any stopping time. Same arguments as
|
|
1116
|
+
* `twoSampleLogE`, which it never exceeds.
|
|
1117
|
+
*/
|
|
1118
|
+
export function universalTwoSampleLogE(candidateSuccesses, candidateTrials, baselineSuccesses, baselineTrials, mde, altConcentration = 8) {
|
|
1119
|
+
requireCounts(candidateSuccesses, candidateTrials, "universalTwoSampleLogE(candidate)");
|
|
1120
|
+
requireCounts(baselineSuccesses, baselineTrials, "universalTwoSampleLogE(baseline)");
|
|
1121
|
+
if (baselineTrials === 0) {
|
|
1122
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "universalTwoSampleLogE: needs at least one baseline trial", {
|
|
1123
|
+
detail: { baselineTrials },
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
const { altPrior } = twoSamplePriors(baselineSuccesses, baselineTrials, mde, altConcentration);
|
|
1127
|
+
const logQ = logBeta(1 + baselineSuccesses, 1 + baselineTrials - baselineSuccesses) +
|
|
1128
|
+
logMarginalBetaBinomial(candidateSuccesses, candidateTrials, altPrior.a, altPrior.b);
|
|
1129
|
+
return logQ - logNullSup(candidateSuccesses, candidateTrials, baselineSuccesses, baselineTrials);
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* An upper bound on `universalTwoSampleLogE` over every future candidate run
|
|
1133
|
+
* count, for a candidate observed at rate `pTrue`. It plays the part
|
|
1134
|
+
* `evidenceCeilingLogE` plays for the default statistic.
|
|
1135
|
+
*
|
|
1136
|
+
* The alternative's marginal is at most the candidate's maximum likelihood, and
|
|
1137
|
+
* the pooled likelihood ratio is at most `n_b · KL(p̂_b ‖ pTrue)`, so the bound
|
|
1138
|
+
* is the baseline's mixture over its maximum likelihood plus that term.
|
|
1139
|
+
*/
|
|
1140
|
+
export function universalCeilingLogE(pTrue, baselineSuccesses, baselineTrials) {
|
|
1141
|
+
requireProbability(pTrue, "pTrue", "universalCeilingLogE");
|
|
1142
|
+
requireCounts(baselineSuccesses, baselineTrials, "universalCeilingLogE");
|
|
1143
|
+
if (baselineTrials === 0) {
|
|
1144
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", "universalCeilingLogE: needs at least one baseline trial", {
|
|
1145
|
+
detail: { baselineTrials },
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
const pb = baselineSuccesses / baselineTrials;
|
|
1149
|
+
const occam = logBeta(1 + baselineSuccesses, 1 + baselineTrials - baselineSuccesses) -
|
|
1150
|
+
logLik(baselineSuccesses, baselineTrials, pb);
|
|
1151
|
+
if (pTrue >= pb)
|
|
1152
|
+
return occam;
|
|
1153
|
+
const p = Math.max(1e-12, pTrue);
|
|
1154
|
+
return occam + baselineTrials * (xlogy(pb, pb / p) + xlogy(1 - pb, (1 - pb) / (1 - p)));
|
|
1155
|
+
}
|
|
1156
|
+
export function discordant(p) {
|
|
1157
|
+
requirePairedCounts(p, "discordant");
|
|
1158
|
+
return p.worse + p.better;
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Phi coefficient of the 2×2 pair table, how well the pairing actually
|
|
1162
|
+
* coupled the two arms. 0 means the seeds bought nothing (the arms are
|
|
1163
|
+
* independent and you may as well not have paired); 1 means perfect coupling.
|
|
1164
|
+
* This is the number the variance-reduction claim has to be measured against,
|
|
1165
|
+
* not assumed.
|
|
1166
|
+
*/
|
|
1167
|
+
export function pairPhi(p) {
|
|
1168
|
+
requirePairedCounts(p, "pairPhi");
|
|
1169
|
+
const { bothPass: a, worse: b, better: cc, bothFail: d } = p;
|
|
1170
|
+
const n = a + b + cc + d;
|
|
1171
|
+
if (n === 0)
|
|
1172
|
+
return 0;
|
|
1173
|
+
const den = Math.sqrt((a + b) * (cc + d) * (a + cc) * (b + d));
|
|
1174
|
+
return den === 0 ? 0 : (a * d - b * cc) / den;
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* A **paired e-value** for "the candidate is worse".
|
|
1178
|
+
*
|
|
1179
|
+
* Null: each discordant pair points the wrong way with probability at most ½.
|
|
1180
|
+
* Alternative: it points the wrong way with probability θ ~ Beta centred on
|
|
1181
|
+
* `thetaAlt`, truncated to θ > ½. Every θ in that support gives a likelihood
|
|
1182
|
+
* ratio against ½ that is a supermartingale whenever the true probability is
|
|
1183
|
+
* ½ or less, so the mixture is an e-value valid under optional stopping for a
|
|
1184
|
+
* candidate that is unchanged or better, not only for one that is unchanged.
|
|
1185
|
+
*/
|
|
1186
|
+
export function pairedLogE(worse, discordantPairs, thetaAlt = 0.75, concentration = 6) {
|
|
1187
|
+
requireCounts(worse, discordantPairs, "pairedLogE");
|
|
1188
|
+
requireProbability(thetaAlt, "thetaAlt", "pairedLogE");
|
|
1189
|
+
requireConcentration(concentration, "concentration", "pairedLogE");
|
|
1190
|
+
if (discordantPairs === 0)
|
|
1191
|
+
return 0;
|
|
1192
|
+
const a1 = Math.max(0.35, concentration * thetaAlt);
|
|
1193
|
+
const b1 = Math.max(0.35, concentration * (1 - thetaAlt));
|
|
1194
|
+
return logUpperHalfMarginal(worse, discordantPairs, a1, b1) - discordantPairs * Math.log(0.5);
|
|
1195
|
+
}
|
|
1196
|
+
// The marginal of `w` in `d` under Beta(a, b) restricted to (½, 1]: P(θ > ½ | a, b) is I_½(b, a).
|
|
1197
|
+
function logUpperHalfMarginal(w, d, a, b) {
|
|
1198
|
+
return (logBeta(a + w, b + d - w) +
|
|
1199
|
+
Math.log(ibeta(b + d - w, a + w, 0.5)) -
|
|
1200
|
+
logBeta(a, b) -
|
|
1201
|
+
Math.log(ibeta(b, a, 0.5)));
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* The discordance model a paired plan runs on.
|
|
1205
|
+
*
|
|
1206
|
+
* With probability `rho` the two arms see the same random draw and a
|
|
1207
|
+
* discordance can only occur inside the (p_c, p_b) gap, always in the "worse"
|
|
1208
|
+
* direction. With probability 1−rho they are independent and discordances go
|
|
1209
|
+
* both ways. `rho` is *measured* from a pilot (see `pairPhi`), not assumed.
|
|
1210
|
+
*/
|
|
1211
|
+
export function pairedDiscordance(pBaseline, mde, rho) {
|
|
1212
|
+
requireProbability(pBaseline, "pBaseline", "pairedDiscordance");
|
|
1213
|
+
requireProbability(rho, "rho", "pairedDiscordance");
|
|
1214
|
+
// Checked last and checked here because it was the one argument that was not:
|
|
1215
|
+
// an mde of -0.5 put the candidate above 1, which came back as a discordance
|
|
1216
|
+
// rate of -0.36 and a theta of 1.194, both impossible, neither an error. An
|
|
1217
|
+
// mde is an effect rather than a rate, so the endpoints are out too, which is
|
|
1218
|
+
// where gate, makePlan and evaluatePoint already draw the line.
|
|
1219
|
+
requireOpenProbability(mde, "mde", "pairedDiscordance");
|
|
1220
|
+
const pc = Math.max(0, pBaseline - mde);
|
|
1221
|
+
const coupledWorse = pBaseline - pc;
|
|
1222
|
+
const indepWorse = pBaseline * (1 - pc);
|
|
1223
|
+
const indepBetter = pc * (1 - pBaseline);
|
|
1224
|
+
const worse = rho * coupledWorse + (1 - rho) * indepWorse;
|
|
1225
|
+
const better = (1 - rho) * indepBetter;
|
|
1226
|
+
const rate = worse + better;
|
|
1227
|
+
return { rate, theta: rate === 0 ? 0.5 : worse / rate };
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Expected number of **pairs** for the paired e-value to clear `logThreshold`.
|
|
1231
|
+
* One pair costs one candidate run plus one baseline run, so the caller must
|
|
1232
|
+
* double it before comparing with `samplesForEvidence`. Even so it usually
|
|
1233
|
+
* wins, and unlike the unpaired design it never returns Infinity for a real
|
|
1234
|
+
* effect.
|
|
1235
|
+
*/
|
|
1236
|
+
export function mcnemarSamplesForEvidence(pBaseline, mde, rho, logThreshold, maxPairs = 100_000, concentration = 6) {
|
|
1237
|
+
requireConcentration(concentration, "concentration", "mcnemarSamplesForEvidence");
|
|
1238
|
+
requireSearchCap(maxPairs, "maxPairs", "mcnemarSamplesForEvidence");
|
|
1239
|
+
requireLogThreshold(logThreshold, "mcnemarSamplesForEvidence");
|
|
1240
|
+
const { rate, theta } = pairedDiscordance(pBaseline, mde, rho);
|
|
1241
|
+
if (rate <= 0 || theta <= 0.5)
|
|
1242
|
+
return Infinity;
|
|
1243
|
+
const a1 = Math.max(0.35, concentration * theta);
|
|
1244
|
+
const b1 = Math.max(0.35, concentration * (1 - theta));
|
|
1245
|
+
const f = (n) => logUpperHalfMarginal(n * rate * theta, n * rate, a1, b1) - n * rate * Math.log(0.5);
|
|
1246
|
+
let hi = 1;
|
|
1247
|
+
while (hi < maxPairs && f(hi) < logThreshold)
|
|
1248
|
+
hi *= 2;
|
|
1249
|
+
if (f(hi) < logThreshold)
|
|
1250
|
+
return Infinity;
|
|
1251
|
+
let lo = Math.floor(hi / 2);
|
|
1252
|
+
while (hi - lo > 1) {
|
|
1253
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
1254
|
+
if (f(mid) >= logThreshold)
|
|
1255
|
+
hi = mid;
|
|
1256
|
+
else
|
|
1257
|
+
lo = mid;
|
|
1258
|
+
}
|
|
1259
|
+
return hi;
|
|
1260
|
+
}
|
|
1261
|
+
/* ───────────────────────── screening statistic ───────────────────────── */
|
|
1262
|
+
/**
|
|
1263
|
+
* P(p_candidate ≤ p_baseline − mde) under independent Beta posteriors, the
|
|
1264
|
+
* posterior probability that a case *moved*, given whatever handful of runs it
|
|
1265
|
+
* has so far.
|
|
1266
|
+
*
|
|
1267
|
+
* This is the screening score: cheap (one quadrature over `ibeta`), needs only
|
|
1268
|
+
* 3 to 4 candidate runs to be informative, and orders cases by where the
|
|
1269
|
+
* expensive sequential test can actually pay. It is deliberately **not** a
|
|
1270
|
+
* decision, screening decides where to spend, the e-value decides what is
|
|
1271
|
+
* true, and the FDR guarantee is unaffected by which cases you chose to look
|
|
1272
|
+
* at (e-BH is valid under arbitrary dependence, including a data-dependent
|
|
1273
|
+
* choice of which cases to sample further).
|
|
1274
|
+
*/
|
|
1275
|
+
export function probabilityMoved(baselineSuccesses, baselineTrials, candidateSuccesses, candidateTrials, mde, nodes = 256) {
|
|
1276
|
+
requireCounts(baselineSuccesses, baselineTrials, "probabilityMoved(baseline)");
|
|
1277
|
+
requireCounts(candidateSuccesses, candidateTrials, "probabilityMoved(candidate)");
|
|
1278
|
+
// Both were unchecked. A negative mde answered the probability of a rise, and
|
|
1279
|
+
// a node count below 1 skipped the quadrature and answered 0, "did not move".
|
|
1280
|
+
// Checked before the early return so the first call of a loop refuses too.
|
|
1281
|
+
requireOpenProbability(mde, "mde", "probabilityMoved");
|
|
1282
|
+
if (!Number.isInteger(nodes) || nodes < 1) {
|
|
1283
|
+
throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `probabilityMoved: nodes must be a positive integer, got ${nodes}`, {
|
|
1284
|
+
detail: { nodes },
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
if (candidateTrials === 0)
|
|
1288
|
+
return 0;
|
|
1289
|
+
const a0 = 1 + baselineSuccesses, b0 = 1 + baselineTrials - baselineSuccesses;
|
|
1290
|
+
const a1 = 1 + candidateSuccesses, b1 = 1 + candidateTrials - candidateSuccesses;
|
|
1291
|
+
// Simpson's rule on ∫ f_b(x) · F_c(x − mde) dx over (0,1)
|
|
1292
|
+
const m = nodes % 2 === 0 ? nodes : nodes + 1;
|
|
1293
|
+
const h = 1 / m;
|
|
1294
|
+
let acc = 0;
|
|
1295
|
+
for (let i = 0; i <= m; i++) {
|
|
1296
|
+
const x = Math.min(1 - 1e-9, Math.max(1e-9, i * h));
|
|
1297
|
+
const w = i === 0 || i === m ? 1 : i % 2 === 1 ? 4 : 2;
|
|
1298
|
+
const fb = Math.exp(logBetaPdf(x, a0, b0));
|
|
1299
|
+
const fc = x - mde <= 0 ? 0 : ibeta(a1, b1, Math.min(1, x - mde));
|
|
1300
|
+
acc += w * fb * fc;
|
|
1301
|
+
}
|
|
1302
|
+
return Math.min(1, Math.max(0, (acc * h) / 3));
|
|
1303
|
+
}
|
|
1304
|
+
//# sourceMappingURL=stats.js.map
|