peeksafe 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peeksafe",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Statistically valid gates for non-deterministic eval suites. Stop early without losing error control.",
5
5
  "keywords": [
6
6
  "evals",
@@ -10,7 +10,16 @@
10
10
  "false-discovery-rate",
11
11
  "llm",
12
12
  "statistics",
13
- "ci"
13
+ "ci",
14
+ "eval",
15
+ "llm-evals",
16
+ "regression-testing",
17
+ "flaky-tests",
18
+ "ab-testing",
19
+ "benjamini-hochberg",
20
+ "e-bh",
21
+ "early-stopping",
22
+ "significance"
14
23
  ],
15
24
  "license": "Apache-2.0",
16
25
  "author": "Alexis Rico",
package/src/check.ts CHANGED
@@ -50,16 +50,20 @@ export interface CertifyProbabilityOptions extends Omit<TypeOneErrorOptions, "ra
50
50
  * baseline at a horizon of 600 takes tens of milliseconds.
51
51
  */
52
52
  export function certifyProbability(options: CertifyProbabilityOptions): number {
53
+ return certify(options, "certifyProbability");
54
+ }
55
+
56
+ function certify(options: CertifyProbabilityOptions, where: string): number {
53
57
  const { baselineRate, candidateRate: pc, baselineTrials: nb, alpha, horizon } = options;
54
58
  const mde = options.mde ?? DEFAULT_GATE_OPTIONS.mde;
55
59
  const concentration = options.altConcentration ?? DEFAULT_GATE_OPTIONS.altConcentration;
56
60
  const evidence = options.evidence ?? DEFAULT_GATE_OPTIONS.evidence;
57
- requireEvidence(evidence, "certifyProbability");
58
- requireOpenProbability(baselineRate, "baselineRate", "certifyProbability");
59
- requireOpenProbability(pc, "candidateRate", "certifyProbability");
60
- requireOpenProbability(alpha, "alpha", "certifyProbability");
61
- requireOpenProbability(mde, "mde", "certifyProbability");
62
- requirePositiveConfig(concentration, "altConcentration", "certifyProbability");
61
+ requireEvidence(evidence, where);
62
+ requireOpenProbability(baselineRate, "baselineRate", where);
63
+ requireOpenProbability(pc, "candidateRate", where);
64
+ requireOpenProbability(alpha, "alpha", where);
65
+ requireOpenProbability(mde, "mde", where);
66
+ requirePositiveConfig(concentration, "altConcentration", where);
63
67
  for (const [name, v] of [
64
68
  ["baselineTrials", nb],
65
69
  ["horizon", horizon],
@@ -67,7 +71,7 @@ export function certifyProbability(options: CertifyProbabilityOptions): number {
67
71
  if (!Number.isInteger(v) || v < 1) {
68
72
  throw new PeeksafeError(
69
73
  "PEEKSAFE_E_CONFIG",
70
- `certifyProbability: ${name} must be a positive integer, got ${v}`,
74
+ `${where}: ${name} must be a positive integer, got ${v}`,
71
75
  {
72
76
  detail: { [name]: v },
73
77
  },
@@ -164,7 +168,7 @@ function universalFromBayes(rawLog: number, s: number, n: number, sb: number, nb
164
168
  export function typeOneError(options: TypeOneErrorOptions): number {
165
169
  const { rate, ...rest } = options;
166
170
  requireOpenProbability(rate, "rate", "typeOneError");
167
- return certifyProbability({ ...rest, baselineRate: rate, candidateRate: rate });
171
+ return certify({ ...rest, baselineRate: rate, candidateRate: rate }, "typeOneError");
168
172
  }
169
173
 
170
174
  export interface PairedCertifyProbabilityOptions {
package/src/cluster.ts CHANGED
@@ -135,12 +135,16 @@ function requireLevel(level: number, where: string): void {
135
135
 
136
136
  /** Group observations by cluster, preserving first-seen order. */
137
137
  export function groupByCluster(obs: ClusterObservation[]): Map<string, number[]> {
138
+ return group(obs, "groupByCluster");
139
+ }
140
+
141
+ function group(obs: ClusterObservation[], where: string): Map<string, number[]> {
138
142
  const m = new Map<string, number[]>();
139
143
  for (const o of obs) {
140
144
  if (!Number.isFinite(o.value)) {
141
145
  throw new PeeksafeError(
142
146
  "PEEKSAFE_E_STAT_DOMAIN",
143
- `clustered mean: value for cluster "${o.cluster}" is not finite`,
147
+ `${where}: value for cluster "${o.cluster}" is not finite`,
144
148
  {
145
149
  detail: { cluster: o.cluster, value: o.value },
146
150
  },
@@ -220,7 +224,7 @@ export function iidMean(values: number[], level = 0.95): Interval {
220
224
  */
221
225
  export function clusterRobustMean(obs: ClusterObservation[], level = 0.95): ClusteredMean {
222
226
  requireLevel(level, "clusterRobustMean");
223
- const groups = groupByCluster(obs);
227
+ const groups = group(obs, "clusterRobustMean");
224
228
  const n = obs.length;
225
229
  const G = groups.size;
226
230
  const sizes = [...groups.values()].map((xs) => xs.length);
@@ -355,7 +359,7 @@ export interface RandomEffectsMean extends Interval {
355
359
  */
356
360
  export function randomEffectsMean(obs: ClusterObservation[], level = 0.95): RandomEffectsMean {
357
361
  requireLevel(level, "randomEffectsMean");
358
- const groups = groupByCluster(obs);
362
+ const groups = group(obs, "randomEffectsMean");
359
363
  const n = obs.length;
360
364
  const G = groups.size;
361
365
  const grand = n > 0 ? obs.reduce((a, o) => a + o.value, 0) / n : 0;
@@ -529,6 +533,9 @@ const fmtPts = (x: number) => `${x >= 0 ? "+" : ""}${(x * 100).toFixed(1)}`;
529
533
  * a reader would conclude.
530
534
  */
531
535
  export function clusteredEffect(obs: ClusterObservation[], level = 0.95): ClusteredEffect {
536
+ // Checked before the estimators below, which would refuse the same input in their own names.
537
+ requireLevel(level, "clusteredEffect");
538
+ const groups = group(obs, "clusteredEffect");
532
539
  const cr2 = clusterRobustMean(obs, level);
533
540
  const re = randomEffectsMean(obs, level);
534
541
  const naive = iidMean(
@@ -536,7 +543,6 @@ export function clusteredEffect(obs: ClusterObservation[], level = 0.95): Cluste
536
543
  level,
537
544
  );
538
545
 
539
- const groups = groupByCluster(obs);
540
546
  const families: FamilyEffect[] = [...groups.entries()]
541
547
  .map(([family, xs]) => {
542
548
  const m = xs.reduce((a, x) => a + x, 0) / xs.length;
@@ -741,6 +747,8 @@ export function clusterKeyDiagnostic(
741
747
  },
742
748
  );
743
749
  }
750
+ group(declared, "clusterKeyDiagnostic");
751
+ group(fallback, "clusterKeyDiagnostic");
744
752
  const d = summarise("declared", declared);
745
753
  const f = summarise("case-id", fallback);
746
754
 
package/src/gate.ts CHANGED
@@ -189,6 +189,39 @@ export interface GateResult {
189
189
  headline: string;
190
190
  }
191
191
 
192
+ /** A number as JSON can hold it: finite as is, the rest as the string `String` gives, which `Number` reads back. */
193
+ export type JsonNumber = number | "Infinity" | "-Infinity" | "NaN";
194
+
195
+ /** A `CaseVerdict` as `gateReport` writes it. A paired case's `ceiling` is `"Infinity"`. */
196
+ export type CaseReport = Omit<CaseVerdict, "ceiling" | "logE"> & {
197
+ ceiling: JsonNumber;
198
+ logE: JsonNumber;
199
+ };
200
+
201
+ /** A `GateResult` as `gateReport` writes it, and as `JSON.stringify(result)` does. */
202
+ export type GateReport = Omit<GateResult, "cases" | "regressed"> & {
203
+ cases: CaseReport[];
204
+ regressed: CaseReport[];
205
+ };
206
+
207
+ const jsonSafe = (v: unknown): unknown => {
208
+ if (typeof v === "number") return Number.isFinite(v) ? v : String(v);
209
+ if (Array.isArray(v)) return v.map(jsonSafe);
210
+ if (v !== null && typeof v === "object") {
211
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, jsonSafe(x)]));
212
+ }
213
+ return v;
214
+ };
215
+
216
+ /**
217
+ * A copy of `result` that survives JSON: `JSON.stringify` writes `Infinity` as
218
+ * `null`, which a reader of a CI artifact takes for "no ceiling" where the truth
219
+ * is "no limit". Every non-finite number becomes a string instead.
220
+ */
221
+ export function gateReport(result: GateResult): GateReport {
222
+ return jsonSafe(result) as GateReport;
223
+ }
224
+
192
225
  /**
193
226
  * Decide which cases regressed.
194
227
  *
@@ -220,8 +253,19 @@ export function gate(
220
253
  continue;
221
254
  }
222
255
  requireCounts(c.successes, c.trials, `gate(${c.id})`);
223
- if (c.baseline)
224
- requireCounts(c.baseline.successes, c.baseline.trials, `gate(${c.id}).baseline`);
256
+ if (!c.baseline) continue;
257
+ // Only the counts are read, so a baseline joined to the wrong case was gated against without a word.
258
+ if (c.baseline.caseId !== c.id) {
259
+ throw new PeeksafeError(
260
+ "PEEKSAFE_E_CONFIG",
261
+ `gate: case ${c.id} carries the baseline of case ${c.baseline.caseId}`,
262
+ {
263
+ detail: { id: c.id, baselineCaseId: c.baseline.caseId },
264
+ hint: "join baselines to cases by id, e.g. toBaselineMap(baselines).get(c.id)",
265
+ },
266
+ );
267
+ }
268
+ requireCounts(c.baseline.successes, c.baseline.trials, `gate(${c.id}).baseline`);
225
269
  }
226
270
 
227
271
  const hasBaseline = (c: GateCase | PairedGateCase): boolean =>
@@ -350,7 +394,7 @@ export function gate(
350
394
  .join(", ")}${regressed.length > 3 ? ", …" : ""})`) +
351
395
  (caveats.length > 0 ? `. Read with care: ${caveats.join("; ")}.` : "");
352
396
 
353
- return {
397
+ const result: GateResult = {
354
398
  verdict: regressed.length === 0 ? "PASS" : "FAIL",
355
399
  cases: verdicts,
356
400
  regressed,
@@ -360,4 +404,7 @@ export function gate(
360
404
  options: opts,
361
405
  headline,
362
406
  };
407
+ // Not enumerable, so equality checks and spreads see only the fields.
408
+ Object.defineProperty(result, "toJSON", { value: () => gateReport(result) });
409
+ return result;
363
410
  }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * ## 1. The decision
12
12
  *
13
13
  * gate(cases, options) which cases regressed, valid at any stopping time
14
+ * gateReport(result) the same verdict with Infinity kept through JSON
14
15
  *
15
16
  * You bring counts. It brings e-values and e-BH. Nothing here knows or cares how
16
17
  * you ran the evals, and nothing here will gate a case that has no baseline.
@@ -109,12 +110,16 @@ export {
109
110
  } from "./frontier.js";
110
111
  /* ── 1. the decision ─────────────────────────────────────────────────────── */
111
112
  export {
113
+ type CaseReport,
112
114
  type CaseVerdict,
113
115
  DEFAULT_GATE_OPTIONS,
114
116
  type GateCase,
115
117
  type GateOptions,
118
+ type GateReport,
116
119
  type GateResult,
117
120
  gate,
121
+ gateReport,
122
+ type JsonNumber,
118
123
  type PairedGateCase,
119
124
  } from "./gate.js";
120
125
  /* ── 2. the budget ───────────────────────────────────────────────────────── */
package/src/plan.ts CHANGED
@@ -162,9 +162,10 @@ export interface PlanCase {
162
162
  */
163
163
  design: "unpaired" | "paired" | null;
164
164
  /**
165
- * How many baseline runs this case would need for the *unpaired* design to
166
- * work, or null if it already works. This is the actionable half of an
167
- * "undetectable" verdict.
165
+ * The smallest total baseline size (not an increment over the current one)
166
+ * at which the *unpaired* design would work, or null if it already works.
167
+ * A floor, not a threshold: see `baselineRunsToClear`. This is the
168
+ * actionable half of an "undetectable" verdict.
168
169
  */
169
170
  baselineRunsNeeded: number | null;
170
171
  /**
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) {
@@ -1070,6 +1113,8 @@ export function bernoulliEntropy(p: number): number {
1070
1113
  /** log of the Beta(a,b) density at p. */
1071
1114
  export function logBetaPdf(p: number, a: number, b: number): number {
1072
1115
  requireProbability(p, "p", "logBetaPdf");
1116
+ requireConcentration(a, "a", "logBetaPdf");
1117
+ requireConcentration(b, "b", "logBetaPdf");
1073
1118
  if (p <= 0 || p >= 1) {
1074
1119
  // density is 0 or ∞ at the boundary depending on the shape; the planner
1075
1120
  // only ever evaluates strictly inside, so clamp rather than return ±∞.
@@ -1145,6 +1190,9 @@ export function evidenceCeilingLogE(
1145
1190
  altConcentration = 8,
1146
1191
  ): number {
1147
1192
  requireProbability(pTrue, "pTrue", "evidenceCeilingLogE");
1193
+ requireCounts(baselineSuccesses, baselineTrials, "evidenceCeilingLogE");
1194
+ requireConcentration(altConcentration, "altConcentration", "evidenceCeilingLogE");
1195
+ requireOpenProbability(mde, "mde", "evidenceCeilingLogE");
1148
1196
  const { nullPrior, altPrior } = twoSamplePriors(
1149
1197
  baselineSuccesses,
1150
1198
  baselineTrials,
@@ -1212,6 +1260,8 @@ export function evidenceCeilingAsymptotic(
1212
1260
  ): number {
1213
1261
  requireCounts(baselineSuccesses, baselineTrials, "evidenceCeilingAsymptotic");
1214
1262
  requireProbability(pTrue, "pTrue", "evidenceCeilingAsymptotic");
1263
+ requireConcentration(altConcentration, "altConcentration", "evidenceCeilingAsymptotic");
1264
+ requireOpenProbability(mde, "mde", "evidenceCeilingAsymptotic");
1215
1265
  if (baselineTrials === 0) {
1216
1266
  throw new PeeksafeError(
1217
1267
  "PEEKSAFE_E_STAT_DOMAIN",
@@ -1273,6 +1323,9 @@ export function expectedLogE(
1273
1323
  detail: { n },
1274
1324
  });
1275
1325
  }
1326
+ requireCounts(baselineSuccesses, baselineTrials, "expectedLogE");
1327
+ requireConcentration(altConcentration, "altConcentration", "expectedLogE");
1328
+ requireOpenProbability(mde, "mde", "expectedLogE");
1276
1329
  const { nullPrior: n0, altPrior: a1 } = twoSamplePriors(
1277
1330
  baselineSuccesses,
1278
1331
  baselineTrials,
@@ -1306,6 +1359,9 @@ export function expectedLogEExact(
1306
1359
  { detail: { n } },
1307
1360
  );
1308
1361
  }
1362
+ requireCounts(baselineSuccesses, baselineTrials, "expectedLogEExact");
1363
+ requireConcentration(altConcentration, "altConcentration", "expectedLogEExact");
1364
+ requireOpenProbability(mde, "mde", "expectedLogEExact");
1309
1365
  let acc = 0;
1310
1366
  for (let s = 0; s <= n; s++) {
1311
1367
  const logW =
@@ -1338,6 +1394,10 @@ export function samplesForEvidence(
1338
1394
  ): number {
1339
1395
  requireSearchCap(maxN, "maxN", "samplesForEvidence");
1340
1396
  requireLogThreshold(logThreshold, "samplesForEvidence");
1397
+ requireProbability(pTrue, "pTrue", "samplesForEvidence");
1398
+ requireCounts(baselineSuccesses, baselineTrials, "samplesForEvidence");
1399
+ requireConcentration(altConcentration, "altConcentration", "samplesForEvidence");
1400
+ requireOpenProbability(mde, "mde", "samplesForEvidence");
1341
1401
  const ceiling = evidenceCeilingLogE(
1342
1402
  pTrue,
1343
1403
  baselineSuccesses,
@@ -1406,6 +1466,8 @@ export function universalTwoSampleLogE(
1406
1466
  ): number {
1407
1467
  requireCounts(candidateSuccesses, candidateTrials, "universalTwoSampleLogE(candidate)");
1408
1468
  requireCounts(baselineSuccesses, baselineTrials, "universalTwoSampleLogE(baseline)");
1469
+ requireConcentration(altConcentration, "altConcentration", "universalTwoSampleLogE");
1470
+ requireOpenProbability(mde, "mde", "universalTwoSampleLogE");
1409
1471
  if (baselineTrials === 0) {
1410
1472
  throw new PeeksafeError(
1411
1473
  "PEEKSAFE_E_STAT_DOMAIN",
@@ -1586,6 +1648,9 @@ export function mcnemarSamplesForEvidence(
1586
1648
  requireConcentration(concentration, "concentration", "mcnemarSamplesForEvidence");
1587
1649
  requireSearchCap(maxPairs, "maxPairs", "mcnemarSamplesForEvidence");
1588
1650
  requireLogThreshold(logThreshold, "mcnemarSamplesForEvidence");
1651
+ requireProbability(pBaseline, "pBaseline", "mcnemarSamplesForEvidence");
1652
+ requireProbability(rho, "rho", "mcnemarSamplesForEvidence");
1653
+ requireOpenProbability(mde, "mde", "mcnemarSamplesForEvidence");
1589
1654
  const { rate, theta } = pairedDiscordance(pBaseline, mde, rho);
1590
1655
  if (rate <= 0 || theta <= 0.5) return Infinity;
1591
1656
  const a1 = Math.max(0.35, concentration * theta);