@crewhaus/tool-buildperf 0.7.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/README.md +194 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +715 -0
- package/dist/lib/bench.d.ts +77 -0
- package/dist/lib/bench.js +267 -0
- package/dist/lib/flaky.d.ts +112 -0
- package/dist/lib/flaky.js +346 -0
- package/dist/lib/size.d.ts +114 -0
- package/dist/lib/size.js +339 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +46 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kernel's own rule of thumb, restated here because this module refuses
|
|
3
|
+
* on it. Below this per side, `mannWhitneyU` sets `normalApproximationValid`
|
|
4
|
+
* false and its p is a rank ordering rather than a rate.
|
|
5
|
+
*/
|
|
6
|
+
export declare const MIN_SAMPLES_FOR_SIGNIFICANCE = 8;
|
|
7
|
+
export declare class BenchError extends Error {
|
|
8
|
+
readonly name = "BenchError";
|
|
9
|
+
}
|
|
10
|
+
/** One benchmark's measurements. Exactly one of the two forms is populated. */
|
|
11
|
+
export type BenchmarkInput = {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
/** Per-iteration samples. */
|
|
14
|
+
readonly base?: ReadonlyArray<number>;
|
|
15
|
+
readonly head?: ReadonlyArray<number>;
|
|
16
|
+
/** Or a single pre-aggregated number, when the framework reports only a mean. */
|
|
17
|
+
readonly baseAggregate?: number;
|
|
18
|
+
readonly headAggregate?: number;
|
|
19
|
+
};
|
|
20
|
+
export type CompareOptions = {
|
|
21
|
+
/**
|
|
22
|
+
* Required, never defaulted. The smallest relative difference this machine
|
|
23
|
+
* and this harness can actually resolve, as a percentage. A tool that
|
|
24
|
+
* guessed it for the caller would be inventing the one number that decides
|
|
25
|
+
* whether a 3% move is a regression or a warm laptop.
|
|
26
|
+
*/
|
|
27
|
+
readonly noiseFloorPercent: number;
|
|
28
|
+
readonly alpha?: number;
|
|
29
|
+
/** True for durations, false for throughput (ops/sec). */
|
|
30
|
+
readonly lowerIsBetter?: boolean;
|
|
31
|
+
readonly continuityCorrection?: boolean;
|
|
32
|
+
};
|
|
33
|
+
export type BenchmarkVerdict = "regression" | "improvement" | "no-detectable-change" | "no-significant-difference" | "cannot-tell" | "not-tested";
|
|
34
|
+
export type SideSummary = {
|
|
35
|
+
readonly n: number;
|
|
36
|
+
readonly median: number | null;
|
|
37
|
+
readonly mad: number | null;
|
|
38
|
+
readonly madScaled: number | null;
|
|
39
|
+
readonly trimmedMean: number | null;
|
|
40
|
+
readonly min: number | null;
|
|
41
|
+
readonly max: number | null;
|
|
42
|
+
/** The plain mean, reported ONLY so a reader can see how far the skew pulls it. */
|
|
43
|
+
readonly mean: number | null;
|
|
44
|
+
};
|
|
45
|
+
export type TestSummary = {
|
|
46
|
+
readonly u: number | null;
|
|
47
|
+
readonly z: number | null;
|
|
48
|
+
readonly p: number | null;
|
|
49
|
+
readonly tieGroups: number;
|
|
50
|
+
readonly normalApproximationValid: boolean;
|
|
51
|
+
readonly note: string;
|
|
52
|
+
};
|
|
53
|
+
export type BenchmarkComparison = {
|
|
54
|
+
readonly name: string;
|
|
55
|
+
readonly method: "mann-whitney" | "threshold";
|
|
56
|
+
readonly base: SideSummary;
|
|
57
|
+
readonly head: SideSummary;
|
|
58
|
+
/** Relative change of the head median against the base median. */
|
|
59
|
+
readonly changePercent: number | null;
|
|
60
|
+
readonly direction: "slower" | "faster" | "unchanged" | "unknown";
|
|
61
|
+
readonly withinNoiseFloor: boolean | null;
|
|
62
|
+
readonly test: TestSummary | null;
|
|
63
|
+
readonly verdict: BenchmarkVerdict;
|
|
64
|
+
readonly why: string;
|
|
65
|
+
};
|
|
66
|
+
export type CompareReport = {
|
|
67
|
+
readonly noiseFloorPercent: number;
|
|
68
|
+
readonly alpha: number;
|
|
69
|
+
readonly lowerIsBetter: boolean;
|
|
70
|
+
readonly minSamplesForSignificance: number;
|
|
71
|
+
readonly benchmarks: ReadonlyArray<BenchmarkComparison>;
|
|
72
|
+
readonly summary: Readonly<Record<BenchmarkVerdict, number>>;
|
|
73
|
+
readonly verdict: "regression" | "inconclusive" | "clean";
|
|
74
|
+
readonly note: string;
|
|
75
|
+
};
|
|
76
|
+
export declare function compareBenchmark(input: BenchmarkInput, options: CompareOptions): BenchmarkComparison;
|
|
77
|
+
export declare function compareBenchmarks(inputs: ReadonlyArray<BenchmarkInput>, options: CompareOptions): CompareReport;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comparing two benchmark runs without lying about what five samples can say.
|
|
3
|
+
*
|
|
4
|
+
* Benchmark timings are not normal. They are bounded below by the work the
|
|
5
|
+
* machine actually has to do and unbounded above by everything that can
|
|
6
|
+
* interrupt it, so the distribution is right-skewed and a single GC pause or
|
|
7
|
+
* a scheduler preemption lands in the tail. Averaging pulls the mean toward
|
|
8
|
+
* that tail; a t-test on five samples then reports the tail as the effect.
|
|
9
|
+
* So: the location estimate is the median, the spread is the MAD, and the
|
|
10
|
+
* test is Mann-Whitney, all of them from `@crewhaus/tool-math`'s statistics
|
|
11
|
+
* kernel rather than re-derived here.
|
|
12
|
+
*
|
|
13
|
+
* Two refusals are the point of the module:
|
|
14
|
+
*
|
|
15
|
+
* - Below the caller's DECLARED noise floor, a difference is reported as no
|
|
16
|
+
* detectable change no matter how small p is. The floor is a statement
|
|
17
|
+
* about bias the samples cannot average away — thermal throttling, a
|
|
18
|
+
* different worktree layout, a noisy neighbour — and significance says
|
|
19
|
+
* nothing about bias.
|
|
20
|
+
* - Below 8 samples per side the normal approximation to U is materially
|
|
21
|
+
* wrong (the kernel documents the size of the gap), so no significance is
|
|
22
|
+
* called at all. At the common default of 5 runs, "cannot tell" is the
|
|
23
|
+
* honest answer and this module returns it.
|
|
24
|
+
*/
|
|
25
|
+
import { statsKernel } from "@crewhaus/tool-math";
|
|
26
|
+
/**
|
|
27
|
+
* The kernel's own rule of thumb, restated here because this module refuses
|
|
28
|
+
* on it. Below this per side, `mannWhitneyU` sets `normalApproximationValid`
|
|
29
|
+
* false and its p is a rank ordering rather than a rate.
|
|
30
|
+
*/
|
|
31
|
+
export const MIN_SAMPLES_FOR_SIGNIFICANCE = 8;
|
|
32
|
+
export class BenchError extends Error {
|
|
33
|
+
name = "BenchError";
|
|
34
|
+
}
|
|
35
|
+
const EMPTY_SIDE = {
|
|
36
|
+
n: 0,
|
|
37
|
+
median: null,
|
|
38
|
+
mad: null,
|
|
39
|
+
madScaled: null,
|
|
40
|
+
trimmedMean: null,
|
|
41
|
+
min: null,
|
|
42
|
+
max: null,
|
|
43
|
+
mean: null,
|
|
44
|
+
};
|
|
45
|
+
function summarize(samples) {
|
|
46
|
+
if (samples.length === 0)
|
|
47
|
+
return EMPTY_SIDE;
|
|
48
|
+
const mad = statsKernel.medianAbsoluteDeviation(samples);
|
|
49
|
+
let min = Number.POSITIVE_INFINITY;
|
|
50
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
51
|
+
let total = 0;
|
|
52
|
+
for (const value of samples) {
|
|
53
|
+
if (value < min)
|
|
54
|
+
min = value;
|
|
55
|
+
if (value > max)
|
|
56
|
+
max = value;
|
|
57
|
+
total += value;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
n: samples.length,
|
|
61
|
+
median: statsKernel.median(samples),
|
|
62
|
+
mad: mad === null ? null : mad.mad,
|
|
63
|
+
madScaled: mad === null ? null : mad.scaled,
|
|
64
|
+
// 10% off each tail: R's convention, and enough to drop one bad sample in
|
|
65
|
+
// a run of ten without pretending the rest are clean.
|
|
66
|
+
trimmedMean: statsKernel.trimmedMean(samples, 0.1),
|
|
67
|
+
min,
|
|
68
|
+
max,
|
|
69
|
+
mean: total / samples.length,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function aggregateSide(value) {
|
|
73
|
+
// A framework that reports only a mean gives one number per side. It is
|
|
74
|
+
// recorded as the median position so the delta arithmetic is shared, but
|
|
75
|
+
// `n: 1` and `method: "threshold"` keep it out of any significance claim.
|
|
76
|
+
return { ...EMPTY_SIDE, n: 1, median: value, mean: value };
|
|
77
|
+
}
|
|
78
|
+
function assertFiniteSamples(name, side, samples) {
|
|
79
|
+
for (const value of samples) {
|
|
80
|
+
if (!Number.isFinite(value)) {
|
|
81
|
+
throw new BenchError(`benchmark "${name}" has a non-finite ${side} sample (${value}); a timing that is NaN or Infinity is a broken measurement, not a slow one`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Which way `changePercent` points, given what "better" means for this unit.
|
|
87
|
+
* Throughput benchmarks (ops/sec) invert the sign, and getting that wrong
|
|
88
|
+
* turns every improvement into a regression.
|
|
89
|
+
*/
|
|
90
|
+
function directionOf(changePercent, lowerIsBetter) {
|
|
91
|
+
if (changePercent === null)
|
|
92
|
+
return "unknown";
|
|
93
|
+
if (changePercent === 0)
|
|
94
|
+
return "unchanged";
|
|
95
|
+
const higher = changePercent > 0;
|
|
96
|
+
return higher === lowerIsBetter ? "slower" : "faster";
|
|
97
|
+
}
|
|
98
|
+
export function compareBenchmark(input, options) {
|
|
99
|
+
const lowerIsBetter = options.lowerIsBetter ?? true;
|
|
100
|
+
const alpha = options.alpha ?? 0.05;
|
|
101
|
+
const floor = options.noiseFloorPercent;
|
|
102
|
+
const hasSamples = input.base !== undefined || input.head !== undefined;
|
|
103
|
+
const hasAggregate = input.baseAggregate !== undefined || input.headAggregate !== undefined;
|
|
104
|
+
if (hasSamples && hasAggregate) {
|
|
105
|
+
throw new BenchError(`benchmark "${input.name}" carries both samples and an aggregate; give one or the other, because an aggregate cannot be tested against samples`);
|
|
106
|
+
}
|
|
107
|
+
if (!hasSamples && !hasAggregate) {
|
|
108
|
+
throw new BenchError(`benchmark "${input.name}" carries no measurements on either side`);
|
|
109
|
+
}
|
|
110
|
+
let base;
|
|
111
|
+
let head;
|
|
112
|
+
let method;
|
|
113
|
+
if (hasAggregate) {
|
|
114
|
+
if (input.baseAggregate === undefined || input.headAggregate === undefined) {
|
|
115
|
+
throw new BenchError(`benchmark "${input.name}" has an aggregate on one side only; there is nothing to compare it against`);
|
|
116
|
+
}
|
|
117
|
+
assertFiniteSamples(input.name, "base", [input.baseAggregate]);
|
|
118
|
+
assertFiniteSamples(input.name, "head", [input.headAggregate]);
|
|
119
|
+
base = aggregateSide(input.baseAggregate);
|
|
120
|
+
head = aggregateSide(input.headAggregate);
|
|
121
|
+
method = "threshold";
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const baseSamples = input.base ?? [];
|
|
125
|
+
const headSamples = input.head ?? [];
|
|
126
|
+
assertFiniteSamples(input.name, "base", baseSamples);
|
|
127
|
+
assertFiniteSamples(input.name, "head", headSamples);
|
|
128
|
+
base = summarize(baseSamples);
|
|
129
|
+
head = summarize(headSamples);
|
|
130
|
+
method = "mann-whitney";
|
|
131
|
+
}
|
|
132
|
+
const baseMedian = base.median;
|
|
133
|
+
const headMedian = head.median;
|
|
134
|
+
const changePercent = baseMedian === null || headMedian === null || baseMedian === 0
|
|
135
|
+
? null
|
|
136
|
+
: ((headMedian - baseMedian) / baseMedian) * 100;
|
|
137
|
+
const direction = directionOf(changePercent, lowerIsBetter);
|
|
138
|
+
const withinNoiseFloor = changePercent === null ? null : Math.abs(changePercent) <= floor;
|
|
139
|
+
const shell = {
|
|
140
|
+
name: input.name,
|
|
141
|
+
method,
|
|
142
|
+
base,
|
|
143
|
+
head,
|
|
144
|
+
changePercent,
|
|
145
|
+
direction,
|
|
146
|
+
withinNoiseFloor,
|
|
147
|
+
};
|
|
148
|
+
if (base.n === 0 || head.n === 0) {
|
|
149
|
+
return {
|
|
150
|
+
...shell,
|
|
151
|
+
test: null,
|
|
152
|
+
verdict: "cannot-tell",
|
|
153
|
+
why: `one side has no measurements (base n=${base.n}, head n=${head.n})`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (changePercent === null) {
|
|
157
|
+
return {
|
|
158
|
+
...shell,
|
|
159
|
+
test: null,
|
|
160
|
+
verdict: "cannot-tell",
|
|
161
|
+
why: baseMedian === 0
|
|
162
|
+
? "the base median is 0, so a relative change is undefined — compare absolute values, or measure something with a non-zero baseline"
|
|
163
|
+
: "there is no median on one side to compare against",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (withinNoiseFloor === true) {
|
|
167
|
+
return {
|
|
168
|
+
...shell,
|
|
169
|
+
test: null,
|
|
170
|
+
verdict: "no-detectable-change",
|
|
171
|
+
why: `${changePercent.toFixed(2)}% is inside the declared noise floor of ${floor}%, which no number of samples can see past`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (method === "threshold") {
|
|
175
|
+
return {
|
|
176
|
+
...shell,
|
|
177
|
+
test: null,
|
|
178
|
+
verdict: "not-tested",
|
|
179
|
+
why: `${changePercent.toFixed(2)}% clears the ${floor}% noise floor, but this framework reported one aggregate per side and a single number cannot support a significance test — re-run it with per-iteration samples to get a verdict`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const test = statsKernel.mannWhitneyU(input.base ?? [], input.head ?? [], {
|
|
183
|
+
continuityCorrection: options.continuityCorrection ?? true,
|
|
184
|
+
});
|
|
185
|
+
const summary = {
|
|
186
|
+
u: test.u,
|
|
187
|
+
z: test.z,
|
|
188
|
+
p: test.p,
|
|
189
|
+
tieGroups: test.tieGroups,
|
|
190
|
+
normalApproximationValid: test.normalApproximationValid,
|
|
191
|
+
note: test.note,
|
|
192
|
+
};
|
|
193
|
+
if (base.n < MIN_SAMPLES_FOR_SIGNIFICANCE || head.n < MIN_SAMPLES_FOR_SIGNIFICANCE) {
|
|
194
|
+
return {
|
|
195
|
+
...shell,
|
|
196
|
+
test: summary,
|
|
197
|
+
verdict: "cannot-tell",
|
|
198
|
+
why: `${changePercent.toFixed(2)}% clears the ${floor}% noise floor, but with base n=${base.n} and head n=${head.n} the normal approximation to U is not usable (it needs ${MIN_SAMPLES_FOR_SIGNIFICANCE} per side); p is reported for ranking candidates only. Re-run with more samples before calling this a regression`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (test.p === null) {
|
|
202
|
+
return {
|
|
203
|
+
...shell,
|
|
204
|
+
test: summary,
|
|
205
|
+
verdict: "cannot-tell",
|
|
206
|
+
why: `no p exists for this pair: ${test.note}`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (test.p >= alpha) {
|
|
210
|
+
return {
|
|
211
|
+
...shell,
|
|
212
|
+
test: summary,
|
|
213
|
+
verdict: "no-significant-difference",
|
|
214
|
+
why: `${changePercent.toFixed(2)}% clears the ${floor}% noise floor but p=${test.p.toFixed(4)} is not below alpha=${alpha} over ${base.n} vs ${head.n} samples`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
...shell,
|
|
219
|
+
test: summary,
|
|
220
|
+
verdict: direction === "slower" ? "regression" : "improvement",
|
|
221
|
+
why: `${changePercent.toFixed(2)}% (${direction}) clears the ${floor}% noise floor with p=${test.p.toFixed(4)} over ${base.n} vs ${head.n} samples`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const VERDICTS = [
|
|
225
|
+
"regression",
|
|
226
|
+
"improvement",
|
|
227
|
+
"no-detectable-change",
|
|
228
|
+
"no-significant-difference",
|
|
229
|
+
"cannot-tell",
|
|
230
|
+
"not-tested",
|
|
231
|
+
];
|
|
232
|
+
export function compareBenchmarks(inputs, options) {
|
|
233
|
+
if (!Number.isFinite(options.noiseFloorPercent) || options.noiseFloorPercent < 0) {
|
|
234
|
+
throw new BenchError(`noiseFloorPercent must be a non-negative percentage, got ${options.noiseFloorPercent}`);
|
|
235
|
+
}
|
|
236
|
+
const alpha = options.alpha ?? 0.05;
|
|
237
|
+
if (!(alpha > 0 && alpha < 1)) {
|
|
238
|
+
throw new BenchError(`alpha must be strictly between 0 and 1, got ${alpha}`);
|
|
239
|
+
}
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
for (const input of inputs) {
|
|
242
|
+
if (seen.has(input.name)) {
|
|
243
|
+
throw new BenchError(`benchmark "${input.name}" appears twice; two rows under one name cannot both be the result for it`);
|
|
244
|
+
}
|
|
245
|
+
seen.add(input.name);
|
|
246
|
+
}
|
|
247
|
+
const benchmarks = inputs.map((input) => compareBenchmark(input, options));
|
|
248
|
+
const summary = Object.fromEntries(VERDICTS.map((v) => [v, benchmarks.filter((b) => b.verdict === v).length]));
|
|
249
|
+
// A gate goes green only when nothing was left unjudged. "cannot tell" on
|
|
250
|
+
// half the suite with no regressions is not a clean run, and reporting it as
|
|
251
|
+
// one is how an unmeasurable suite stays unmeasured for a year.
|
|
252
|
+
const verdict = summary.regression > 0
|
|
253
|
+
? "regression"
|
|
254
|
+
: summary["cannot-tell"] + summary["not-tested"] > 0
|
|
255
|
+
? "inconclusive"
|
|
256
|
+
: "clean";
|
|
257
|
+
return {
|
|
258
|
+
noiseFloorPercent: options.noiseFloorPercent,
|
|
259
|
+
alpha,
|
|
260
|
+
lowerIsBetter: options.lowerIsBetter ?? true,
|
|
261
|
+
minSamplesForSignificance: MIN_SAMPLES_FOR_SIGNIFICANCE,
|
|
262
|
+
benchmarks,
|
|
263
|
+
summary,
|
|
264
|
+
verdict,
|
|
265
|
+
note: `${benchmarks.length} benchmark(s) compared by median with Mann-Whitney at alpha=${alpha}, under a declared noise floor of ${options.noiseFloorPercent}%`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export declare const TEST_STATUSES: readonly ["pass", "fail", "error", "skip"];
|
|
2
|
+
export type TestStatus = (typeof TEST_STATUSES)[number];
|
|
3
|
+
export declare class FlakyError extends Error {
|
|
4
|
+
readonly name = "FlakyError";
|
|
5
|
+
}
|
|
6
|
+
export type TestObservation = {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly status: TestStatus;
|
|
9
|
+
/** Failure text, used only to group failures — never to classify one. */
|
|
10
|
+
readonly error?: string;
|
|
11
|
+
/** A pre-computed failure signature, e.g. from `ErrorCluster`. Wins over `error`. */
|
|
12
|
+
readonly signature?: string;
|
|
13
|
+
};
|
|
14
|
+
export type RunInput = {
|
|
15
|
+
readonly id?: string;
|
|
16
|
+
/** The shuffle seed, when the runner exposes one. */
|
|
17
|
+
readonly seed?: string;
|
|
18
|
+
/** Execution order is the order of this array. */
|
|
19
|
+
readonly tests: ReadonlyArray<TestObservation>;
|
|
20
|
+
};
|
|
21
|
+
export type Mask = {
|
|
22
|
+
readonly pattern: string;
|
|
23
|
+
readonly with?: string;
|
|
24
|
+
readonly flags?: string;
|
|
25
|
+
};
|
|
26
|
+
export type FlakyOptions = {
|
|
27
|
+
/** Two-sided z for the Wilson interval; the kernel's 95% value by default. */
|
|
28
|
+
readonly z?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Recommend quarantine only when the interval's lower bound clears this.
|
|
31
|
+
* Default 0.05: one failure in twenty, established rather than observed.
|
|
32
|
+
*/
|
|
33
|
+
readonly quarantineLowerBound?: number;
|
|
34
|
+
/** Caller-declared substitutions applied to failure text before grouping. */
|
|
35
|
+
readonly masks?: ReadonlyArray<Mask>;
|
|
36
|
+
};
|
|
37
|
+
export type FailureGroup = {
|
|
38
|
+
readonly signature: string;
|
|
39
|
+
readonly count: number;
|
|
40
|
+
/** One verbatim example, so the masking stays auditable. */
|
|
41
|
+
readonly example: string;
|
|
42
|
+
readonly runs: ReadonlyArray<string>;
|
|
43
|
+
};
|
|
44
|
+
export type OrderAnalysis = {
|
|
45
|
+
readonly decidable: boolean;
|
|
46
|
+
/** Tests that ran before this one in EVERY failing run and in NO passing run. */
|
|
47
|
+
readonly suspects: ReadonlyArray<string>;
|
|
48
|
+
readonly medianPositionWhenFailing: number | null;
|
|
49
|
+
readonly medianPositionWhenPassing: number | null;
|
|
50
|
+
readonly note: string;
|
|
51
|
+
};
|
|
52
|
+
export type TestClassification = "passing" | "flaky" | "failing-every-run" | "not-observed";
|
|
53
|
+
export type TestReport = {
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly classification: TestClassification;
|
|
56
|
+
readonly runsObserved: number;
|
|
57
|
+
readonly failures: number;
|
|
58
|
+
readonly passes: number;
|
|
59
|
+
readonly skipped: number;
|
|
60
|
+
readonly absent: number;
|
|
61
|
+
readonly failureRate: {
|
|
62
|
+
readonly pointEstimate: number | null;
|
|
63
|
+
readonly lower: number | null;
|
|
64
|
+
readonly upper: number | null;
|
|
65
|
+
readonly width: number | null;
|
|
66
|
+
readonly z: number;
|
|
67
|
+
};
|
|
68
|
+
readonly order: OrderAnalysis;
|
|
69
|
+
readonly failureGroups: ReadonlyArray<FailureGroup>;
|
|
70
|
+
readonly sameFailureEveryTime: boolean | null;
|
|
71
|
+
readonly recommendation: "keep" | "quarantine" | "fix" | "insufficient-evidence";
|
|
72
|
+
readonly why: string;
|
|
73
|
+
};
|
|
74
|
+
export type FlakyReport = {
|
|
75
|
+
readonly runs: number;
|
|
76
|
+
readonly ordering: {
|
|
77
|
+
readonly varied: boolean;
|
|
78
|
+
readonly seedsDeclared: ReadonlyArray<string>;
|
|
79
|
+
readonly note: string;
|
|
80
|
+
};
|
|
81
|
+
readonly tests: ReadonlyArray<TestReport>;
|
|
82
|
+
readonly summary: Readonly<Record<TestClassification, number>>;
|
|
83
|
+
readonly masksApplied: Readonly<Record<string, number>>;
|
|
84
|
+
/** Consecutive failures needed before the lower bound on the rate clears 0.9. */
|
|
85
|
+
readonly runsForDeterminismClaim: number | null;
|
|
86
|
+
readonly verdict: "clean" | "flaky" | "failing" | "inconclusive";
|
|
87
|
+
readonly note: string;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Apply the caller's substitutions and report how many times each fired.
|
|
91
|
+
*
|
|
92
|
+
* A mask that rewrites text invisibly can merge two genuinely different
|
|
93
|
+
* failures into one group, which reads as "it always fails the same way" when
|
|
94
|
+
* it does not — so the counts come back with the result, the same rule
|
|
95
|
+
* `GoldenCompare` follows for its normalizers.
|
|
96
|
+
*
|
|
97
|
+
* There are deliberately no built-in masks. `ErrorCluster` in
|
|
98
|
+
* `@crewhaus/tool-obs` already owns shape-based masking of error text (URLs,
|
|
99
|
+
* uuids, timestamps, paths, prefixed ids, hex blobs, numbers); a second set
|
|
100
|
+
* here would drift from it, and the two tools would then disagree about
|
|
101
|
+
* whether two failures are the same failure. Pass its fingerprint in as
|
|
102
|
+
* `signature`, or declare the substitutions this suite needs.
|
|
103
|
+
*/
|
|
104
|
+
export declare function applyMasks(text: string, masks: ReadonlyArray<Mask>, counts: Record<string, number>): string;
|
|
105
|
+
/**
|
|
106
|
+
* The smallest run count whose all-failing Wilson lower bound clears
|
|
107
|
+
* `target`. Answers "how many consecutive failures before I may call this
|
|
108
|
+
* deterministic?" — at 95% confidence against a 0.9 target the answer is in
|
|
109
|
+
* the dozens, which is why three-for-three is evidence and not proof.
|
|
110
|
+
*/
|
|
111
|
+
export declare function runsForLowerBound(target: number, z?: number): number | null;
|
|
112
|
+
export declare function detectFlaky(runs: ReadonlyArray<RunInput>, options?: FlakyOptions): FlakyReport;
|