peeksafe 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/stats.ts CHANGED
@@ -23,7 +23,9 @@
23
23
  import {
24
24
  PeeksafeError,
25
25
  requireCounts,
26
+ requireErrorRates,
26
27
  requireOpenProbability,
28
+ requirePairedCounts,
27
29
  requireProbability,
28
30
  } from "./errors.js";
29
31
 
@@ -36,6 +38,16 @@ const LANCZOS = [
36
38
 
37
39
  /** Lanczos approximation to log Γ(x), x > 0. ~15 significant digits. */
38
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
+ }
39
51
  if (x < 0.5) {
40
52
  // reflection: Γ(x)Γ(1-x) = π / sin(πx)
41
53
  return Math.log(Math.PI / Math.abs(Math.sin(Math.PI * x))) - logGamma(1 - x);
@@ -86,7 +98,14 @@ function gammaQContinued(a: number, x: number): number {
86
98
 
87
99
  /** Regularized lower incomplete gamma P(a,x), accurate to ~1e-15. */
88
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
+ }
89
107
  if (x <= 0) return 0;
108
+ if (x === Infinity) return 1;
90
109
  return x < a + 1 ? gammaPSeries(a, x) : 1 - gammaQContinued(a, x);
91
110
  }
92
111
 
@@ -106,6 +125,16 @@ export const normalCdf = (x: number): number => 0.5 * (1 + erf(x / Math.SQRT2));
106
125
 
107
126
  /** Φ⁻¹(p): Acklam's inverse normal CDF, refined by one Halley step. */
108
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
+ }
109
138
  if (p <= 0) return -Infinity;
110
139
  if (p >= 1) return Infinity;
111
140
  const a = [
@@ -244,6 +273,20 @@ export interface Interval {
244
273
  observations?: number;
245
274
  }
246
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
+
247
290
  /**
248
291
  * Wilson score interval for a binomial proportion.
249
292
  * Correct at the boundaries (0/n and n/n) where Wald is degenerate, which is
@@ -251,6 +294,7 @@ export interface Interval {
251
294
  */
252
295
  export function wilsonInterval(successes: number, n: number, z = 1.959963984540054): Interval {
253
296
  requireCounts(successes, n, "wilsonInterval");
297
+ requireZ(z, "wilsonInterval");
254
298
  // No data is not "p = 0.5". The interval is the whole line and the point
255
299
  // estimate is NaN-free only because callers must check `n`, which is why
256
300
  // `n` is echoed back in `observations`.
@@ -321,6 +365,12 @@ export function betaQuantile(alpha: number, beta: number, q: number): number {
321
365
  },
322
366
  );
323
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
+ }
324
374
  if (q <= 0) return 0;
325
375
  if (q >= 1) return 1;
326
376
  let lo = 0,
