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