peeksafe 0.1.0 → 0.3.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
@@ -684,9 +729,8 @@ export function sprtExpectedN(p: number, p0: number, p1: number, alpha = 0.05, b
684
729
  * outside the two hypotheses, so the two powers are evaluated relative to
685
730
  * whichever of them is largest. Written as plain `exp`, a tilt of −662, which is
686
731
  * p = 0.0025 against p₀ = 0.999 and p₁ = 0.99, overflows `B^h` to Infinity and
687
- * the ratio comes back NaN; `expectedSequentialSamples` and
688
- * `typicalObservationsPerCase` both test the result for `> 0`, so a NaN there
689
- * silently prices the case at `maxTrials`.
732
+ * the ratio comes back NaN, which a caller testing the result for `> 0` reads
733
+ * as no estimate at all.
690
734
  */
691
735
  function sprtAcceptH1(h: number, A: number, B: number, drift: number): number {
692
736
  // No tilt exists at p = 0 or p = 1: one outcome is impossible, so the drift
@@ -791,6 +835,41 @@ function requireConcentration(k: number, name: string, where: string): void {
791
835
  }
792
836
  }
793
837
 
838
+ /**
839
+ * The most runs a sample-size search may try. Below 1, or NaN, the doubling
840
+ * loop never ran and the search answered Infinity, "no number of runs will
841
+ * do", for a design that needs a few hundred.
842
+ */
843
+ function requireSearchCap(cap: number, name: string, where: string): void {
844
+ if (!(cap >= 1) || !Number.isFinite(cap)) {
845
+ throw new PeeksafeError(
846
+ "PEEKSAFE_E_STAT_DOMAIN",
847
+ `${where}: ${name} must be finite and at least 1, got ${cap}`,
848
+ {
849
+ detail: { [name]: cap, where },
850
+ },
851
+ );
852
+ }
853
+ }
854
+
855
+ /**
856
+ * The log e-value bar a sample-size search sizes a study to. A NaN bar loses
857
+ * every comparison the searches make, so one answered a single pair and the
858
+ * other Infinity, "no number of runs will do", for the same mistake.
859
+ */
860
+ function requireLogThreshold(logThreshold: number, where: string): void {
861
+ if (!Number.isFinite(logThreshold)) {
862
+ throw new PeeksafeError(
863
+ "PEEKSAFE_E_STAT_DOMAIN",
864
+ `${where}: logThreshold must be finite, got ${logThreshold}`,
865
+ {
866
+ detail: { logThreshold },
867
+ hint: "this is a log e-value bar, such as Math.log(ebhSoloThreshold(m, fdr))",
868
+ },
869
+ );
870
+ }
871
+ }
872
+
794
873
  // Below 1 a shape piles alternative mass onto 0 or 1, which pushed type I error past alpha for thin baselines.
795
874
  const ALT_SHAPE_FLOOR = 1;
796
875
 
@@ -907,8 +986,25 @@ export function ebhCorrect(evalues: number[], q = 0.05): EbhResult {
907
986
  return { rejected, discoveries: kStar, threshold: kStar > 0 ? m / (q * kStar) : m / q };
908
987
  }
909
988
 
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;
989
+ /**
990
+ * The e-value a case must reach to be certain of rejection whatever the others do.
991
+ *
992
+ * `q` is checked as `ebhCorrect` checks it, so a level of 0 answers Infinity.
993
+ */
994
+ export function ebhSoloThreshold(m: number, q = 0.05): number {
995
+ if (!Number.isInteger(m) || m < 1) {
996
+ throw new PeeksafeError(
997
+ "PEEKSAFE_E_STAT_DOMAIN",
998
+ `ebhSoloThreshold: m must be a positive integer, got ${m}`,
999
+ {
1000
+ detail: { m },
1001
+ hint: "m is the number of cases in the e-BH family",
1002
+ },
1003
+ );
1004
+ }
1005
+ requireProbability(q, "q", "ebhSoloThreshold");
1006
+ return m / q;
1007
+ }
912
1008
 
913
1009
  /* ──────────────────────────────── planning ──────────────────────────── */
914
1010
 
@@ -937,6 +1033,16 @@ export function sampleSizeTwoProportion(
937
1033
  // naive design costs.
938
1034
  requireOpenProbability(alpha, "alpha", "sampleSizeTwoProportion");
939
1035
  requireOpenProbability(beta, "beta", "sampleSizeTwoProportion");
1036
+ // Both comparisons below are false for NaN, so it fell through to the formula and came back NaN.
1037
+ if (Number.isNaN(delta)) {
1038
+ throw new PeeksafeError(
1039
+ "PEEKSAFE_E_STAT_DOMAIN",
1040
+ "sampleSizeTwoProportion: delta must be a number, got NaN",
1041
+ {
1042
+ detail: { p0, delta },
1043
+ },
1044
+ );
1045
+ }
940
1046
  if (delta <= 0 || delta >= p0) return Infinity;
941
1047
  const p1 = p0 - delta;
942
1048
  const pbar = (p0 + p1) / 2;
@@ -1230,6 +1336,8 @@ export function samplesForEvidence(
1230
1336
  maxN = 100_000,
1231
1337
  altConcentration = 8,
1232
1338
  ): number {
1339
+ requireSearchCap(maxN, "maxN", "samplesForEvidence");
1340
+ requireLogThreshold(logThreshold, "samplesForEvidence");
1233
1341
  const ceiling = evidenceCeilingLogE(
1234
1342
  pTrue,
1235
1343
  baselineSuccesses,
@@ -1373,7 +1481,10 @@ export interface PairedCounts {
1373
1481
  bothFail: number;
1374
1482
  }
1375
1483
 
1376
- export const discordant = (p: PairedCounts): number => p.worse + p.better;
1484
+ export function discordant(p: PairedCounts): number {
1485
+ requirePairedCounts(p, "discordant");
1486
+ return p.worse + p.better;
1487
+ }
1377
1488
 
1378
1489
  /**
1379
1490
  * Phi coefficient of the 2×2 pair table, how well the pairing actually
@@ -1383,23 +1494,8 @@ export const discordant = (p: PairedCounts): number => p.worse + p.better;
1383
1494
  * not assumed.
1384
1495
  */
1385
1496
  export function pairPhi(p: PairedCounts): number {
1497
+ requirePairedCounts(p, "pairPhi");
1386
1498
  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
1499
  const n = a + b + cc + d;
1404
1500
  if (n === 0) return 0;
1405
1501
  const den = Math.sqrt((a + b) * (cc + d) * (a + cc) * (b + d));
@@ -1488,21 +1584,8 @@ export function mcnemarSamplesForEvidence(
1488
1584
  concentration = 6,
1489
1585
  ): number {
1490
1586
  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
- }
1587
+ requireSearchCap(maxPairs, "maxPairs", "mcnemarSamplesForEvidence");
1588
+ requireLogThreshold(logThreshold, "mcnemarSamplesForEvidence");
1506
1589
  const { rate, theta } = pairedDiscordance(pBaseline, mde, rho);
1507
1590
  if (rate <= 0 || theta <= 0.5) return Infinity;
1508
1591
  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");