peeksafe 0.2.0 → 0.4.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
@@ -115,13 +115,30 @@ export function gammaP(a: number, x: number): number {
115
115
  * approximation is visible in a p-value and we compare p-values to thresholds.
116
116
  */
117
117
  export function erf(x: number): number {
118
+ requireNumber(x, "x", "erf");
118
119
  if (x === 0) return 0;
119
120
  const p = gammaP(0.5, x * x);
120
121
  return x > 0 ? p : -p;
121
122
  }
122
123
 
123
124
  /** Φ(x): standard normal CDF. */
124
- export const normalCdf = (x: number): number => 0.5 * (1 + erf(x / Math.SQRT2));
125
+ export function normalCdf(x: number): number {
126
+ requireNumber(x, "x", "normalCdf");
127
+ return 0.5 * (1 + erf(x / Math.SQRT2));
128
+ }
129
+
130
+ /** Refuses NaN only: erf and normalCdf answer the infinities as their limits. */
131
+ function requireNumber(x: number, name: string, where: string): void {
132
+ if (Number.isNaN(x)) {
133
+ throw new PeeksafeError(
134
+ "PEEKSAFE_E_STAT_DOMAIN",
135
+ `${where}: ${name} must be a number, got NaN`,
136
+ {
137
+ detail: { [name]: x },
138
+ },
139
+ );
140
+ }
141
+ }
125
142
 
126
143
  /** Φ⁻¹(p): Acklam's inverse normal CDF, refined by one Halley step. */
127
144
  export function normalQuantile(p: number): number {
@@ -330,10 +347,16 @@ export function betaPosterior(
330
347
  prior: { alpha: number; beta: number } = { alpha: 1, beta: 1 },
331
348
  ): BetaPosterior {
332
349
  requireCounts(successes, successes + failures, "betaPosterior");
333
- if (!(prior.alpha > 0) || !(prior.beta > 0)) {
350
+ // An infinite shape passed `> 0` and came back as NaN moments.
351
+ if (
352
+ !(prior.alpha > 0) ||
353
+ !(prior.beta > 0) ||
354
+ !Number.isFinite(prior.alpha) ||
355
+ !Number.isFinite(prior.beta)
356
+ ) {
334
357
  throw new PeeksafeError(
335
358
  "PEEKSAFE_E_STAT_DOMAIN",
336
- "betaPosterior: prior parameters must be positive",
359
+ "betaPosterior: prior parameters must be finite and positive",
337
360
  {
338
361
  detail: { prior },
339
362
  },
@@ -424,8 +447,27 @@ export function tQuantile(p: number, df: number): number {
424
447
  return Math.sqrt((df * (1 - x)) / x);
425
448
  }
426
449
 
450
+ // A posterior built by hand reached betaQuantile or ibeta unchecked and was refused in their name.
451
+ function requirePosteriorShape(post: BetaPosterior, where: string): void {
452
+ if (
453
+ !(post.alpha > 0) ||
454
+ !(post.beta > 0) ||
455
+ !Number.isFinite(post.alpha) ||
456
+ !Number.isFinite(post.beta)
457
+ ) {
458
+ throw new PeeksafeError(
459
+ "PEEKSAFE_E_STAT_DOMAIN",
460
+ `${where}: posterior alpha and beta must be finite and positive`,
461
+ {
462
+ detail: { alpha: post.alpha, beta: post.beta },
463
+ },
464
+ );
465
+ }
466
+ }
467
+
427
468
  /** Equal-tailed credible interval of a Beta posterior. */
428
469
  export function betaCredibleInterval(post: BetaPosterior, level = 0.95): Interval {
470
+ requirePosteriorShape(post, "betaCredibleInterval");
429
471
  // betaQuantile clamps q to [0, 1], so a level of 95 or -1 came back as [0, 1] or [1, 0].
430
472
  requireOpenProbability(level, "level", "betaCredibleInterval");
431
473
  const tail = (1 - level) / 2;
@@ -438,6 +480,7 @@ export function betaCredibleInterval(post: BetaPosterior, level = 0.95): Interva
438
480
 
439
481
  /** P(a < θ < b) under a Beta posterior. */
440
482
  export function betaMassBetween(post: BetaPosterior, a: number, b: number): number {
483
+ requirePosteriorShape(post, "betaMassBetween");
441
484
  requireProbability(a, "a", "betaMassBetween");
442
485
  requireProbability(b, "b", "betaMassBetween");
443
486
  if (a > b) {
@@ -729,9 +772,8 @@ export function sprtExpectedN(p: number, p0: number, p1: number, alpha = 0.05, b
729
772
  * outside the two hypotheses, so the two powers are evaluated relative to
730
773
  * whichever of them is largest. Written as plain `exp`, a tilt of −662, which is
731
774
  * p = 0.0025 against p₀ = 0.999 and p₁ = 0.99, overflows `B^h` to Infinity and
732
- * the ratio comes back NaN; `expectedSequentialSamples` and
733
- * `typicalObservationsPerCase` both test the result for `> 0`, so a NaN there
734
- * silently prices the case at `maxTrials`.
775
+ * the ratio comes back NaN, which a caller testing the result for `> 0` reads
776
+ * as no estimate at all.
735
777
  */
736
778
  function sprtAcceptH1(h: number, A: number, B: number, drift: number): number {
737
779
  // No tilt exists at p = 0 or p = 1: one outcome is impossible, so the drift
@@ -1071,6 +1113,8 @@ export function bernoulliEntropy(p: number): number {
1071
1113
  /** log of the Beta(a,b) density at p. */
1072
1114
  export function logBetaPdf(p: number, a: number, b: number): number {
1073
1115
  requireProbability(p, "p", "logBetaPdf");
1116
+ requireConcentration(a, "a", "logBetaPdf");
1117
+ requireConcentration(b, "b", "logBetaPdf");
1074
1118
  if (p <= 0 || p >= 1) {
1075
1119
  // density is 0 or ∞ at the boundary depending on the shape; the planner
1076
1120
  // only ever evaluates strictly inside, so clamp rather than return ±∞.
@@ -1146,6 +1190,9 @@ export function evidenceCeilingLogE(
1146
1190
  altConcentration = 8,
1147
1191
  ): number {
1148
1192
  requireProbability(pTrue, "pTrue", "evidenceCeilingLogE");
1193
+ requireCounts(baselineSuccesses, baselineTrials, "evidenceCeilingLogE");
1194
+ requireConcentration(altConcentration, "altConcentration", "evidenceCeilingLogE");
1195
+ requireOpenProbability(mde, "mde", "evidenceCeilingLogE");
1149
1196
  const { nullPrior, altPrior } = twoSamplePriors(
1150
1197
  baselineSuccesses,
1151
1198
  baselineTrials,
@@ -1213,6 +1260,8 @@ export function evidenceCeilingAsymptotic(
1213
1260
  ): number {
1214
1261
  requireCounts(baselineSuccesses, baselineTrials, "evidenceCeilingAsymptotic");
1215
1262
  requireProbability(pTrue, "pTrue", "evidenceCeilingAsymptotic");
1263
+ requireConcentration(altConcentration, "altConcentration", "evidenceCeilingAsymptotic");
1264
+ requireOpenProbability(mde, "mde", "evidenceCeilingAsymptotic");
1216
1265
  if (baselineTrials === 0) {
1217
1266
  throw new PeeksafeError(
1218
1267
  "PEEKSAFE_E_STAT_DOMAIN",
@@ -1274,6 +1323,9 @@ export function expectedLogE(
1274
1323
  detail: { n },
1275
1324
  });
1276
1325
  }
1326
+ requireCounts(baselineSuccesses, baselineTrials, "expectedLogE");
1327
+ requireConcentration(altConcentration, "altConcentration", "expectedLogE");
1328
+ requireOpenProbability(mde, "mde", "expectedLogE");
1277
1329
  const { nullPrior: n0, altPrior: a1 } = twoSamplePriors(
1278
1330
  baselineSuccesses,
1279
1331
  baselineTrials,
@@ -1307,6 +1359,9 @@ export function expectedLogEExact(
1307
1359
  { detail: { n } },
1308
1360
  );
1309
1361
  }
1362
+ requireCounts(baselineSuccesses, baselineTrials, "expectedLogEExact");
1363
+ requireConcentration(altConcentration, "altConcentration", "expectedLogEExact");
1364
+ requireOpenProbability(mde, "mde", "expectedLogEExact");
1310
1365
  let acc = 0;
1311
1366
  for (let s = 0; s <= n; s++) {
1312
1367
  const logW =
@@ -1339,6 +1394,10 @@ export function samplesForEvidence(
1339
1394
  ): number {
1340
1395
  requireSearchCap(maxN, "maxN", "samplesForEvidence");
1341
1396
  requireLogThreshold(logThreshold, "samplesForEvidence");
1397
+ requireProbability(pTrue, "pTrue", "samplesForEvidence");
1398
+ requireCounts(baselineSuccesses, baselineTrials, "samplesForEvidence");
1399
+ requireConcentration(altConcentration, "altConcentration", "samplesForEvidence");
1400
+ requireOpenProbability(mde, "mde", "samplesForEvidence");
1342
1401
  const ceiling = evidenceCeilingLogE(
1343
1402
  pTrue,
1344
1403
  baselineSuccesses,
@@ -1407,6 +1466,8 @@ export function universalTwoSampleLogE(
1407
1466
  ): number {
1408
1467
  requireCounts(candidateSuccesses, candidateTrials, "universalTwoSampleLogE(candidate)");
1409
1468
  requireCounts(baselineSuccesses, baselineTrials, "universalTwoSampleLogE(baseline)");
1469
+ requireConcentration(altConcentration, "altConcentration", "universalTwoSampleLogE");
1470
+ requireOpenProbability(mde, "mde", "universalTwoSampleLogE");
1410
1471
  if (baselineTrials === 0) {
1411
1472
  throw new PeeksafeError(
1412
1473
  "PEEKSAFE_E_STAT_DOMAIN",
@@ -1587,6 +1648,9 @@ export function mcnemarSamplesForEvidence(
1587
1648
  requireConcentration(concentration, "concentration", "mcnemarSamplesForEvidence");
1588
1649
  requireSearchCap(maxPairs, "maxPairs", "mcnemarSamplesForEvidence");
1589
1650
  requireLogThreshold(logThreshold, "mcnemarSamplesForEvidence");
1651
+ requireProbability(pBaseline, "pBaseline", "mcnemarSamplesForEvidence");
1652
+ requireProbability(rho, "rho", "mcnemarSamplesForEvidence");
1653
+ requireOpenProbability(mde, "mde", "mcnemarSamplesForEvidence");
1590
1654
  const { rate, theta } = pairedDiscordance(pBaseline, mde, rho);
1591
1655
  if (rate <= 0 || theta <= 0.5) return Infinity;
1592
1656
  const a1 = Math.max(0.35, concentration * theta);
package/src/stop.ts CHANGED
@@ -449,7 +449,7 @@ export function shouldStopPaired(
449
449
  }
450
450
 
451
451
  // Mean of prod(1 - lambda (D - mde)) over fixed bets, D = +1 worse, -1 better; a supermartingale whenever E[D] >= mde.
452
- export function settleLogE(c: PairedCounts, mde: number): number {
452
+ function settleLogE(c: PairedCounts, mde: number): number {
453
453
  const terms: number[] = [];
454
454
  for (let k = 1; k <= 9; k++) {
455
455
  const lambda = k / 10 / (1 - mde);