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/src/errors.ts ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * errors.ts: one error class, stable codes, structured detail.
3
+ *
4
+ * Nothing in peeksafe throws a bare string. Every code below is thrown
5
+ * somewhere in `src/` and is part of the public contract: a caller may branch
6
+ * on `err.code` and we will not repurpose one.
7
+ */
8
+
9
+ export type PeeksafeErrorCode =
10
+ /* input the user handed us */
11
+ | "PEEKSAFE_E_CONFIG"
12
+ | "PEEKSAFE_E_CASE_DUPLICATE"
13
+ | "PEEKSAFE_E_SUITE_EMPTY"
14
+ /* the baseline */
15
+ | "PEEKSAFE_E_BASELINE_MISSING"
16
+ /* statistics that were asked something impossible */
17
+ | "PEEKSAFE_E_STAT_DOMAIN";
18
+
19
+ export interface PeeksafeErrorOptions {
20
+ detail?: Record<string, unknown>;
21
+ /** what the operator should actually do about it */
22
+ hint?: string;
23
+ cause?: unknown;
24
+ }
25
+
26
+ export class PeeksafeError extends Error {
27
+ readonly code: PeeksafeErrorCode;
28
+ readonly detail: Record<string, unknown>;
29
+ readonly hint?: string;
30
+
31
+ constructor(code: PeeksafeErrorCode, message: string, opts: PeeksafeErrorOptions = {}) {
32
+ super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
33
+ this.name = "PeeksafeError";
34
+ this.code = code;
35
+ this.detail = opts.detail ?? {};
36
+ this.hint = opts.hint;
37
+ }
38
+
39
+ /** Stable JSON shape, for logs and CI artifacts. */
40
+ toJSON(): {
41
+ name: string;
42
+ code: string;
43
+ message: string;
44
+ hint?: string;
45
+ detail: Record<string, unknown>;
46
+ } {
47
+ return {
48
+ name: this.name,
49
+ code: this.code,
50
+ message: this.message,
51
+ ...(this.hint ? { hint: this.hint } : {}),
52
+ detail: this.detail,
53
+ };
54
+ }
55
+
56
+ static is(e: unknown): e is PeeksafeError {
57
+ return e instanceof PeeksafeError;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Domain guard for the statistics. Every function in `stats.ts` that can be
63
+ * handed nonsense (successes > trials, a negative count, an MDE larger than the
64
+ * baseline rate) routes through here rather than returning NaN.
65
+ */
66
+ export function requireCounts(successes: number, trials: number, where: string): void {
67
+ if (!Number.isFinite(successes) || !Number.isFinite(trials)) {
68
+ throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `${where}: counts must be finite`, {
69
+ detail: { successes, trials, where },
70
+ });
71
+ }
72
+ if (!Number.isInteger(successes) || !Number.isInteger(trials)) {
73
+ throw new PeeksafeError("PEEKSAFE_E_STAT_DOMAIN", `${where}: counts must be integers`, {
74
+ detail: { successes, trials, where },
75
+ });
76
+ }
77
+ if (trials < 0 || successes < 0 || successes > trials) {
78
+ throw new PeeksafeError(
79
+ "PEEKSAFE_E_STAT_DOMAIN",
80
+ `${where}: need 0 ≤ successes ≤ trials, got ${successes}/${trials}`,
81
+ { detail: { successes, trials, where } },
82
+ );
83
+ }
84
+ }
85
+
86
+ /**
87
+ * A probability that must be strictly inside (0,1), a rate, an effect size, an
88
+ * error budget. `requireProbability` allows the endpoints, which is right for a
89
+ * *rate* and wrong for an *effect*: an MDE of 0 asks for a zero-point drop to be
90
+ * detected, which every design answers "no" to for reasons that look like a
91
+ * ceiling problem and are not.
92
+ */
93
+ export function requireOpenProbability(p: number, name: string, where: string): void {
94
+ if (!Number.isFinite(p) || p <= 0 || p >= 1) {
95
+ throw new PeeksafeError(
96
+ "PEEKSAFE_E_STAT_DOMAIN",
97
+ `${where}: ${name} must be strictly between 0 and 1, got ${p}`,
98
+ {
99
+ detail: { [name]: p, where },
100
+ },
101
+ );
102
+ }
103
+ }
104
+
105
+ /**
106
+ * A configuration number that must be finite and strictly positive.
107
+ *
108
+ * Shared by `gate` and `shouldStop` so the two entry points cannot drift on
109
+ * what they accept. They did drift: `gate` rejected a non-positive
110
+ * `altConcentration` and `shouldStop` passed it through to a prior floor, so
111
+ * the same options object was a refusal through one door and a verdict through
112
+ * the other.
113
+ */
114
+ export function requirePositiveConfig(v: number, name: string, where: string): void {
115
+ if (!Number.isFinite(v) || v <= 0) {
116
+ throw new PeeksafeError(
117
+ "PEEKSAFE_E_CONFIG",
118
+ `${where}: ${name} must be finite and positive, got ${v}`,
119
+ {
120
+ detail: { [name]: v, where },
121
+ },
122
+ );
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The largest `altConcentration` the default statistic accepts at a verdict.
128
+ *
129
+ * Its error control is computed rather than proven, and a concentrated
130
+ * alternative is the one known way it exceeds alpha at a rate inside the
131
+ * checked range: 1.04 times alpha at 32 (`test/error-control.test.ts`). At 16
132
+ * the worst checked cell is 0.91 of alpha.
133
+ */
134
+ export const MAX_ALT_CONCENTRATION = 16;
135
+
136
+ /**
137
+ * `altConcentration` for a verdict: finite and positive, and at most
138
+ * `MAX_ALT_CONCENTRATION` under the default `bayes` statistic. `universal` is
139
+ * valid at every rate by construction, so it takes any positive value.
140
+ */
141
+ export function requireAltConcentration(v: number, evidence: string, where: string): void {
142
+ requirePositiveConfig(v, "altConcentration", where);
143
+ if (evidence === "bayes" && v > MAX_ALT_CONCENTRATION) {
144
+ throw new PeeksafeError(
145
+ "PEEKSAFE_E_CONFIG",
146
+ `${where}: altConcentration must be at most ${MAX_ALT_CONCENTRATION} with the default statistic, got ${v}`,
147
+ {
148
+ detail: { altConcentration: v, max: MAX_ALT_CONCENTRATION, evidence, where },
149
+ hint: "Use evidence: 'universal' for a more concentrated alternative, or check the rates with typeOneError.",
150
+ },
151
+ );
152
+ }
153
+ }
154
+
155
+ /**
156
+ * The error rates of a Wald SPRT, run by `sprtDecision` or priced by a
157
+ * planner: each strictly between 0 and 1, and summing to less than 1.
158
+ *
159
+ * At `alpha + beta >= 1` Wald's walls meet or cross, so the test decides
160
+ * before it has seen a run. `sprtExpectedN` answered a small positive number
161
+ * there, and a plan at `alpha: 0.6, beta: 0.5` priced every case at
162
+ * `minTrials` with nothing to say the design had no error control left.
163
+ */
164
+ export function requireErrorRates(alpha: number, beta: number, where: string): void {
165
+ requireOpenProbability(alpha, "alpha", where);
166
+ requireOpenProbability(beta, "beta", where);
167
+ if (alpha + beta >= 1) {
168
+ throw new PeeksafeError(
169
+ "PEEKSAFE_E_CONFIG",
170
+ `${where}: alpha + beta must be under 1, got ${alpha} + ${beta}`,
171
+ {
172
+ detail: { alpha, beta, where },
173
+ hint: "a test with alpha + beta >= 1 does no better than ignoring the data",
174
+ },
175
+ );
176
+ }
177
+ }
178
+
179
+ /**
180
+ * `runsPerCaseCeiling`: finite and at least one run. The planners search up to
181
+ * eight times it, and at NaN or 0 that search never ran, so every case came
182
+ * back undecidable with a reason that blamed the evidence bar.
183
+ */
184
+ export function requireRunCeiling(v: number, where: string): void {
185
+ if (!(v >= 1) || !Number.isFinite(v)) {
186
+ throw new PeeksafeError(
187
+ "PEEKSAFE_E_CONFIG",
188
+ `${where}: runsPerCaseCeiling must be finite and at least 1, got ${v}`,
189
+ {
190
+ detail: { runsPerCaseCeiling: v, where },
191
+ },
192
+ );
193
+ }
194
+ }
195
+
196
+ /**
197
+ * The batch grid a plan prices the gate on: `minTrials` and `batchSize`
198
+ * positive integers, `maxTrials` an integer at least `minTrials`. A NaN bound
199
+ * or a zero batch made the typical bill NaN, and a cap under the minimum
200
+ * priced every case at the cap.
201
+ */
202
+ export function requireTrialGrid(
203
+ cfg: { minTrials: number; maxTrials: number; batchSize: number },
204
+ where: string,
205
+ ): void {
206
+ const { minTrials, maxTrials, batchSize } = cfg;
207
+ const detail = { minTrials, maxTrials, batchSize, where };
208
+ for (const [name, v] of [
209
+ ["minTrials", minTrials],
210
+ ["batchSize", batchSize],
211
+ ] as const) {
212
+ if (!Number.isInteger(v) || v < 1) {
213
+ throw new PeeksafeError(
214
+ "PEEKSAFE_E_CONFIG",
215
+ `${where}: ${name} must be a positive integer, got ${v}`,
216
+ { detail },
217
+ );
218
+ }
219
+ }
220
+ if (!Number.isInteger(maxTrials) || maxTrials < minTrials) {
221
+ throw new PeeksafeError(
222
+ "PEEKSAFE_E_CONFIG",
223
+ `${where}: maxTrials must be an integer ≥ minTrials, got ${maxTrials} vs ${minTrials}`,
224
+ {
225
+ detail,
226
+ hint: "the typical bill is clamped to [minTrials, maxTrials]; a cap under the minimum prices a gate that stops before it starts",
227
+ },
228
+ );
229
+ }
230
+ }
231
+
232
+ /** The four cells of a pair table, each a non-negative integer. */
233
+ export function requirePairedCounts(
234
+ p: { bothPass: number; worse: number; better: number; bothFail: number },
235
+ where: string,
236
+ ): void {
237
+ for (const name of ["bothPass", "worse", "better", "bothFail"] as const) {
238
+ const v = p[name];
239
+ if (!Number.isInteger(v) || v < 0) {
240
+ throw new PeeksafeError(
241
+ "PEEKSAFE_E_STAT_DOMAIN",
242
+ `${where}: ${name} must be a non-negative integer, got ${v}`,
243
+ {
244
+ detail: { [name]: v, where },
245
+ },
246
+ );
247
+ }
248
+ }
249
+ }
250
+
251
+ export function requireProbability(p: number, name: string, where: string): void {
252
+ if (!Number.isFinite(p) || p < 0 || p > 1) {
253
+ throw new PeeksafeError(
254
+ "PEEKSAFE_E_STAT_DOMAIN",
255
+ `${where}: ${name} must be in [0,1], got ${p}`,
256
+ {
257
+ detail: { [name]: p, where },
258
+ },
259
+ );
260
+ }
261
+ }
262
+
263
+ /** Case ids, each at most once: a repeated id is planned twice and inflates the suite size the bar is set from. */
264
+ export function requireUniqueCaseIds(cases: readonly { id: string }[], where: string): void {
265
+ const seen = new Set<string>();
266
+ for (const c of cases) {
267
+ if (seen.has(c.id)) {
268
+ throw new PeeksafeError("PEEKSAFE_E_CASE_DUPLICATE", `${where}: duplicate case id ${c.id}`, {
269
+ detail: { id: c.id, where },
270
+ hint: "a case listed twice is priced twice and counted twice in the suite size; dedupe the case list before calling",
271
+ });
272
+ }
273
+ seen.add(c.id);
274
+ }
275
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * evidence.ts: which two-sample e-value a decision uses, and its ceiling.
3
+ *
4
+ * `bayes` is `twoSampleLogE`, the default: more evidence per run, with error
5
+ * control checked numerically rather than proven. `universal` is
6
+ * `universalTwoSampleLogE`: valid at every rate by construction, with less
7
+ * evidence per run. README, "What that guarantee rests on", has the numbers.
8
+ */
9
+
10
+ import { PeeksafeError } from "./errors.js";
11
+ import {
12
+ evidenceCeilingLogE,
13
+ twoSampleLogE,
14
+ universalCeilingLogE,
15
+ universalTwoSampleLogE,
16
+ } from "./stats.js";
17
+
18
+ export type Evidence = "bayes" | "universal";
19
+
20
+ export function requireEvidence(evidence: unknown, where: string): asserts evidence is Evidence {
21
+ if (evidence !== "bayes" && evidence !== "universal") {
22
+ throw new PeeksafeError(
23
+ "PEEKSAFE_E_CONFIG",
24
+ `${where}: evidence must be 'bayes' or 'universal', got ${String(evidence)}`,
25
+ {
26
+ detail: { evidence },
27
+ },
28
+ );
29
+ }
30
+ }
31
+
32
+ export const logEvidence = (
33
+ evidence: Evidence,
34
+ cs: number,
35
+ cn: number,
36
+ bs: number,
37
+ bn: number,
38
+ mde: number,
39
+ altConcentration: number,
40
+ ): number =>
41
+ evidence === "universal"
42
+ ? universalTwoSampleLogE(cs, cn, bs, bn, mde, altConcentration)
43
+ : twoSampleLogE(cs, cn, bs, bn, mde, altConcentration);
44
+
45
+ // exp() of a large logE overflows, and e-BH cannot rank an infinite entry; the largest double keeps the order.
46
+ export const toEvalue = (logE: number): number => {
47
+ const e = Math.exp(logE);
48
+ return Number.isFinite(e) ? e : Number.MAX_VALUE;
49
+ };
50
+
51
+ /** The most evidence a candidate at rate `p` could ever reach against this baseline. */
52
+ export const ceilingLogEvidence = (
53
+ evidence: Evidence,
54
+ p: number,
55
+ bs: number,
56
+ bn: number,
57
+ mde: number,
58
+ altConcentration: number,
59
+ ): number =>
60
+ evidence === "universal"
61
+ ? universalCeilingLogE(p, bs, bn)
62
+ : evidenceCeilingLogE(p, bs, bn, mde, altConcentration);