peeksafe 0.0.0-stage → 0.1.0

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