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.
Files changed (66) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +78 -0
  4. package/README.md +492 -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 +598 -0
  17. package/dist/cluster.js.map +1 -0
  18. package/dist/errors.d.ts +87 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +143 -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 +269 -0
  27. package/dist/frontier.d.ts.map +1 -0
  28. package/dist/frontier.js +460 -0
  29. package/dist/frontier.js.map +1 -0
  30. package/dist/gate.d.ts +173 -0
  31. package/dist/gate.d.ts.map +1 -0
  32. package/dist/gate.js +196 -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 +248 -0
  39. package/dist/plan.d.ts.map +1 -0
  40. package/dist/plan.js +482 -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 +92 -0
  45. package/dist/rand.js.map +1 -0
  46. package/dist/stats.d.ts +397 -0
  47. package/dist/stats.d.ts.map +1 -0
  48. package/dist/stats.js +1245 -0
  49. package/dist/stats.js.map +1 -0
  50. package/dist/stop.d.ts +163 -0
  51. package/dist/stop.d.ts.map +1 -0
  52. package/dist/stop.js +262 -0
  53. package/dist/stop.js.map +1 -0
  54. package/package.json +64 -4
  55. package/src/baseline.ts +72 -0
  56. package/src/check.ts +238 -0
  57. package/src/cluster.ts +830 -0
  58. package/src/errors.ts +208 -0
  59. package/src/evidence.ts +62 -0
  60. package/src/frontier.ts +782 -0
  61. package/src/gate.ts +358 -0
  62. package/src/index.ts +206 -0
  63. package/src/plan.ts +778 -0
  64. package/src/rand.ts +121 -0
  65. package/src/stats.ts +1579 -0
  66. package/src/stop.ts +452 -0
@@ -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
+ }