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.
- package/CHANGELOG.md +325 -0
- package/LICENSE +190 -0
- package/NOTICE +78 -0
- package/README.md +490 -2
- package/RESEARCH-NOTES.md +274 -0
- package/dist/baseline.d.ts +45 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +49 -0
- package/dist/baseline.js.map +1 -0
- package/dist/check.d.ts +64 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +172 -0
- package/dist/check.js.map +1 -0
- package/dist/cluster.d.ts +328 -0
- package/dist/cluster.d.ts.map +1 -0
- package/dist/cluster.js +606 -0
- package/dist/cluster.js.map +1 -0
- package/dist/errors.d.ts +108 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +192 -0
- package/dist/errors.js.map +1 -0
- package/dist/evidence.d.ts +15 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +30 -0
- package/dist/evidence.js.map +1 -0
- package/dist/frontier.d.ts +271 -0
- package/dist/frontier.d.ts.map +1 -0
- package/dist/frontier.js +505 -0
- package/dist/frontier.js.map +1 -0
- package/dist/gate.d.ts +175 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +200 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +79 -0
- package/dist/index.js.map +1 -0
- package/dist/plan.d.ts +280 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +626 -0
- package/dist/plan.js.map +1 -0
- package/dist/rand.d.ts +17 -0
- package/dist/rand.d.ts.map +1 -0
- package/dist/rand.js +104 -0
- package/dist/rand.js.map +1 -0
- package/dist/stats.d.ts +401 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +1304 -0
- package/dist/stats.js.map +1 -0
- package/dist/stop.d.ts +164 -0
- package/dist/stop.d.ts.map +1 -0
- package/dist/stop.js +270 -0
- package/dist/stop.js.map +1 -0
- package/package.json +62 -3
- package/src/baseline.ts +72 -0
- package/src/check.ts +238 -0
- package/src/cluster.ts +842 -0
- package/src/errors.ts +275 -0
- package/src/evidence.ts +62 -0
- package/src/frontier.ts +837 -0
- package/src/gate.ts +363 -0
- package/src/index.ts +206 -0
- package/src/plan.ts +968 -0
- package/src/rand.ts +137 -0
- package/src/stats.ts +1663 -0
- 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
|
+
}
|
package/src/evidence.ts
ADDED
|
@@ -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);
|