@@ -376,6 +426,8 @@ export function tQuantile(p: number, df: number): number {
376
426
 
377
427
  /** Equal-tailed credible interval of a Beta posterior. */
378
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");
379
431
  const tail = (1 - level) / 2;
380
432
  return {
381
433
  low: betaQuantile(post.alpha, post.beta, tail),
@@ -416,6 +468,7 @@ export function diffInterval(
416
468
  ): Interval {
417
469
  requireCounts(s1, n1, "diffInterval(arm 1)");
418
470
  requireCounts(s2, n2, "diffInterval(arm 2)");
471
+ requireZ(z, "diffInterval");
419
472
  // With an empty arm there is no difference to estimate. Saying so beats
420
473
  // reporting `p2 − 0` as if the missing arm had scored zero.
421
474
  if (n1 === 0 || n2 === 0) return { low: -1, high: 1, point: 0, observations: 0 };
@@ -551,8 +604,9 @@ export function sprtDecision(opts: SprtOpts): SprtResult {
551
604
  // against a wall that is not a number. An alpha of 0 puts `upper` at
552
605
  // Infinity, which no evidence ever crosses. Open at both ends: an error rate
553
606
  // of 0 is a test that never decides and one of 1 is a test that always does.
554
- requireOpenProbability(alpha, "alpha", "sprtDecision");
555
- requireOpenProbability(beta, "beta", "sprtDecision");
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");
556
610
  const e = 1e-12;
557
611
  const cl = (x: number) => Math.min(1 - e, Math.max(e, x));
558
612
  const q0 = cl(p0),
@@ -630,8 +684,9 @@ export function sprtExpectedN(p: number, p0: number, p1: number, alpha = 0.05, b
630
684
  // and quote `maxTrials` when that fails, which both answers do, so a plan
631
685
  // built on an out-of-range error rate priced every case at the per-case cap
632
686
  // and reported it as a budget, with nothing to say it was not a real answer.
633
- requireOpenProbability(alpha, "alpha", "sprtExpectedN");
634
- requireOpenProbability(beta, "beta", "sprtExpectedN");
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");
635
690
  const A = Math.log((1 - beta) / alpha);
636
691
  const B = Math.log(beta / (1 - alpha));
637
692
  const logR1 = Math.log(p1 / p0);
@@ -641,16 +696,6 @@ export function sprtExpectedN(p: number, p0: number, p1: number, alpha = 0.05, b
641
696
  // p0 === p1 leaves every trial uninformative, so the drift and the curvature
642
697
  // are both 0 and no number of runs ends the test.
643
698
  if (second === 0) return Infinity;
644
- // `A === B` is `alpha + beta === 1`: both walls sit at a log likelihood ratio
645
- // of 0, which is where the test starts, so it decides before it has seen
646
- // anything and the expected sample number is 0. The general form is
647
- // `(L1·(A − B) + B) / drift`, which is 0 whatever `L1` is, but it computes
648
- // `L1` as `(1 − B^h) / (A^h − B^h)`, a genuine 0/0 here, and so returned NaN
649
- // for in-domain error rates: (0.5, 0.5) and (0.25, 0.75) both landed on it.
650
- // The neighbouring configurations already converge to 0 from both sides.
651
- // Only the pairs where both logs round to exactly 0 were affected, which is
652
- // why this survived the error-rate guards above.
653
- if (A === B) return 0;
654
699
  // The ASN is 0/0 where the tilt vanishes, and that limit is the one place
655
700
  // Wald's formula needs a different expression. What decides whether the
656
701
  // quotient is really 0/0 is the *tilt*, not the drift: near the zero
@@ -791,6 +836,41 @@ function requireConcentration(k: number, name: string, where: string): void {
791
836
  }
792
837
  }
793
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
+
794
874
  // Below 1 a shape piles alternative mass onto 0 or 1, which pushed type I error past alpha for thin baselines.
795
875
  const ALT_SHAPE_FLOOR = 1;
796
876
 
@@ -907,8 +987,25 @@ export function ebhCorrect(evalues: number[], q = 0.05): EbhResult {
907
987
  return { rejected, discoveries: kStar, threshold: kStar > 0 ? m / (q * kStar) : m / q };
908
988
  }
909
989
 
910
- /** The e-value a case must reach to be certain of rejection whatever the others do. */
911
- export const ebhSoloThreshold = (m: number, q = 0.05): number => m / q;
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
+ }
912
1009
 
913
1010
  /* ──────────────────────────────── planning ──────────────────────────── */
914
1011
 
@@ -937,6 +1034,16 @@ export function sampleSizeTwoProportion(
937
1034
  // naive design costs.
938
1035
  requireOpenProbability(alpha, "alpha", "sampleSizeTwoProportion");
939
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
+ }
940
1047
  if (delta <= 0 || delta >= p0) return Infinity;
941
1048
  const p1 = p0 - delta;
942
1049
  const pbar = (p0 + p1) / 2;
@@ -1230,6 +1337,8 @@ export function samplesForEvidence(
1230
1337
  maxN = 100_000,
1231
1338
  altConcentration = 8,
1232
1339
  ): number {
1340
+ requireSearchCap(maxN, "maxN", "samplesForEvidence");
1341
+ requireLogThreshold(logThreshold, "samplesForEvidence");
1233
1342
  const ceiling = evidenceCeilingLogE(
1234
1343
  pTrue,
1235
1344
  baselineSuccesses,
@@ -1373,7 +1482,10 @@ export interface PairedCounts {
1373
1482
  bothFail: number;
1374
1483
  }
1375
1484
 
1376
- export const discordant = (p: PairedCounts): number => p.worse + p.better;
1485
+ export function discordant(p: PairedCounts): number {
1486
+ requirePairedCounts(p, "discordant");
1487
+ return p.worse + p.better;
1488
+ }
1377
1489
 
1378
1490
  /**
1379
1491
  * Phi coefficient of the 2×2 pair table, how well the pairing actually
@@ -1383,23 +1495,8 @@ export const discordant = (p: PairedCounts): number => p.worse + p.better;
1383
1495
  * not assumed.
1384
1496
  */
1385
1497
  export function pairPhi(p: PairedCounts): number {
1498
+ requirePairedCounts(p, "pairPhi");
1386
1499
  const { bothPass: a, worse: b, better: cc, bothFail: d } = p;
1387
- for (const [name, v] of [
1388
- ["bothPass", a],
1389
- ["worse", b],
1390
- ["better", cc],
1391
- ["bothFail", d],
1392
- ] as const) {
1393
- if (!Number.isInteger(v) || v < 0) {
1394
- throw new PeeksafeError(
1395
- "PEEKSAFE_E_STAT_DOMAIN",
1396
- `pairPhi: ${name} must be a non-negative integer, got ${v}`,
1397
- {
1398
- detail: { counts: p },
1399
- },
1400
- );
1401
- }
1402
- }
1403
1500
  const n = a + b + cc + d;
1404
1501
  if (n === 0) return 0;
1405
1502
  const den = Math.sqrt((a + b) * (cc + d) * (a + cc) * (b + d));
@@ -1488,21 +1585,8 @@ export function mcnemarSamplesForEvidence(
1488
1585
  concentration = 6,
1489
1586
  ): number {
1490
1587
  requireConcentration(concentration, "concentration", "mcnemarSamplesForEvidence");
1491
- // Every comparison below is a `<` against `logThreshold`, and a NaN loses all
1492
- // of them: the doubling loop exited on its first test, the Infinity guard did
1493
- // not fire, and the bisection returned its own starting point. The answer
1494
- // came back as one pair, where the same design at a real 15pt effect needs
1495
- // 172. `samplesForEvidence` writes its guard negated and so refuses instead.
1496
- if (!Number.isFinite(logThreshold)) {
1497
- throw new PeeksafeError(
1498
- "PEEKSAFE_E_STAT_DOMAIN",
1499
- `mcnemarSamplesForEvidence: logThreshold must be finite, got ${logThreshold}`,
1500
- {
1501
- detail: { logThreshold },
1502
- hint: "this is a log e-value bar, such as Math.log(ebhSoloThreshold(m, fdr))",
1503
- },
1504
- );
1505
- }
1588
+ requireSearchCap(maxPairs, "maxPairs", "mcnemarSamplesForEvidence");
1589
+ requireLogThreshold(logThreshold, "mcnemarSamplesForEvidence");
1506
1590
  const { rate, theta } = pairedDiscordance(pBaseline, mde, rho);
1507
1591
  if (rate <= 0 || theta <= 0.5) return Infinity;
1508
1592
  const a1 = Math.max(0.35, concentration * theta);
package/src/stop.ts CHANGED
@@ -60,7 +60,7 @@ import {
60
60
  requireEvidence,
61
61
  toEvalue,
62
62
  } from "./evidence.js";
63
- import { cannotDropBy, DEFAULT_GATE_OPTIONS } from "./gate.js";
63
+ import { cannotDropBy, DEFAULT_GATE_OPTIONS, definedOptions } from "./gate.js";
64
64
  import {
65
65
  discordant,
66
66
  ebhSoloThreshold,
@@ -160,7 +160,19 @@ const zFor = (confidence: number): number => {
160
160
  },
161
161
  );
162
162
  }
163
- return normalQuantile(1 - (1 - confidence) / 2);
163
+ const z = normalQuantile(1 - (1 - confidence) / 2);
164
+ // At the largest double below 1 the upper tail rounds to 1, and an infinite z reached the ceiling as NaN.
165
+ if (!Number.isFinite(z)) {
166
+ throw new PeeksafeError(
167
+ "PEEKSAFE_E_CONFIG",
168
+ `shouldStop: futilityConfidence ${confidence} is too close to 1 for a finite interval`,
169
+ {
170
+ detail: { futilityConfidence: confidence },
171
+ hint: "a two-sided confidence level such as 0.95; higher makes futility fire later",
172
+ },
173
+ );
174
+ }
175
+ return z;
164
176
  };
165
177
 
166
178
  /**
@@ -180,7 +192,7 @@ export function shouldStop(
180
192
  baseline: BaselineStat,
181
193
  options: StopOptions,
182
194
  ): StopDecision {
183
- const opts = { ...DEFAULT_STOP_OPTIONS, ...options };
195
+ const opts = { ...DEFAULT_STOP_OPTIONS, ...definedOptions(options) };
184
196
  requireCounts(observed.successes, observed.trials, "shouldStop");
185
197
  requireCounts(baseline.successes, baseline.trials, "shouldStop.baseline");
186
198
  requireOpenProbability(opts.mde, "mde", "shouldStop");
@@ -363,7 +375,7 @@ export function shouldStopPaired(
363
375
  counts: PairedCounts,
364
376
  options: PairedStopOptions,
365
377
  ): PairedStopDecision {
366
- const opts = { ...DEFAULT_STOP_OPTIONS, ...options };
378
+ const opts = { ...DEFAULT_STOP_OPTIONS, ...definedOptions(options) };
367
379
  requirePairedCounts(counts, "shouldStopPaired");
368
380
  requireOpenProbability(opts.mde, "mde", "shouldStopPaired");
369
381
  requireOpenProbability(opts.fdr, "fdr", "shouldStopPaired");
@@ -437,7 +449,7 @@ export function shouldStopPaired(
437
449
  }
438
450
 
439
451
  // Mean of prod(1 - lambda (D - mde)) over fixed bets, D = +1 worse, -1 better; a supermartingale whenever E[D] >= mde.
440
- function settleLogE(c: PairedCounts, mde: number): number {
452
+ export function settleLogE(c: PairedCounts, mde: number): number {
441
453
  const terms: number[] = [];
442
454
  for (let k = 1; k <= 9; k++) {
443
455
  const lambda = k / 10 / (1 - mde);