peeksafe 0.0.0-stage → 0.1.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 +146 -0
- package/LICENSE +190 -0
- package/NOTICE +78 -0
- package/README.md +492 -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 +598 -0
- package/dist/cluster.js.map +1 -0
- package/dist/errors.d.ts +87 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +143 -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 +269 -0
- package/dist/frontier.d.ts.map +1 -0
- package/dist/frontier.js +460 -0
- package/dist/frontier.js.map +1 -0
- package/dist/gate.d.ts +173 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +196 -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 +248 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +482 -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 +92 -0
- package/dist/rand.js.map +1 -0
- package/dist/stats.d.ts +397 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +1245 -0
- package/dist/stats.js.map +1 -0
- package/dist/stop.d.ts +163 -0
- package/dist/stop.d.ts.map +1 -0
- package/dist/stop.js +262 -0
- package/dist/stop.js.map +1 -0
- package/package.json +64 -4
- package/src/baseline.ts +72 -0
- package/src/check.ts +238 -0
- package/src/cluster.ts +830 -0
- package/src/errors.ts +208 -0
- package/src/evidence.ts +62 -0
- package/src/frontier.ts +782 -0
- package/src/gate.ts +358 -0
- package/src/index.ts +206 -0
- package/src/plan.ts +778 -0
- package/src/rand.ts +121 -0
- package/src/stats.ts +1579 -0
- package/src/stop.ts +452 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
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 the sequential test a planner prices: each strictly
|
|
157
|
+
* 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` answers 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
|
+
/** The four cells of a pair table, each a non-negative integer. */
|
|
180
|
+
export function requirePairedCounts(
|
|
181
|
+
p: { bothPass: number; worse: number; better: number; bothFail: number },
|
|
182
|
+
where: string,
|
|
183
|
+
): void {
|
|
184
|
+
for (const name of ["bothPass", "worse", "better", "bothFail"] as const) {
|
|
185
|
+
const v = p[name];
|
|
186
|
+
if (!Number.isInteger(v) || v < 0) {
|
|
187
|
+
throw new PeeksafeError(
|
|
188
|
+
"PEEKSAFE_E_STAT_DOMAIN",
|
|
189
|
+
`${where}: ${name} must be a non-negative integer, got ${v}`,
|
|
190
|
+
{
|
|
191
|
+
detail: { [name]: v, where },
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function requireProbability(p: number, name: string, where: string): void {
|
|
199
|
+
if (!Number.isFinite(p) || p < 0 || p > 1) {
|
|
200
|
+
throw new PeeksafeError(
|
|
201
|
+
"PEEKSAFE_E_STAT_DOMAIN",
|
|
202
|
+
`${where}: ${name} must be in [0,1], got ${p}`,
|
|
203
|
+
{
|
|
204
|
+
detail: { [name]: p, where },
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
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);
|