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