@cosyte/bench 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,1043 @@
1
+ /**
2
+ * The benchmark constants, frozen.
3
+ *
4
+ * Every value here is fixed by the performance standard,
5
+ * `documentation/decisions/0005-the-performance-standard.md` in the `config` repo, which says for
6
+ * each one whether it was measured or chosen, and on what basis. The warmup rule is not here: it
7
+ * is `PERF_CONTRACT` in `@cosyte/test-utils/perf` (ADR 0001 section 2), which `measure.ts` imports
8
+ * and the build bundles, so the scaling gate and the benchmarks warm up by one rule kept in one
9
+ * place.
10
+ *
11
+ * They are not tuning knobs and cannot be overridden per package. A comparison's false alarm rate
12
+ * was measured with these values; change one and the measurement no longer describes the tool.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ /** The benchmark constants. See {@link BENCH_CONTRACT}. */
17
+ interface BenchContractShape {
18
+ /** Timed samples per case per process. Each sample is whole passes over the corpus. */
19
+ readonly SAMPLES: number;
20
+ /** Minimum timed length of one sample, in milliseconds. A sample runs whole passes until it is reached. */
21
+ readonly SAMPLE_MIN_MS: number;
22
+ /** Rounds in one base against head comparison. Each round runs both sides once, in fresh processes. */
23
+ readonly ROUNDS: number;
24
+ /** One-sided error allowed at each end of the comparison's interval on the median ratio. */
25
+ readonly ALPHA: number;
26
+ /** The smallest slowdown a comparison will call a regression, as a fraction: 0.03 is 3%. */
27
+ readonly THRESHOLD: number;
28
+ /** Time budget for the latency calls of one case, in milliseconds. */
29
+ readonly LATENCY_BUDGET_MS: number;
30
+ /** Most latency calls taken for one case. */
31
+ readonly LATENCY_MAX_CALLS: number;
32
+ /** Fewest latency calls taken for one case, whatever the budget says. */
33
+ readonly LATENCY_MIN_CALLS: number;
34
+ /** A percentile q is reported only when at least this many calls lie above it: n(1 - q) >= 10. */
35
+ readonly LATENCY_TAIL_CALLS: number;
36
+ /** The median call must be at least this many timer reads long, or latency is skipped. */
37
+ readonly LATENCY_TIMER_MULTIPLE: number;
38
+ /** Most operations in the first allocation window. Halved while a garbage collection lands in a window. */
39
+ readonly ALLOC_WINDOW_OPS: number;
40
+ /** The first allocation window is cut to about this many milliseconds of operations, from the warmup's pace. */
41
+ readonly ALLOC_WINDOW_MS: number;
42
+ /** The young generation size the allocation process runs with, in MiB, so a window rarely collects. */
43
+ readonly ALLOC_SEMI_SPACE_MIB: number;
44
+ /** Calls measured one by one for the peak heap. */
45
+ readonly PEAK_CALLS: number;
46
+ /** Rounds in the long run that checks the heap stays flat. */
47
+ readonly LONG_RUN_ROUNDS: number;
48
+ /** Rounds at the start of the long run left out of the slope, while caches and compiled code settle. */
49
+ readonly LONG_RUN_SETTLE_ROUNDS: number;
50
+ /** Minimum length of one long run round, in milliseconds, fixed as a number of operations by the first. */
51
+ readonly LONG_RUN_ROUND_MS: number;
52
+ /** A long run whose fitted growth over the measured rounds reaches this many bytes is reported as growing. */
53
+ readonly LONG_RUN_GROWTH_BYTES: number;
54
+ /** A garbage collection round that reclaims less than this, in bytes, counts as settled (ADR 0001 section 8). */
55
+ readonly GC_SETTLE_BYTES: number;
56
+ /** Consecutive settled rounds needed (ADR 0001 section 8). */
57
+ readonly GC_SETTLE_ROUNDS: number;
58
+ /** Most garbage collection rounds before giving up (ADR 0001 section 8). */
59
+ readonly GC_MAX_ROUNDS: number;
60
+ /** Fresh processes timed for the cold import, for the target and for an empty module each. */
61
+ readonly IMPORT_RUNS: number;
62
+ /** The timing comparison may become a required check when A/A false alarms are under this rate. */
63
+ readonly REQUIRED_FALSE_ALARM_RATE: number;
64
+ /** The confidence that the false alarm rate is under the line, for the rule above. */
65
+ readonly REQUIRED_CONFIDENCE: number;
66
+ }
67
+ /**
68
+ * The frozen benchmark constants.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * import { BENCH_CONTRACT } from "@cosyte/bench";
73
+ *
74
+ * BENCH_CONTRACT.ROUNDS; // => 12
75
+ * BENCH_CONTRACT.THRESHOLD; // => 0.03
76
+ * ```
77
+ */
78
+ declare const BENCH_CONTRACT: BenchContractShape;
79
+
80
+ /**
81
+ * How a package describes its benchmarks.
82
+ *
83
+ * A suite is a module whose default export is {@link defineSuite}'s result. It is handed the module
84
+ * under test (`bind`) rather than importing it, so one suite file measures the base build and the
85
+ * head build of a comparison. Every corpus is built in code (`build`): the runner never takes a data
86
+ * file, which is how its inputs stay synthetic by construction.
87
+ *
88
+ * @packageDocumentation
89
+ */
90
+ /** One corpus: the inputs one pass runs over, built outside every timed region. */
91
+ interface BenchCorpus<TInput> {
92
+ /** The inputs. One pass calls the measured function once on each. */
93
+ readonly inputs: readonly TInput[];
94
+ /** Bytes one pass handles, when it means something. Turns operations a second into bytes a second. */
95
+ readonly bytes?: number;
96
+ /** Called once the process has finished with this corpus, for example to remove a temporary directory. */
97
+ readonly teardown?: () => void;
98
+ }
99
+ /** One input size of a benchmark: small, large, or anything between. */
100
+ interface BenchSize<TInput> {
101
+ /** How a reader names this size, for example `"1 KiB"`. Unique within its benchmark. */
102
+ readonly label: string;
103
+ /** Build the corpus. Called once per process, before anything is timed. */
104
+ readonly build: () => BenchCorpus<TInput>;
105
+ /**
106
+ * Whether the base against head comparison runs this size. Defaults to `true`. Set `false` for a
107
+ * size whose processes are too slow for a pull request's time budget; a reading still measures it.
108
+ */
109
+ readonly compare?: boolean;
110
+ }
111
+ /** One benchmark: a function measured at one or more input sizes. */
112
+ interface Benchmark<TTarget, TInput, TResult> {
113
+ /** Unique within the suite. */
114
+ readonly name: string;
115
+ /** The sizes, smallest first. Each size is measured on its own. */
116
+ readonly sizes: readonly BenchSize<TInput>[];
117
+ /** Take the imported module under test and return the function to measure. */
118
+ readonly bind: (target: TTarget) => (input: TInput) => TResult;
119
+ /**
120
+ * Reduce a result to a number summed into a sink the runner checks, so the compiler cannot delete
121
+ * the measured call. Defaults to 1 for each result that is not `null` or `undefined`; supply it
122
+ * when the function returns nothing. The sum must grow over a measurement, so return a positive
123
+ * number for at least some results; a run whose sink did not grow is refused.
124
+ */
125
+ readonly weigh?: (result: TResult) => number;
126
+ /** The size label the long run (is the heap flat?) uses. Defaults to the first size. */
127
+ readonly longRun?: string;
128
+ }
129
+ /** A benchmark with its input and result types erased, as a suite holds it. */
130
+ type AnyBenchmark<TTarget> = Benchmark<TTarget, unknown, unknown>;
131
+ /** A suite: the benchmarks of one package. */
132
+ interface BenchSuite<TTarget> {
133
+ /** Shown in every report, for example `"@cosyte/script-utils phi-scan"`. */
134
+ readonly name: string;
135
+ /** At least one. */
136
+ readonly benchmarks: readonly AnyBenchmark<TTarget>[];
137
+ }
138
+ /**
139
+ * Type one benchmark. Only a type helper: it returns its argument, with the input and result types
140
+ * erased so benchmarks of different shapes can sit in one suite.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * import { defineBenchmark } from "@cosyte/bench";
145
+ *
146
+ * const b = defineBenchmark({
147
+ * name: "split",
148
+ * sizes: [{ label: "1 KiB", build: () => ({ inputs: ["a|b".repeat(341)] }) }],
149
+ * bind: () => (s: string) => s.split("|"),
150
+ * });
151
+ * b.name; // => "split"
152
+ * ```
153
+ */
154
+ declare function defineBenchmark<TTarget, TInput, TResult>(benchmark: Benchmark<TTarget, TInput, TResult>): AnyBenchmark<TTarget>;
155
+ /**
156
+ * Check a suite and freeze it. Throws a `TypeError` naming the problem: an empty name, no
157
+ * benchmarks, a repeated name or label, a benchmark with no sizes, or a `longRun` naming no size.
158
+ *
159
+ * @example
160
+ * ```ts
161
+ * import { defineBenchmark, defineSuite } from "@cosyte/bench";
162
+ *
163
+ * export default defineSuite({
164
+ * name: "example",
165
+ * benchmarks: [
166
+ * defineBenchmark({
167
+ * name: "split",
168
+ * sizes: [{ label: "1 KiB", build: () => ({ inputs: ["a|b".repeat(341)] }) }],
169
+ * bind: () => (s: string) => s.split("|"),
170
+ * }),
171
+ * ],
172
+ * });
173
+ * ```
174
+ */
175
+ declare function defineSuite<TTarget>(suite: BenchSuite<TTarget>): BenchSuite<TTarget>;
176
+ /** One case: a benchmark at one size. */
177
+ interface CaseRef {
178
+ readonly benchmark: string;
179
+ readonly size: string;
180
+ }
181
+
182
+ /**
183
+ * The timing primitives: the warmup, the throughput samples and the latency calls. They run inside
184
+ * a measuring process, one case per process, and take their clocks as arguments so their tests can
185
+ * drive them with exact arithmetic instead of a machine's noise.
186
+ *
187
+ * @packageDocumentation
188
+ */
189
+ /**
190
+ * The warmup rule, read from `PERF_CONTRACT` in `@cosyte/test-utils/perf` (ADR 0001 section 2) and
191
+ * renamed, so the scaling gate and the benchmarks warm up by one rule kept in one place.
192
+ *
193
+ * @internal
194
+ */
195
+ declare const WARMUP: Readonly<{
196
+ MIN_MS: number;
197
+ BATCH_MS: number;
198
+ MAX_MS: number;
199
+ STABLE_TOL: number;
200
+ STABLE_BATCHES: number;
201
+ }>;
202
+ /** What the warmup did. */
203
+ interface WarmupReport {
204
+ /** Whether three batches in a row came within 5% of their median before 5 s ran out. */
205
+ readonly stable: boolean;
206
+ /** Wall time spent warming, in milliseconds. */
207
+ readonly elapsedMs: number;
208
+ /** Passes over the corpus. */
209
+ readonly passes: number;
210
+ /** Each batch's milliseconds per pass. */
211
+ readonly batches: readonly number[];
212
+ }
213
+ /** Per-call latency, or why there is none. */
214
+ type LatencyReport = {
215
+ readonly status: "measured";
216
+ readonly calls: number;
217
+ readonly timerOverheadNs: number;
218
+ /** `null` when fewer than `LATENCY_TAIL_CALLS` calls lie above the percentile. */
219
+ readonly p50Ns: number | null;
220
+ readonly p95Ns: number | null;
221
+ readonly p99Ns: number | null;
222
+ readonly maxNs: number;
223
+ } | {
224
+ readonly status: "skipped";
225
+ readonly reason: "latency-below-timer-resolution";
226
+ readonly calls: number;
227
+ readonly timerOverheadNs: number;
228
+ readonly p50Ns: number;
229
+ };
230
+
231
+ /**
232
+ * The memory measurements: allocations per operation, peak heap per call, and whether the heap stays
233
+ * flat over a long run. Each runs in a measuring process started with `--expose-gc`, one case per
234
+ * process.
235
+ *
236
+ * They read the heap through a {@link HeapProbe}, so their tests can drive them with a scripted heap.
237
+ * The real probe follows ADR 0001 section 8: a zero-argument `gc()` only, settled by rule rather than
238
+ * by a count.
239
+ *
240
+ * @packageDocumentation
241
+ */
242
+
243
+ /** Why a memory measurement has no number. */
244
+ type MemorySkipReason = "gc-unavailable" | "gc-unsettled" | "allocation-window-gc";
245
+ /** Allocations per operation. */
246
+ type AllocationReport = {
247
+ readonly status: "measured";
248
+ /** The smaller of the two windows, in operations. */
249
+ readonly windowOps: number;
250
+ /** Bytes allocated on the V8 heap per operation. */
251
+ readonly heapBytesPerOp: number;
252
+ /** Bytes allocated outside it (Buffers, ArrayBuffers) per operation. */
253
+ readonly externalBytesPerOp: number;
254
+ } | {
255
+ readonly status: "skipped";
256
+ readonly reason: MemorySkipReason;
257
+ };
258
+ /** Peak heap per call. */
259
+ type PeakReport = {
260
+ readonly status: "measured";
261
+ readonly calls: number;
262
+ /** The highest peak of any call, in bytes above the settled heap before it. */
263
+ readonly peakHeapBytes: number;
264
+ /** The median call's peak. */
265
+ readonly medianPeakHeapBytes: number;
266
+ } | {
267
+ readonly status: "skipped";
268
+ readonly reason: MemorySkipReason;
269
+ };
270
+ /** Whether the heap stayed flat over a long run. */
271
+ type LongRunReport = {
272
+ readonly status: "measured";
273
+ /** `growing` when the fitted growth reaches `LONG_RUN_GROWTH_BYTES`. */
274
+ readonly verdict: "flat" | "growing";
275
+ /** Operations in all. */
276
+ readonly ops: number;
277
+ /** Operations per round, fixed by the first round. */
278
+ readonly opsPerRound: number;
279
+ /** The settled heap after each round, in bytes. */
280
+ readonly settledHeap: readonly number[];
281
+ /** Theil-Sen slope of the settled heap against operations, after the settling rounds. */
282
+ readonly slopeBytesPerOp: number;
283
+ /** The slope times the operations it was fitted over: the growth the verdict reads. */
284
+ readonly growthBytes: number;
285
+ /** The smallest leak, in bytes per operation, this run could call growing: the bound over the span. */
286
+ readonly detectableBytesPerOp: number;
287
+ } | {
288
+ readonly status: "skipped";
289
+ readonly reason: MemorySkipReason;
290
+ };
291
+
292
+ /** An interval on a median, from the sign test. */
293
+ interface MedianInterval {
294
+ /** The `rank`-th smallest value. */
295
+ readonly low: number;
296
+ /** The `rank`-th largest value. */
297
+ readonly high: number;
298
+ /** Which order statistics bound it. */
299
+ readonly rank: number;
300
+ /** The chance that each end alone misses the median, exactly: `P(Binomial(n, 1/2) <= rank - 1)`. */
301
+ readonly tail: number;
302
+ }
303
+
304
+ /**
305
+ * The base against head comparison, and its calibration.
306
+ *
307
+ * A comparison is `ROUNDS` rounds. In each round every case runs once on the base build and once on
308
+ * the head build, each in a fresh process, back to back; even rounds run the base first, odd rounds
309
+ * the head. A drift in the machine over the run lands on both sides of a round, seconds apart, and
310
+ * cancels in their ratio; an order effect lands on the head in half the rounds and on the base in
311
+ * the other half, so it widens the spread of the ratios instead of biasing their median.
312
+ *
313
+ * Each round gives one ratio per case, `head / base` of the two processes' fastest samples. The
314
+ * fastest, not the median, because the noise on a shared machine is one-sided (another tenant only
315
+ * ever slows a sample down), and ADR 0001 section 1 measured `min` as the one estimator that keeps a
316
+ * stall on one side out of a ratio. The verdict reads the sign test's interval on the median of the
317
+ * per-round ratios, which assumes nothing about the shape of the noise, only that rounds are
318
+ * independent.
319
+ *
320
+ * @packageDocumentation
321
+ */
322
+
323
+ /** What a comparison says about one case. */
324
+ type Verdict = "regression" | "improvement" | "no-regression" | "inconclusive" | "skipped";
325
+ /**
326
+ * Judge one case from its per-round ratios.
327
+ *
328
+ * - `regression`: the whole interval is above `1 + THRESHOLD`. The head is slower by more than the
329
+ * threshold, with each end of the interval wrong at most `ALPHA` of the time.
330
+ * - `improvement`: the whole interval is below `1 / (1 + THRESHOLD)`.
331
+ * - `no-regression`: the whole interval is at or below `1 + THRESHOLD`: a slowdown larger than
332
+ * the threshold is ruled out.
333
+ * - `inconclusive`: the interval straddles `1 + THRESHOLD`, or there are too few rounds for one.
334
+ * Not a pass and not a failure: the rounds disagreed too much to tell.
335
+ *
336
+ * @example
337
+ * ```ts
338
+ * import { judge } from "@cosyte/bench";
339
+ *
340
+ * judge([1.1, 1.12, 1.09, 1.11, 1.1, 1.13, 1.1, 1.08, 1.12, 1.1, 1.11, 1.09]).verdict; // => "regression"
341
+ * judge([1, 1.01, 0.99, 1, 1.02, 0.98, 1, 1.01, 0.99, 1, 1, 1.01]).verdict; // => "no-regression"
342
+ * ```
343
+ */
344
+ declare function judge(ratios: readonly number[], threshold?: number, alpha?: number): {
345
+ verdict: Exclude<Verdict, "skipped">;
346
+ interval: MedianInterval | null;
347
+ medianRatio: number;
348
+ };
349
+ /** One case of a comparison. */
350
+ interface CaseComparison extends CaseRef {
351
+ /** Each round's base process: the fastest of its samples, milliseconds per operation. */
352
+ readonly baseMsPerOp: readonly number[];
353
+ /** Each round's head process: the fastest of its samples, milliseconds per operation. */
354
+ readonly headMsPerOp: readonly number[];
355
+ /** Each round's base samples, whole, so another estimator can be judged on the same runs. */
356
+ readonly baseSamples: readonly (readonly number[])[];
357
+ /** Each round's head samples, whole. */
358
+ readonly headSamples: readonly (readonly number[])[];
359
+ /** Each round's `head / base`. */
360
+ readonly ratios: readonly number[];
361
+ readonly medianRatio: number | null;
362
+ readonly interval: MedianInterval | null;
363
+ readonly verdict: Verdict;
364
+ /** Why the case was skipped, when it was. A skip is not a pass. */
365
+ readonly skipReason: string | null;
366
+ }
367
+ /** A whole comparison. */
368
+ interface ComparisonBody {
369
+ readonly rounds: number;
370
+ readonly threshold: number;
371
+ readonly alpha: number;
372
+ /** The slowdown injected on the head side, for calibration. Zero in a real comparison. */
373
+ readonly inject: number;
374
+ readonly cases: readonly CaseComparison[];
375
+ }
376
+ /** A rate with its exact upper bound. */
377
+ interface CountedRate {
378
+ readonly events: number;
379
+ readonly trials: number;
380
+ readonly rate: number;
381
+ /** The one-sided upper bound at `REQUIRED_CONFIDENCE` (Clopper-Pearson). */
382
+ readonly upperBound: number;
383
+ }
384
+ /** The ratio one case was observed at over a whole calibration. */
385
+ interface ObservedRatio extends CaseRef {
386
+ /** Measured rounds, pooled over every comparison that measured the case. */
387
+ readonly rounds: number;
388
+ /**
389
+ * The median of those rounds' `head / base` ratios, or `null` when no round measured. Under an
390
+ * injected slowdown this is the slowdown the head side was actually measured at, which is not
391
+ * `1 + inject` (see `sampleThroughput`): a detection rate is read against it.
392
+ */
393
+ readonly medianRatio: number | null;
394
+ }
395
+ /** A calibration: many comparisons of known outcome. */
396
+ interface CalibrationBody {
397
+ /** The slowdown injected on the head side. Zero is an A/A calibration: every regression is a false alarm. */
398
+ readonly inject: number;
399
+ readonly comparisons: number;
400
+ /** Each comparison's cases, kept whole so a different rule can be judged against the same runs. */
401
+ readonly runs: readonly (readonly CaseComparison[])[];
402
+ /** Case verdicts of `regression`, out of the case verdicts that measured. A skipped case is not a trial. */
403
+ readonly perCase: CountedRate;
404
+ /**
405
+ * Comparisons with at least one `regression`, out of the comparisons whose outcome is known: every
406
+ * case measured, or a regression was called. A comparison that skipped a case and called no
407
+ * regression is neither a pass nor a trial.
408
+ */
409
+ readonly perComparison: CountedRate;
410
+ /** Comparisons with at least one skipped case, out of all comparisons. A required check is red on each (exit 2). */
411
+ readonly skippedComparisons: CountedRate;
412
+ /** Case verdicts by kind. */
413
+ readonly verdicts: Readonly<Record<Verdict, number>>;
414
+ /** Each case's observed ratio, in the order the cases first appear. */
415
+ readonly observed: readonly ObservedRatio[];
416
+ /** For an A/A calibration: whether the timing comparison may now be a required check, and why. */
417
+ readonly requiredRule: {
418
+ readonly applies: boolean;
419
+ readonly met: boolean;
420
+ readonly reason: string;
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Cold import time and the bytes an import loads.
426
+ *
427
+ * Each timed import runs in a fresh process with no compile cache, so nothing is compiled before it
428
+ * starts. The same number of fresh processes import an empty module, interleaved with the target's,
429
+ * so a reader can see how much of the time is the import machinery rather than the package.
430
+ *
431
+ * The bytes are the files Node actually loaded for the import, found by a module hook in one further
432
+ * process that is not timed (the hook itself costs time). This is what a Node consumer loads; it is
433
+ * not what a bundler would keep after tree shaking, which depends on what the consumer imports.
434
+ *
435
+ * @packageDocumentation
436
+ */
437
+
438
+ /** One file an import loaded. */
439
+ interface LoadedFile {
440
+ /** Relative to the working directory, so no report carries a home directory. */
441
+ readonly path: string;
442
+ readonly bytes: number;
443
+ /** Gzip at level 9: roughly what it costs to download. */
444
+ readonly gzipBytes: number;
445
+ }
446
+ /** Cold import time and loaded bytes. */
447
+ interface ImportCostReport {
448
+ readonly runs: number;
449
+ /** One per fresh process, milliseconds, in the order taken. */
450
+ readonly coldImportMs: readonly number[];
451
+ readonly medianMs: number;
452
+ readonly p95Ms: number;
453
+ /** The same for an empty module: the import machinery alone. */
454
+ readonly emptyImportMs: readonly number[];
455
+ readonly emptyMedianMs: number;
456
+ readonly files: readonly LoadedFile[];
457
+ readonly totalBytes: number;
458
+ readonly totalGzipBytes: number;
459
+ }
460
+
461
+ /**
462
+ * A reading: how fast, how much memory and how heavy one build is, on this machine.
463
+ *
464
+ * A reading is not a guarantee and travels with its machine: it is what the results repository keeps
465
+ * over time, compared only within one runner class.
466
+ *
467
+ * @packageDocumentation
468
+ */
469
+
470
+ /** Throughput of one case. */
471
+ type Throughput = {
472
+ readonly status: "measured";
473
+ /** One entry per process: its samples and their median, milliseconds per operation. */
474
+ readonly processes: readonly {
475
+ readonly samplesMsPerOp: readonly number[];
476
+ readonly medianMsPerOp: number;
477
+ }[];
478
+ /** The median of the process medians. */
479
+ readonly medianMsPerOp: number;
480
+ readonly opsPerSecond: number;
481
+ /** `null` when the suite gave no byte count for the corpus. */
482
+ readonly bytesPerSecond: number | null;
483
+ } | {
484
+ readonly status: "skipped";
485
+ readonly reason: string;
486
+ };
487
+ /** Everything a reading measured for one case. */
488
+ interface CaseReading extends CaseRef {
489
+ readonly inputsPerPass: number;
490
+ readonly bytesPerPass: number | null;
491
+ readonly throughput: Throughput;
492
+ readonly latency: LatencyReport | null;
493
+ readonly allocations: AllocationReport | null;
494
+ readonly peak: PeakReport | null;
495
+ readonly longRun: LongRunReport | null;
496
+ }
497
+ /** A whole reading. */
498
+ interface ReadingBody {
499
+ /** Fresh processes timed per case. */
500
+ readonly processes: number;
501
+ readonly cases: readonly CaseReading[];
502
+ /** `null` when import cost was not asked for. */
503
+ readonly importCost: ImportCostReport | null;
504
+ }
505
+
506
+ /**
507
+ * The machine a measurement was taken on. Every results document carries it, because a number
508
+ * means nothing apart from its machine: ADR 0001 measured a significant shift from a runner image
509
+ * update with no code change.
510
+ *
511
+ * Nothing here names a person: no host name, no user, no absolute path.
512
+ *
513
+ * @packageDocumentation
514
+ */
515
+ /** Where a measurement ran. */
516
+ interface Environment {
517
+ readonly node: string;
518
+ readonly v8: string;
519
+ readonly platform: string;
520
+ readonly arch: string;
521
+ readonly osRelease: string;
522
+ readonly cpuModel: string | null;
523
+ readonly logicalCpus: number;
524
+ readonly totalMemoryBytes: number;
525
+ /** The cgroup CPU quota (`cpu.max`), when Linux reports one: a throttled container measures differently. */
526
+ readonly cgroupCpuMax: string | null;
527
+ /** Whether `NODE_OPTIONS` was set. Its value is not recorded; it can hold paths. */
528
+ readonly nodeOptionsSet: boolean;
529
+ /**
530
+ * The 1, 5 and 15 minute load averages when the document was written (zeros where the platform
531
+ * has none). A busy machine is a noisy one: this is how a reader tells.
532
+ */
533
+ readonly loadAverage: readonly number[];
534
+ readonly runner: Runner;
535
+ }
536
+ /** The CI runner, when there is one. */
537
+ interface Runner {
538
+ readonly ci: boolean;
539
+ /** `github-actions`, or `null` off CI. */
540
+ readonly provider: string | null;
541
+ /** `github-hosted` or `self-hosted` (`RUNNER_ENVIRONMENT`). */
542
+ readonly environment: string | null;
543
+ /** The runner image (`ImageOS`) and its version (`ImageVersion`). */
544
+ readonly imageOS: string | null;
545
+ readonly imageVersion: string | null;
546
+ /**
547
+ * A key for "the same kind of machine": the provider, image and architecture on CI, or
548
+ * `local-<platform>-<arch>` off it. Results are only compared within one class.
549
+ */
550
+ readonly class: string;
551
+ }
552
+
553
+ /**
554
+ * The results document: one JSON file per reading, comparison or calibration, in a versioned shape a
555
+ * results repository can keep for years and read back.
556
+ *
557
+ * The shape is fixed by a JSON Schema ({@link RESULTS_SCHEMA}, also shipped as
558
+ * `schema/results-v1.schema.json`). Version 1 only grows: a new field may appear, no field changes
559
+ * meaning or goes away, and readers ignore fields they do not know. Anything else is version 2.
560
+ *
561
+ * @packageDocumentation
562
+ */
563
+
564
+ /** The version this package writes and reads. */
565
+ declare const SCHEMA_VERSION = 1;
566
+ /** Which build a document describes. */
567
+ interface BuildRef {
568
+ /** The entry measured, relative to the working directory. */
569
+ readonly target: string;
570
+ /** The commit it was built from, when known. */
571
+ readonly commit: string | null;
572
+ }
573
+ interface DocumentBase {
574
+ readonly schemaVersion: 1;
575
+ readonly tool: {
576
+ readonly name: "@cosyte/bench";
577
+ readonly version: string;
578
+ };
579
+ /** When the document was written, ISO 8601 UTC. */
580
+ readonly createdAt: string;
581
+ readonly suite: {
582
+ readonly name: string;
583
+ readonly file: string;
584
+ };
585
+ readonly environment: Environment;
586
+ /** The constants in force, so a document can be judged against the method that made it. */
587
+ readonly contract: {
588
+ readonly bench: BenchContractShape;
589
+ readonly warmup: typeof WARMUP;
590
+ };
591
+ }
592
+ /** A results document. */
593
+ type ResultsDocument = DocumentBase & ({
594
+ readonly kind: "reading";
595
+ readonly subject: BuildRef;
596
+ readonly reading: ReadingBody;
597
+ } | {
598
+ readonly kind: "comparison";
599
+ readonly base: BuildRef;
600
+ readonly head: BuildRef;
601
+ readonly comparison: ComparisonBody;
602
+ } | {
603
+ readonly kind: "calibration";
604
+ readonly base: BuildRef;
605
+ readonly head: BuildRef;
606
+ readonly calibration: CalibrationBody;
607
+ });
608
+ /** The JSON Schema keywords the results schema uses. */
609
+ interface Schema {
610
+ readonly $ref?: string;
611
+ readonly $defs?: Readonly<Record<string, Schema>>;
612
+ readonly const?: unknown;
613
+ readonly enum?: readonly unknown[];
614
+ readonly type?: string | readonly string[];
615
+ readonly minimum?: number;
616
+ readonly required?: readonly string[];
617
+ readonly properties?: Readonly<Record<string, Schema>>;
618
+ readonly items?: Schema;
619
+ readonly oneOf?: readonly Schema[];
620
+ }
621
+ /**
622
+ * The results schema, version 1, as JSON Schema (draft 2020-12). It holds what every reader relies
623
+ * on; a document may carry more.
624
+ *
625
+ * @example
626
+ * ```ts
627
+ * import { RESULTS_SCHEMA } from "@cosyte/bench";
628
+ *
629
+ * RESULTS_SCHEMA.$id; // => "https://github.com/cosyte/config/blob/main/packages/bench/schema/results-v1.schema.json"
630
+ * ```
631
+ */
632
+ declare const RESULTS_SCHEMA: {
633
+ readonly $schema: "https://json-schema.org/draft/2020-12/schema";
634
+ readonly $id: "https://github.com/cosyte/config/blob/main/packages/bench/schema/results-v1.schema.json";
635
+ readonly title: "@cosyte/bench results, version 1";
636
+ readonly type: "object";
637
+ readonly required: readonly ["schemaVersion", "kind", "tool", "createdAt", "suite", "environment", "contract"];
638
+ readonly properties: {
639
+ readonly schemaVersion: {
640
+ readonly const: 1;
641
+ };
642
+ readonly kind: {
643
+ readonly enum: readonly ["reading", "comparison", "calibration"];
644
+ };
645
+ readonly tool: {
646
+ readonly type: "object";
647
+ readonly required: readonly ["name", "version"];
648
+ readonly properties: {
649
+ readonly name: {
650
+ readonly const: "@cosyte/bench";
651
+ };
652
+ readonly version: {
653
+ readonly type: "string";
654
+ };
655
+ };
656
+ };
657
+ readonly createdAt: {
658
+ readonly type: "string";
659
+ };
660
+ readonly suite: {
661
+ readonly type: "object";
662
+ readonly required: readonly ["name", "file"];
663
+ readonly properties: {
664
+ readonly name: {
665
+ readonly type: "string";
666
+ };
667
+ readonly file: {
668
+ readonly type: "string";
669
+ };
670
+ };
671
+ };
672
+ readonly environment: {
673
+ readonly $ref: "#/$defs/environment";
674
+ };
675
+ readonly contract: {
676
+ readonly type: "object";
677
+ readonly required: readonly ["bench", "warmup"];
678
+ readonly properties: {
679
+ readonly bench: {
680
+ readonly type: "object";
681
+ };
682
+ readonly warmup: {
683
+ readonly type: "object";
684
+ };
685
+ };
686
+ };
687
+ };
688
+ readonly oneOf: readonly [{
689
+ readonly required: readonly ["subject", "reading"];
690
+ readonly properties: {
691
+ readonly kind: {
692
+ readonly const: "reading";
693
+ };
694
+ readonly subject: {
695
+ readonly $ref: "#/$defs/build";
696
+ };
697
+ readonly reading: {
698
+ readonly $ref: "#/$defs/reading";
699
+ };
700
+ };
701
+ }, {
702
+ readonly required: readonly ["base", "head", "comparison"];
703
+ readonly properties: {
704
+ readonly kind: {
705
+ readonly const: "comparison";
706
+ };
707
+ readonly base: {
708
+ readonly $ref: "#/$defs/build";
709
+ };
710
+ readonly head: {
711
+ readonly $ref: "#/$defs/build";
712
+ };
713
+ readonly comparison: {
714
+ readonly $ref: "#/$defs/comparison";
715
+ };
716
+ };
717
+ }, {
718
+ readonly required: readonly ["base", "head", "calibration"];
719
+ readonly properties: {
720
+ readonly kind: {
721
+ readonly const: "calibration";
722
+ };
723
+ readonly base: {
724
+ readonly $ref: "#/$defs/build";
725
+ };
726
+ readonly head: {
727
+ readonly $ref: "#/$defs/build";
728
+ };
729
+ readonly calibration: {
730
+ readonly $ref: "#/$defs/calibration";
731
+ };
732
+ };
733
+ }];
734
+ readonly $defs: {
735
+ readonly build: {
736
+ readonly type: "object";
737
+ readonly required: readonly ["target", "commit"];
738
+ readonly properties: {
739
+ readonly target: {
740
+ readonly type: "string";
741
+ };
742
+ readonly commit: {
743
+ readonly type: readonly ["string", "null"];
744
+ };
745
+ };
746
+ };
747
+ readonly environment: {
748
+ readonly type: "object";
749
+ readonly required: readonly ["node", "v8", "platform", "arch", "osRelease", "cpuModel", "logicalCpus", "totalMemoryBytes", "cgroupCpuMax", "runner"];
750
+ readonly properties: {
751
+ readonly node: {
752
+ readonly type: "string";
753
+ };
754
+ readonly v8: {
755
+ readonly type: "string";
756
+ };
757
+ readonly platform: {
758
+ readonly type: "string";
759
+ };
760
+ readonly arch: {
761
+ readonly type: "string";
762
+ };
763
+ readonly osRelease: {
764
+ readonly type: "string";
765
+ };
766
+ readonly cpuModel: {
767
+ readonly type: readonly ["string", "null"];
768
+ };
769
+ readonly logicalCpus: {
770
+ readonly type: "integer";
771
+ readonly minimum: 0;
772
+ };
773
+ readonly totalMemoryBytes: {
774
+ readonly type: "integer";
775
+ readonly minimum: 0;
776
+ };
777
+ readonly cgroupCpuMax: {
778
+ readonly type: readonly ["string", "null"];
779
+ };
780
+ readonly loadAverage: {
781
+ readonly type: "array";
782
+ readonly items: {
783
+ readonly type: "number";
784
+ };
785
+ };
786
+ readonly runner: {
787
+ readonly type: "object";
788
+ readonly required: readonly ["ci", "provider", "class"];
789
+ readonly properties: {
790
+ readonly ci: {
791
+ readonly type: "boolean";
792
+ };
793
+ readonly provider: {
794
+ readonly type: readonly ["string", "null"];
795
+ };
796
+ readonly class: {
797
+ readonly type: "string";
798
+ };
799
+ };
800
+ };
801
+ };
802
+ };
803
+ readonly interval: Schema;
804
+ readonly caseComparison: {
805
+ readonly type: "object";
806
+ readonly required: readonly ["benchmark", "size", "baseMsPerOp", "headMsPerOp", "ratios", "medianRatio", "interval", "verdict", "skipReason"];
807
+ readonly properties: {
808
+ readonly benchmark: {
809
+ readonly type: "string";
810
+ };
811
+ readonly size: {
812
+ readonly type: "string";
813
+ };
814
+ readonly baseMsPerOp: {
815
+ readonly type: "array";
816
+ readonly items: {
817
+ readonly type: "number";
818
+ };
819
+ };
820
+ readonly headMsPerOp: {
821
+ readonly type: "array";
822
+ readonly items: {
823
+ readonly type: "number";
824
+ };
825
+ };
826
+ readonly ratios: {
827
+ readonly type: "array";
828
+ readonly items: {
829
+ readonly type: "number";
830
+ };
831
+ };
832
+ readonly medianRatio: {
833
+ readonly type: readonly ["number", "null"];
834
+ };
835
+ readonly interval: {
836
+ readonly $ref: "#/$defs/interval";
837
+ };
838
+ readonly verdict: {
839
+ readonly enum: readonly ["regression", "improvement", "no-regression", "inconclusive", "skipped"];
840
+ };
841
+ readonly skipReason: {
842
+ readonly type: readonly ["string", "null"];
843
+ };
844
+ };
845
+ };
846
+ readonly comparison: {
847
+ readonly type: "object";
848
+ readonly required: readonly ["rounds", "threshold", "alpha", "inject", "cases"];
849
+ readonly properties: {
850
+ readonly rounds: {
851
+ readonly type: "integer";
852
+ readonly minimum: 0;
853
+ };
854
+ readonly threshold: {
855
+ readonly type: "number";
856
+ };
857
+ readonly alpha: {
858
+ readonly type: "number";
859
+ };
860
+ readonly inject: {
861
+ readonly type: "number";
862
+ };
863
+ readonly cases: {
864
+ readonly type: "array";
865
+ readonly items: {
866
+ readonly $ref: "#/$defs/caseComparison";
867
+ };
868
+ };
869
+ };
870
+ };
871
+ readonly rate: {
872
+ readonly type: "object";
873
+ readonly required: readonly ["events", "trials", "rate", "upperBound"];
874
+ readonly properties: {
875
+ readonly events: {
876
+ readonly type: "integer";
877
+ readonly minimum: 0;
878
+ };
879
+ readonly trials: {
880
+ readonly type: "integer";
881
+ readonly minimum: 0;
882
+ };
883
+ readonly rate: {
884
+ readonly type: "number";
885
+ };
886
+ readonly upperBound: {
887
+ readonly type: "number";
888
+ };
889
+ };
890
+ };
891
+ readonly calibration: {
892
+ readonly type: "object";
893
+ readonly required: readonly ["inject", "comparisons", "runs", "perCase", "perComparison", "verdicts", "requiredRule"];
894
+ readonly properties: {
895
+ readonly inject: {
896
+ readonly type: "number";
897
+ };
898
+ readonly comparisons: {
899
+ readonly type: "integer";
900
+ readonly minimum: 0;
901
+ };
902
+ readonly runs: {
903
+ readonly type: "array";
904
+ readonly items: {
905
+ readonly type: "array";
906
+ readonly items: {
907
+ readonly $ref: "#/$defs/caseComparison";
908
+ };
909
+ };
910
+ };
911
+ readonly perCase: {
912
+ readonly $ref: "#/$defs/rate";
913
+ };
914
+ readonly perComparison: {
915
+ readonly $ref: "#/$defs/rate";
916
+ };
917
+ readonly skippedComparisons: {
918
+ readonly $ref: "#/$defs/rate";
919
+ };
920
+ readonly verdicts: {
921
+ readonly type: "object";
922
+ };
923
+ readonly observed: {
924
+ readonly type: "array";
925
+ readonly items: {
926
+ readonly type: "object";
927
+ readonly required: readonly ["benchmark", "size", "rounds", "medianRatio"];
928
+ readonly properties: {
929
+ readonly benchmark: {
930
+ readonly type: "string";
931
+ };
932
+ readonly size: {
933
+ readonly type: "string";
934
+ };
935
+ readonly rounds: {
936
+ readonly type: "integer";
937
+ readonly minimum: 0;
938
+ };
939
+ readonly medianRatio: {
940
+ readonly type: readonly ["number", "null"];
941
+ };
942
+ };
943
+ };
944
+ };
945
+ readonly requiredRule: {
946
+ readonly type: "object";
947
+ readonly required: readonly ["applies", "met", "reason"];
948
+ readonly properties: {
949
+ readonly applies: {
950
+ readonly type: "boolean";
951
+ };
952
+ readonly met: {
953
+ readonly type: "boolean";
954
+ };
955
+ readonly reason: {
956
+ readonly type: "string";
957
+ };
958
+ };
959
+ };
960
+ };
961
+ };
962
+ readonly reading: {
963
+ readonly type: "object";
964
+ readonly required: readonly ["processes", "cases", "importCost"];
965
+ readonly properties: {
966
+ readonly processes: {
967
+ readonly type: "integer";
968
+ readonly minimum: 0;
969
+ };
970
+ readonly cases: {
971
+ readonly type: "array";
972
+ readonly items: {
973
+ readonly $ref: "#/$defs/caseReading";
974
+ };
975
+ };
976
+ readonly importCost: Schema;
977
+ };
978
+ };
979
+ readonly caseReading: {
980
+ readonly type: "object";
981
+ readonly required: readonly ["benchmark", "size", "inputsPerPass", "bytesPerPass", "throughput", "latency", "allocations", "peak", "longRun"];
982
+ readonly properties: {
983
+ readonly benchmark: {
984
+ readonly type: "string";
985
+ };
986
+ readonly size: {
987
+ readonly type: "string";
988
+ };
989
+ readonly inputsPerPass: {
990
+ readonly type: "integer";
991
+ readonly minimum: 0;
992
+ };
993
+ readonly bytesPerPass: {
994
+ readonly type: readonly ["number", "null"];
995
+ };
996
+ readonly throughput: Schema;
997
+ readonly latency: Schema;
998
+ readonly allocations: Schema;
999
+ readonly peak: Schema;
1000
+ readonly longRun: Schema;
1001
+ };
1002
+ };
1003
+ };
1004
+ };
1005
+ /**
1006
+ * Parse and check a results document. Throws a `TypeError` listing every problem when the text is
1007
+ * not JSON, is a version this package does not read, or does not match the schema.
1008
+ *
1009
+ * @example
1010
+ * ```ts
1011
+ * import { readResults } from "@cosyte/bench";
1012
+ *
1013
+ * try {
1014
+ * readResults('{"schemaVersion": 2}');
1015
+ * } catch (error) {
1016
+ * String(error).includes("version 2"); // => true
1017
+ * }
1018
+ * ```
1019
+ */
1020
+ declare function readResults(text: string): ResultsDocument;
1021
+
1022
+ /**
1023
+ * Starting measuring processes and reading what they measured.
1024
+ *
1025
+ * @packageDocumentation
1026
+ */
1027
+
1028
+ /**
1029
+ * A run that could not produce a measurement. The command line reports it and exits 2.
1030
+ *
1031
+ * @example
1032
+ * ```ts
1033
+ * import { BenchError } from "@cosyte/bench";
1034
+ *
1035
+ * new BenchError("no result").name; // => "BenchError"
1036
+ * ```
1037
+ */
1038
+ declare class BenchError extends Error {
1039
+ readonly name = "BenchError";
1040
+ }
1041
+
1042
+ export { BENCH_CONTRACT, BenchError, RESULTS_SCHEMA, SCHEMA_VERSION, defineBenchmark, defineSuite, judge, readResults };
1043
+ export type { AllocationReport, AnyBenchmark, BenchContractShape, BenchCorpus, BenchSize, BenchSuite, Benchmark, BuildRef, CalibrationBody, CaseComparison, CaseReading, CaseRef, ComparisonBody, CountedRate, Environment, ImportCostReport, LatencyReport, LoadedFile, LongRunReport, MemorySkipReason, PeakReport, ReadingBody, ResultsDocument, Runner, Throughput, Verdict, WarmupReport };