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/baseline.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* baseline.ts: what a case's reference revision did, and the null it implies.
|
|
3
|
+
*
|
|
4
|
+
* peeksafe never runs anything. A caller brings counts, and these are the shapes
|
|
5
|
+
* those counts arrive in.
|
|
6
|
+
*
|
|
7
|
+
* The distinction that matters is between a baseline that says "this case passed
|
|
8
|
+
* 51 of 60 times" and no baseline at all. The prototype this library came from
|
|
9
|
+
* treated a missing baseline as `0/60`, whose Beta(1,1) posterior median is 0.5,
|
|
10
|
+
* and cheerfully gated against a null of "this case passes half the time". It
|
|
11
|
+
* reported a verdict of PASS and a suite improvement of +87.5 points, measured
|
|
12
|
+
* against nothing. Every entry point here therefore takes `BaselineStat |
|
|
13
|
+
* undefined` explicitly and refuses to invent one.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { PeeksafeError, requireCounts, requireOpenProbability } from "./errors.js";
|
|
17
|
+
import { betaQuantile } from "./stats.js";
|
|
18
|
+
|
|
19
|
+
/** One case's recorded reference performance. */
|
|
20
|
+
export interface BaselineStat {
|
|
21
|
+
caseId: string;
|
|
22
|
+
successes: number;
|
|
23
|
+
trials: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The minimum a case has to declare to be planned or gated. `suite` groups
|
|
28
|
+
* cases into families for the cluster-robust suite summary; leave it undefined
|
|
29
|
+
* and every case is its own family.
|
|
30
|
+
*/
|
|
31
|
+
export interface CaseRef {
|
|
32
|
+
id: string;
|
|
33
|
+
suite?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The rate the null is tested at.
|
|
38
|
+
*
|
|
39
|
+
* Not `successes / trials`. A 60-run baseline at a true rate of 0.85 has a
|
|
40
|
+
* standard error of 0.046, which is a third of a 15-point effect, so testing
|
|
41
|
+
* against the point estimate treats a noisy number as an oracle. When the
|
|
42
|
+
* baseline happens to have come in high, every candidate looks worse than it is.
|
|
43
|
+
* Taking a low quantile of the baseline's own posterior instead is what stops
|
|
44
|
+
* that, at the cost of some power.
|
|
45
|
+
*
|
|
46
|
+
* `q` is that quantile. Lower is more conservative.
|
|
47
|
+
*/
|
|
48
|
+
export function baselineNullRate(b: BaselineStat, q = 0.25): number {
|
|
49
|
+
requireCounts(b.successes, b.trials, "baselineNullRate");
|
|
50
|
+
requireOpenProbability(q, "q", "baselineNullRate");
|
|
51
|
+
return betaQuantile(1 + b.successes, 1 + b.trials - b.successes, q);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Index a list of baselines by case id, rejecting duplicates rather than merging them. */
|
|
55
|
+
export function toBaselineMap(stats: readonly BaselineStat[]): Map<string, BaselineStat> {
|
|
56
|
+
const m = new Map<string, BaselineStat>();
|
|
57
|
+
for (const b of stats) {
|
|
58
|
+
if (m.has(b.caseId)) {
|
|
59
|
+
throw new PeeksafeError(
|
|
60
|
+
"PEEKSAFE_E_CASE_DUPLICATE",
|
|
61
|
+
`toBaselineMap: duplicate baseline for case ${b.caseId}`,
|
|
62
|
+
{
|
|
63
|
+
detail: { caseId: b.caseId },
|
|
64
|
+
hint: "merging two baselines for one case would count its runs twice; dedupe them before calling",
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
requireCounts(b.successes, b.trials, `toBaselineMap(${b.caseId})`);
|
|
69
|
+
m.set(b.caseId, b);
|
|
70
|
+
}
|
|
71
|
+
return m;
|
|
72
|
+
}
|
package/src/check.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check.ts: the error rate and the power the gate delivers at rates you name.
|
|
3
|
+
*
|
|
4
|
+
* The two-sample e-value is not an e-value at every fixed rate (README, "What
|
|
5
|
+
* that guarantee rests on"), so its error control is computed rather than
|
|
6
|
+
* assumed. This is that computation, for a caller whose rates, baseline sizes
|
|
7
|
+
* or options sit outside the grid the tests check. Moving the candidate below
|
|
8
|
+
* the baseline turns the same computation into the power.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
PeeksafeError,
|
|
13
|
+
requireOpenProbability,
|
|
14
|
+
requirePositiveConfig,
|
|
15
|
+
requireProbability,
|
|
16
|
+
} from "./errors.js";
|
|
17
|
+
import { type Evidence, requireEvidence } from "./evidence.js";
|
|
18
|
+
import { cannotDropBy, DEFAULT_GATE_OPTIONS } from "./gate.js";
|
|
19
|
+
import { logBeta, logGamma, pairedLogE, twoSamplePriors } from "./stats.js";
|
|
20
|
+
|
|
21
|
+
export interface TypeOneErrorOptions {
|
|
22
|
+
/** Shared true pass rate of the baseline and the candidate. */
|
|
23
|
+
rate: number;
|
|
24
|
+
/** Baseline runs the case will be gated against. */
|
|
25
|
+
baselineTrials: number;
|
|
26
|
+
/** The bar is `1 / alpha`. For `gate`'s solo bar pass `fdr / suiteSize`. */
|
|
27
|
+
alpha: number;
|
|
28
|
+
/** The most candidate runs any case could reach, e.g. `shouldStop`'s `maxTrials`. */
|
|
29
|
+
horizon: number;
|
|
30
|
+
mde?: number;
|
|
31
|
+
altConcentration?: number;
|
|
32
|
+
/** Which e-value `gate` will use. */
|
|
33
|
+
evidence?: Evidence;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CertifyProbabilityOptions extends Omit<TypeOneErrorOptions, "rate"> {
|
|
37
|
+
/** True pass rate of the baseline revision. */
|
|
38
|
+
baselineRate: number;
|
|
39
|
+
/** True pass rate of the candidate revision. */
|
|
40
|
+
candidateRate: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Exact probability that a case is certified as regressed within `horizon`
|
|
45
|
+
* candidate runs, however it was stopped. With the candidate below the baseline
|
|
46
|
+
* this is the power; at or above it, the type I error.
|
|
47
|
+
*
|
|
48
|
+
* Exact because the e-value depends on the candidate only through its counts,
|
|
49
|
+
* so a dynamic program over the success count replaces simulation. A 240-run
|
|
50
|
+
* baseline at a horizon of 600 takes tens of milliseconds.
|
|
51
|
+
*/
|
|
52
|
+
export function certifyProbability(options: CertifyProbabilityOptions): number {
|
|
53
|
+
const { baselineRate, candidateRate: pc, baselineTrials: nb, alpha, horizon } = options;
|
|
54
|
+
const mde = options.mde ?? DEFAULT_GATE_OPTIONS.mde;
|
|
55
|
+
const concentration = options.altConcentration ?? DEFAULT_GATE_OPTIONS.altConcentration;
|
|
56
|
+
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");
|
|
63
|
+
for (const [name, v] of [
|
|
64
|
+
["baselineTrials", nb],
|
|
65
|
+
["horizon", horizon],
|
|
66
|
+
] as const) {
|
|
67
|
+
if (!Number.isInteger(v) || v < 1) {
|
|
68
|
+
throw new PeeksafeError(
|
|
69
|
+
"PEEKSAFE_E_CONFIG",
|
|
70
|
+
`certifyProbability: ${name} must be a positive integer, got ${v}`,
|
|
71
|
+
{
|
|
72
|
+
detail: { [name]: v },
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const bar = Math.log(1 / alpha);
|
|
79
|
+
const logChoose = (n: number, k: number) =>
|
|
80
|
+
logGamma(n + 1) - logGamma(k + 1) - logGamma(n - k + 1);
|
|
81
|
+
let total = 0;
|
|
82
|
+
for (let sb = 0; sb <= nb; sb++) {
|
|
83
|
+
const weight = Math.exp(
|
|
84
|
+
logChoose(nb, sb) + sb * Math.log(baselineRate) + (nb - sb) * Math.log(1 - baselineRate),
|
|
85
|
+
);
|
|
86
|
+
if (weight < 1e-12 || cannotDropBy({ successes: sb, trials: nb }, mde)) continue;
|
|
87
|
+
const { nullPrior: n0, altPrior: a1 } = twoSamplePriors(sb, nb, mde, concentration);
|
|
88
|
+
let alive = new Float64Array(horizon + 1);
|
|
89
|
+
alive[0] = 1;
|
|
90
|
+
let crossed = 0;
|
|
91
|
+
// Only the band of success counts still carrying mass is stepped.
|
|
92
|
+
let lo = 0;
|
|
93
|
+
let hi = 0;
|
|
94
|
+
for (let n = 1; n <= horizon; n++) {
|
|
95
|
+
const next = new Float64Array(horizon + 1);
|
|
96
|
+
for (let s = lo; s <= hi; s++) {
|
|
97
|
+
next[s + 1]! += alive[s]! * pc;
|
|
98
|
+
next[s]! += alive[s]! * (1 - pc);
|
|
99
|
+
}
|
|
100
|
+
hi++;
|
|
101
|
+
// Every count in the band is tested: logE is not monotone in s, so the
|
|
102
|
+
// crossing set need not be a prefix. Successive counts differ by a ratio
|
|
103
|
+
// of Beta functions, which keeps this cheap.
|
|
104
|
+
let logE = rawLogE(lo, n, sb, nb, n0, a1);
|
|
105
|
+
for (let s = lo; s <= hi; s++) {
|
|
106
|
+
if (s > lo) {
|
|
107
|
+
const f = n - s + 1;
|
|
108
|
+
logE +=
|
|
109
|
+
Math.log((a1.a + s - 1) / (a1.b + f - 1)) - Math.log((n0.a + s - 1) / (n0.b + f - 1));
|
|
110
|
+
}
|
|
111
|
+
const decided =
|
|
112
|
+
evidence === "universal"
|
|
113
|
+
? universalFromBayes(logE, s, n, sb, nb)
|
|
114
|
+
: s / n >= sb / nb
|
|
115
|
+
? Math.min(0, logE)
|
|
116
|
+
: logE;
|
|
117
|
+
if (decided >= bar) {
|
|
118
|
+
crossed += next[s]!;
|
|
119
|
+
next[s] = 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
while (lo < hi && next[lo]! < 1e-18) lo++;
|
|
123
|
+
while (hi > lo && next[hi]! < 1e-18) hi--;
|
|
124
|
+
alive = next;
|
|
125
|
+
}
|
|
126
|
+
total += weight * crossed;
|
|
127
|
+
}
|
|
128
|
+
return total;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const xlogy = (x: number, y: number): number => (x === 0 ? 0 : x * Math.log(y));
|
|
132
|
+
|
|
133
|
+
/** The uncapped Bayes factor, which both statistics are built from. */
|
|
134
|
+
const rawLogE = (
|
|
135
|
+
s: number,
|
|
136
|
+
n: number,
|
|
137
|
+
_sb: number,
|
|
138
|
+
_nb: number,
|
|
139
|
+
n0: { a: number; b: number },
|
|
140
|
+
a1: { a: number; b: number },
|
|
141
|
+
): number =>
|
|
142
|
+
logBeta(a1.a + s, a1.b + n - s) -
|
|
143
|
+
logBeta(a1.a, a1.b) -
|
|
144
|
+
(logBeta(n0.a + s, n0.b + n - s) - logBeta(n0.a, n0.b));
|
|
145
|
+
|
|
146
|
+
/** `universalTwoSampleLogE`, from the uncapped Bayes factor: times the pooled mixture over the null maximum. */
|
|
147
|
+
function universalFromBayes(rawLog: number, s: number, n: number, sb: number, nb: number): number {
|
|
148
|
+
const S = sb + s;
|
|
149
|
+
const N = nb + n;
|
|
150
|
+
const pb = sb / nb;
|
|
151
|
+
const pc = s / n;
|
|
152
|
+
const sup =
|
|
153
|
+
pc >= pb
|
|
154
|
+
? xlogy(sb, pb) + xlogy(nb - sb, 1 - pb) + xlogy(s, pc) + xlogy(n - s, 1 - pc)
|
|
155
|
+
: xlogy(S, S / N) + xlogy(N - S, 1 - S / N);
|
|
156
|
+
return rawLog + logBeta(1 + S, 1 + N - S) - sup;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Exact probability that a case which did not move is ever certified within
|
|
161
|
+
* `horizon` candidate runs, however it was stopped. At or below `alpha` means
|
|
162
|
+
* the gate keeps its promise for this case.
|
|
163
|
+
*/
|
|
164
|
+
export function typeOneError(options: TypeOneErrorOptions): number {
|
|
165
|
+
const { rate, ...rest } = options;
|
|
166
|
+
requireOpenProbability(rate, "rate", "typeOneError");
|
|
167
|
+
return certifyProbability({ ...rest, baselineRate: rate, candidateRate: rate });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface PairedCertifyProbabilityOptions {
|
|
171
|
+
/** True pass rate of the baseline revision. */
|
|
172
|
+
baselineRate: number;
|
|
173
|
+
/** True pass rate of the candidate revision. */
|
|
174
|
+
candidateRate: number;
|
|
175
|
+
/** Share of pairs whose two runs see the same random draw (a shared seed); 0 for independent runs. */
|
|
176
|
+
coupling?: number;
|
|
177
|
+
/** The bar is `1 / alpha`. For `gate`'s solo bar pass `fdr / suiteSize`. */
|
|
178
|
+
alpha: number;
|
|
179
|
+
/** The most pairs any case could reach. */
|
|
180
|
+
horizon: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `certifyProbability` for a paired case: the exact probability it is certified within
|
|
185
|
+
* `horizon` pairs, however it was stopped. The power when the candidate is lower.
|
|
186
|
+
*/
|
|
187
|
+
export function pairedCertifyProbability(options: PairedCertifyProbabilityOptions): number {
|
|
188
|
+
const { baselineRate: pb, candidateRate: pc, alpha, horizon } = options;
|
|
189
|
+
const rho = options.coupling ?? 0;
|
|
190
|
+
requireOpenProbability(pb, "baselineRate", "pairedCertifyProbability");
|
|
191
|
+
requireOpenProbability(pc, "candidateRate", "pairedCertifyProbability");
|
|
192
|
+
requireOpenProbability(alpha, "alpha", "pairedCertifyProbability");
|
|
193
|
+
requireProbability(rho, "coupling", "pairedCertifyProbability");
|
|
194
|
+
if (!Number.isInteger(horizon) || horizon < 1) {
|
|
195
|
+
throw new PeeksafeError(
|
|
196
|
+
"PEEKSAFE_E_CONFIG",
|
|
197
|
+
`pairedCertifyProbability: horizon must be a positive integer, got ${horizon}`,
|
|
198
|
+
{
|
|
199
|
+
detail: { horizon },
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const worse = rho * Math.max(0, pb - pc) + (1 - rho) * pb * (1 - pc);
|
|
204
|
+
const better = rho * Math.max(0, pc - pb) + (1 - rho) * pc * (1 - pb);
|
|
205
|
+
const bar = Math.log(1 / alpha);
|
|
206
|
+
// The e-value depends on (worse, discordant) only, so crossing is tabulated once per state.
|
|
207
|
+
const crosses = Array.from({ length: horizon + 1 }, (_, d) =>
|
|
208
|
+
Uint8Array.from({ length: d + 1 }, (_, w) => (pairedLogE(w, d) >= bar ? 1 : 0)),
|
|
209
|
+
);
|
|
210
|
+
// alive[d][w]: probability of d discordant pairs, w of them worse, and no crossing yet.
|
|
211
|
+
let alive: Float64Array[] = [Float64Array.of(1)];
|
|
212
|
+
let crossed = 0;
|
|
213
|
+
for (let n = 1; n <= horizon; n++) {
|
|
214
|
+
const next = Array.from({ length: n + 1 }, (_, d) => new Float64Array(d + 1));
|
|
215
|
+
for (let d = 0; d < n; d++) {
|
|
216
|
+
const row = alive[d]!;
|
|
217
|
+
for (let w = 0; w <= d; w++) {
|
|
218
|
+
const m = row[w]!;
|
|
219
|
+
if (m < 1e-300) continue;
|
|
220
|
+
next[d]![w]! += m * (1 - worse - better);
|
|
221
|
+
next[d + 1]![w + 1]! += m * worse;
|
|
222
|
+
next[d + 1]![w]! += m * better;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (let d = 1; d <= n; d++) {
|
|
226
|
+
const row = next[d]!;
|
|
227
|
+
const cross = crosses[d]!;
|
|
228
|
+
for (let w = 0; w <= d; w++) {
|
|
229
|
+
if (cross[w] && row[w]! > 0) {
|
|
230
|
+
crossed += row[w]!;
|
|
231
|
+
row[w] = 0;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
alive = next;
|
|
236
|
+
}
|
|
237
|
+
return crossed;
|
|
238
|
+
}
|