peeksafe 0.0.0-stage → 0.2.0

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