@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.
package/dist/cli.mjs ADDED
@@ -0,0 +1,1441 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'fs';
3
+ import { cpus, loadavg, totalmem, availableParallelism, release, tmpdir } from 'os';
4
+ import { createRequire } from 'module';
5
+ import { resolve, relative, join } from 'path';
6
+ import 'perf_hooks';
7
+ import { fileURLToPath, pathToFileURL } from 'url';
8
+ import { gzipSync } from 'zlib';
9
+ import { spawn } from 'child_process';
10
+
11
+ function readCgroupCpuMax(path = "/sys/fs/cgroup/cpu.max") {
12
+ try {
13
+ return String(readFileSync(path)).trim();
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+ function environment(env = process.env, cgroupCpuMax = () => readCgroupCpuMax(), cpuList = cpus, load = loadavg) {
19
+ const cpu = cpuList()[0];
20
+ const provider = env["GITHUB_ACTIONS"] === "true" ? "github-actions" : null;
21
+ const imageOS = env["ImageOS"] ?? null;
22
+ const runnerClass = provider === null ? `local-${process.platform}-${process.arch}` : `${provider}-${env["RUNNER_ENVIRONMENT"] ?? "unknown"}-${imageOS ?? "unknown"}-${process.arch}`;
23
+ return {
24
+ node: process.version,
25
+ v8: process.versions.v8,
26
+ platform: process.platform,
27
+ arch: process.arch,
28
+ osRelease: release(),
29
+ cpuModel: cpu === void 0 ? null : cpu.model.trim(),
30
+ logicalCpus: availableParallelism(),
31
+ totalMemoryBytes: totalmem(),
32
+ cgroupCpuMax: cgroupCpuMax(),
33
+ nodeOptionsSet: (env["NODE_OPTIONS"] ?? "") !== "",
34
+ loadAverage: load().map((x) => Math.round(x * 100) / 100),
35
+ runner: {
36
+ ci: env["CI"] === "true",
37
+ provider,
38
+ environment: env["RUNNER_ENVIRONMENT"] ?? null,
39
+ imageOS,
40
+ imageVersion: env["ImageVersion"] ?? null,
41
+ class: runnerClass
42
+ }
43
+ };
44
+ }
45
+
46
+ // src/contract.ts
47
+ var BENCH_CONTRACT = Object.freeze({
48
+ SAMPLES: 5,
49
+ SAMPLE_MIN_MS: 50,
50
+ ROUNDS: 12,
51
+ ALPHA: 0.05,
52
+ THRESHOLD: 0.03,
53
+ LATENCY_BUDGET_MS: 2e3,
54
+ LATENCY_MAX_CALLS: 5e3,
55
+ LATENCY_MIN_CALLS: 20,
56
+ LATENCY_TAIL_CALLS: 10,
57
+ LATENCY_TIMER_MULTIPLE: 20,
58
+ ALLOC_WINDOW_OPS: 256,
59
+ ALLOC_WINDOW_MS: 100,
60
+ ALLOC_SEMI_SPACE_MIB: 64,
61
+ PEAK_CALLS: 20,
62
+ LONG_RUN_ROUNDS: 30,
63
+ LONG_RUN_SETTLE_ROUNDS: 5,
64
+ LONG_RUN_ROUND_MS: 100,
65
+ LONG_RUN_GROWTH_BYTES: 64 * 1024,
66
+ GC_SETTLE_BYTES: 64 * 1024,
67
+ GC_SETTLE_ROUNDS: 2,
68
+ GC_MAX_ROUNDS: 7,
69
+ IMPORT_RUNS: 20,
70
+ REQUIRED_FALSE_ALARM_RATE: 0.01,
71
+ REQUIRED_CONFIDENCE: 0.95
72
+ });
73
+ var BenchError = class extends Error {
74
+ name = "BenchError";
75
+ };
76
+ var ENTRIES = Object.freeze({
77
+ child: fileURLToPath(new URL("./child-entry.mjs", import.meta.url)),
78
+ importer: fileURLToPath(new URL("./import-entry.mjs", import.meta.url))
79
+ });
80
+ function childEnv(env = process.env) {
81
+ const out = { ...env };
82
+ delete out["NODE_V8_COVERAGE"];
83
+ delete out["NODE_COMPILE_CACHE"];
84
+ return out;
85
+ }
86
+ function runNode(entry, args, execArgv, timeoutMs, execPath = process.execPath) {
87
+ return new Promise((resolve2, reject) => {
88
+ const child = spawn(execPath, [...execArgv, entry, ...args], {
89
+ stdio: ["ignore", "ignore", "pipe"],
90
+ env: childEnv()
91
+ });
92
+ let stderr = "";
93
+ let timedOut = false;
94
+ child.stderr.on("data", (chunk) => {
95
+ stderr = (stderr + String(chunk)).slice(-16384);
96
+ });
97
+ const timer = setTimeout(() => {
98
+ timedOut = true;
99
+ child.kill("SIGKILL");
100
+ }, timeoutMs);
101
+ child.on("error", (error) => {
102
+ clearTimeout(timer);
103
+ reject(error);
104
+ });
105
+ child.on("close", (code, signal) => {
106
+ clearTimeout(timer);
107
+ resolve2({ code, signal, stderr, timedOut });
108
+ });
109
+ });
110
+ }
111
+ function flagsFor(job) {
112
+ if (job.mode === "timing") return [];
113
+ if (job.mode === "heap") return ["--expose-gc"];
114
+ const mib = String(BENCH_CONTRACT.ALLOC_SEMI_SPACE_MIB);
115
+ return ["--expose-gc", `--min-semi-space-size=${mib}`, `--max-semi-space-size=${mib}`];
116
+ }
117
+ function describe(job) {
118
+ return `${job.mode} job for "${job.case.benchmark} @ ${job.case.size}"`;
119
+ }
120
+ async function runInChild(job, options = {}) {
121
+ const dir = mkdtempSync(join(tmpdir(), "cosyte-bench-"));
122
+ try {
123
+ const jobFile = join(dir, "job.json");
124
+ const outFile = join(dir, "out.json");
125
+ writeFileSync(jobFile, JSON.stringify(job));
126
+ const outcome = await runNode(
127
+ options.entry ?? ENTRIES.child,
128
+ [jobFile, outFile],
129
+ options.execArgv ?? flagsFor(job),
130
+ options.timeoutMs ?? 6e5
131
+ );
132
+ if (outcome.timedOut) throw new BenchError(`the ${describe(job)} timed out`);
133
+ let text;
134
+ try {
135
+ text = String(readFileSync(outFile));
136
+ } catch {
137
+ const how = outcome.signal === null ? `exit ${String(outcome.code)}` : outcome.signal;
138
+ throw new BenchError(
139
+ `the ${describe(job)} ended (${how}) without a result. Its stderr ends:
140
+ ${outcome.stderr}`
141
+ );
142
+ }
143
+ const out = JSON.parse(text);
144
+ if (!out.ok || out.result === void 0) {
145
+ throw new BenchError(`the ${describe(job)} could not measure: ${String(out.error)}`);
146
+ }
147
+ return out.result;
148
+ } finally {
149
+ rmSync(dir, { recursive: true });
150
+ }
151
+ }
152
+
153
+ // src/stats.ts
154
+ function sortedCopy(xs) {
155
+ return [...xs].sort((a, b) => a - b);
156
+ }
157
+ function median(xs) {
158
+ const s = sortedCopy(xs);
159
+ const mid = Math.floor(s.length / 2);
160
+ return s.length % 2 === 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
161
+ }
162
+ function nearestRank(xs, q) {
163
+ if (xs.length === 0) return Number.NaN;
164
+ const s = sortedCopy(xs);
165
+ const rank = Math.max(1, Math.ceil(q * s.length));
166
+ return s[rank - 1];
167
+ }
168
+ function binomialCdf(k, n, p) {
169
+ const logP = Math.log(p);
170
+ const logQ = Math.log1p(-p);
171
+ let logCoef = 0;
172
+ let total = 0;
173
+ for (let i = 0; i <= Math.min(k, n); i++) {
174
+ if (i > 0) logCoef += Math.log(n - i + 1) - Math.log(i);
175
+ total += Math.exp(logCoef + i * logP + (n - i) * logQ);
176
+ }
177
+ return Math.min(1, total);
178
+ }
179
+ function signTestRank(n, alpha) {
180
+ let k = 0;
181
+ while (k < n && binomialCdf(k, n, 0.5) <= alpha) k++;
182
+ return k;
183
+ }
184
+ function signTestInterval(xs, alpha) {
185
+ const rank = signTestRank(xs.length, alpha);
186
+ if (rank === 0) return null;
187
+ const s = sortedCopy(xs);
188
+ return {
189
+ low: s[rank - 1],
190
+ high: s[s.length - rank],
191
+ rank,
192
+ tail: binomialCdf(rank - 1, xs.length, 0.5)
193
+ };
194
+ }
195
+ function rateUpperBound(k, n, confidence) {
196
+ if (n === 0) return 1;
197
+ const alpha = 1 - confidence;
198
+ let lo = k / n;
199
+ let hi = 1;
200
+ for (let i = 0; i < 100; i++) {
201
+ const mid = (lo + hi) / 2;
202
+ if (binomialCdf(k, n, mid) > alpha) lo = mid;
203
+ else hi = mid;
204
+ }
205
+ return hi;
206
+ }
207
+ function trialsForZeroEvents(rate, confidence) {
208
+ return Math.ceil(Math.log(1 - confidence) / Math.log1p(-rate));
209
+ }
210
+ function round(n, places = 4) {
211
+ const f = 10 ** places;
212
+ return Math.round(n * f) / f;
213
+ }
214
+
215
+ // src/import-cost.ts
216
+ async function oneImport(entry, execArgv, mode, target, dir) {
217
+ const out = join(dir, "import.json");
218
+ rmSync(out, { force: true });
219
+ const outcome = await runNode(entry, [mode, target, out], execArgv, 12e4);
220
+ try {
221
+ return JSON.parse(String(readFileSync(out)));
222
+ } catch {
223
+ throw new BenchError(
224
+ `the cold import of ${relative(process.cwd(), target)} wrote no result. Its stderr ends:
225
+ ${outcome.stderr}`
226
+ );
227
+ }
228
+ }
229
+ async function measureImportCost(target, entry = ENTRIES.importer, execArgv = []) {
230
+ const dir = mkdtempSync(join(tmpdir(), "cosyte-bench-import-"));
231
+ try {
232
+ const empty = join(dir, "empty.mjs");
233
+ writeFileSync(empty, "export {};\n");
234
+ const cold = [];
235
+ const bare = [];
236
+ for (let i = 0; i < BENCH_CONTRACT.IMPORT_RUNS; i++) {
237
+ const order = i % 2 === 0 ? [target, empty] : [empty, target];
238
+ for (const which of order) {
239
+ const { ms } = await oneImport(entry, execArgv, "time", which, dir);
240
+ (which === target ? cold : bare).push(round(ms, 3));
241
+ }
242
+ }
243
+ const { files } = await oneImport(entry, execArgv, "files", target, dir);
244
+ const loaded = files.map((file) => {
245
+ const bytes2 = readFileSync(file);
246
+ return {
247
+ path: relative(process.cwd(), file),
248
+ bytes: bytes2.length,
249
+ gzipBytes: gzipSync(bytes2, { level: 9 }).length
250
+ };
251
+ }).sort((a, b) => a.path.localeCompare(b.path));
252
+ return {
253
+ runs: BENCH_CONTRACT.IMPORT_RUNS,
254
+ coldImportMs: cold,
255
+ medianMs: round(median(cold), 3),
256
+ p95Ms: round(nearestRank(cold, 0.95), 3),
257
+ emptyImportMs: bare,
258
+ emptyMedianMs: round(median(bare), 3),
259
+ files: loaded,
260
+ totalBytes: loaded.reduce((n, f) => n + f.bytes, 0),
261
+ totalGzipBytes: loaded.reduce((n, f) => n + f.gzipBytes, 0)
262
+ };
263
+ } finally {
264
+ rmSync(dir, { recursive: true });
265
+ }
266
+ }
267
+
268
+ // src/suite.ts
269
+ function fail(message) {
270
+ throw new TypeError(`@cosyte/bench: ${message}`);
271
+ }
272
+ function isRecord(value) {
273
+ return typeof value === "object" && value !== null;
274
+ }
275
+ function checkName(value, what) {
276
+ if (typeof value !== "string" || value.trim() === "") fail(`${what} must be a non-empty string`);
277
+ return value;
278
+ }
279
+ function checkSuite(value) {
280
+ if (!isRecord(value)) fail("a suite must be an object made by defineSuite");
281
+ const name = checkName(value["name"], "the suite name");
282
+ const benchmarks = value["benchmarks"];
283
+ if (!Array.isArray(benchmarks) || benchmarks.length === 0) {
284
+ fail(`suite "${name}" must have at least one benchmark`);
285
+ }
286
+ const seen = /* @__PURE__ */ new Set();
287
+ for (const b of benchmarks) {
288
+ if (!isRecord(b)) fail(`suite "${name}" holds a benchmark that is not an object`);
289
+ const bName = checkName(b["name"], `a benchmark name in suite "${name}"`);
290
+ if (seen.has(bName)) fail(`suite "${name}" has two benchmarks named "${bName}"`);
291
+ seen.add(bName);
292
+ if (typeof b["bind"] !== "function") fail(`benchmark "${bName}" has no bind function`);
293
+ if (b["weigh"] !== void 0 && typeof b["weigh"] !== "function") {
294
+ fail(`benchmark "${bName}" has a weigh that is not a function`);
295
+ }
296
+ const sizes = b["sizes"];
297
+ if (!Array.isArray(sizes) || sizes.length === 0) fail(`benchmark "${bName}" has no sizes`);
298
+ const labels = /* @__PURE__ */ new Set();
299
+ for (const s of sizes) {
300
+ if (!isRecord(s)) fail(`benchmark "${bName}" holds a size that is not an object`);
301
+ const label = checkName(s["label"], `a size label in benchmark "${bName}"`);
302
+ if (labels.has(label)) fail(`benchmark "${bName}" has two sizes labelled "${label}"`);
303
+ labels.add(label);
304
+ if (typeof s["build"] !== "function")
305
+ fail(`size "${label}" of "${bName}" has no build function`);
306
+ if (s["compare"] !== void 0 && typeof s["compare"] !== "boolean") {
307
+ fail(`size "${label}" of "${bName}" has a compare that is not true or false`);
308
+ }
309
+ }
310
+ if (b["longRun"] !== void 0 && !labels.has(b["longRun"])) {
311
+ fail(`benchmark "${bName}" names a longRun size it does not have`);
312
+ }
313
+ }
314
+ return Object.freeze(value);
315
+ }
316
+ function listCases(suite, only = [], forComparison = false) {
317
+ const all = [];
318
+ for (const b of suite.benchmarks) {
319
+ for (const s of b.sizes) {
320
+ if (!forComparison || s.compare !== false) all.push({ benchmark: b.name, size: s.label });
321
+ }
322
+ }
323
+ if (only.length === 0) return all;
324
+ for (const o of only) {
325
+ if (!all.some((c) => c.benchmark === o || caseId(c) === o)) {
326
+ fail(`--only "${o}" names no benchmark or case in suite "${suite.name}"`);
327
+ }
328
+ }
329
+ return all.filter((c) => only.includes(c.benchmark) || only.includes(caseId(c)));
330
+ }
331
+ function caseId(c) {
332
+ return `${c.benchmark} @ ${c.size}`;
333
+ }
334
+ function findCase(suite, c) {
335
+ const benchmark = suite.benchmarks.find((b) => b.name === c.benchmark);
336
+ const size = benchmark?.sizes.find((s) => s.label === c.size);
337
+ if (size === void 0) fail(`suite "${suite.name}" has no case "${caseId(c)}"`);
338
+ return { benchmark, size };
339
+ }
340
+
341
+ // src/compare.ts
342
+ function judge(ratios, threshold = BENCH_CONTRACT.THRESHOLD, alpha = BENCH_CONTRACT.ALPHA) {
343
+ const interval = signTestInterval(ratios, alpha);
344
+ const medianRatio = median(ratios);
345
+ if (interval === null) return { verdict: "inconclusive", interval, medianRatio };
346
+ const up = 1 + threshold;
347
+ let verdict;
348
+ if (interval.low > up) verdict = "regression";
349
+ else if (interval.high < 1 / up) verdict = "improvement";
350
+ else if (interval.high <= up) verdict = "no-regression";
351
+ else verdict = "inconclusive";
352
+ return { verdict, interval, medianRatio };
353
+ }
354
+ async function compareBuilds(options) {
355
+ const runJob = options.runJob;
356
+ const inject = options.inject ?? 0;
357
+ const cases = listCases(options.suite, options.only, true);
358
+ const state = cases.map((c) => ({
359
+ c,
360
+ rounds: [],
361
+ skip: null
362
+ }));
363
+ for (let r = 0; r < BENCH_CONTRACT.ROUNDS; r++) {
364
+ for (const s of state) {
365
+ if (s.skip !== null) continue;
366
+ const sides = r % 2 === 0 ? ["base", "head"] : ["head", "base"];
367
+ const got = /* @__PURE__ */ new Map();
368
+ for (const side of sides) {
369
+ const result = await runJob({
370
+ mode: "timing",
371
+ suite: options.suiteFile,
372
+ target: side === "base" ? options.base : options.head,
373
+ case: s.c,
374
+ latency: false,
375
+ inject: side === "head" ? inject : 0
376
+ });
377
+ if (result.mode !== "timing")
378
+ throw new Error("@cosyte/bench: a timing job returned another kind");
379
+ if (result.status === "skipped") {
380
+ s.skip = `${side}: ${result.reason}`;
381
+ break;
382
+ }
383
+ got.set(side, result.samplesMsPerOp);
384
+ }
385
+ const base = got.get("base");
386
+ const head = got.get("head");
387
+ if (base !== void 0 && head !== void 0) s.rounds.push({ base, head });
388
+ }
389
+ options.onProgress?.(`round ${String(r + 1)} of ${String(BENCH_CONTRACT.ROUNDS)} done`);
390
+ }
391
+ return {
392
+ rounds: BENCH_CONTRACT.ROUNDS,
393
+ threshold: BENCH_CONTRACT.THRESHOLD,
394
+ alpha: BENCH_CONTRACT.ALPHA,
395
+ inject,
396
+ cases: state.map(({ c, rounds, skip }) => {
397
+ const recorded = {
398
+ baseMsPerOp: rounds.map((p) => Math.min(...p.base)),
399
+ headMsPerOp: rounds.map((p) => Math.min(...p.head)),
400
+ baseSamples: rounds.map((p) => p.base),
401
+ headSamples: rounds.map((p) => p.head)
402
+ };
403
+ if (skip !== null) {
404
+ return {
405
+ ...c,
406
+ ...recorded,
407
+ ratios: [],
408
+ medianRatio: null,
409
+ interval: null,
410
+ verdict: "skipped",
411
+ skipReason: skip
412
+ };
413
+ }
414
+ const ratios = rounds.map((p) => round(Math.min(...p.head) / Math.min(...p.base), 6));
415
+ const j = judge(ratios);
416
+ return {
417
+ ...c,
418
+ ...recorded,
419
+ ratios,
420
+ medianRatio: round(j.medianRatio, 6),
421
+ interval: j.interval,
422
+ verdict: j.verdict,
423
+ skipReason: null
424
+ };
425
+ })
426
+ };
427
+ }
428
+ function comparisonExitCode(body) {
429
+ if (body.cases.some((c) => c.verdict === "regression")) return 1;
430
+ if (body.cases.some((c) => c.verdict === "skipped")) return 2;
431
+ return 0;
432
+ }
433
+ function counted(events, trials) {
434
+ return {
435
+ events,
436
+ trials,
437
+ rate: trials === 0 ? 0 : round(events / trials, 6),
438
+ upperBound: round(rateUpperBound(events, trials, BENCH_CONTRACT.REQUIRED_CONFIDENCE), 6)
439
+ };
440
+ }
441
+ function summarizeCalibration(inject, runs) {
442
+ const verdicts = {
443
+ regression: 0,
444
+ improvement: 0,
445
+ "no-regression": 0,
446
+ inconclusive: 0,
447
+ skipped: 0
448
+ };
449
+ const pooled = /* @__PURE__ */ new Map();
450
+ let caseTrials = 0;
451
+ let caseEvents = 0;
452
+ let compTrials = 0;
453
+ let compEvents = 0;
454
+ let compSkipped = 0;
455
+ for (const run of runs) {
456
+ let alarmed = false;
457
+ let skipped2 = false;
458
+ for (const c of run) {
459
+ verdicts[c.verdict]++;
460
+ const id = caseId(c);
461
+ const pool = pooled.get(id) ?? { ref: { benchmark: c.benchmark, size: c.size }, ratios: [] };
462
+ pooled.set(id, pool);
463
+ if (c.verdict === "skipped") {
464
+ skipped2 = true;
465
+ continue;
466
+ }
467
+ pool.ratios.push(...c.ratios);
468
+ caseTrials++;
469
+ if (c.verdict === "regression") {
470
+ caseEvents++;
471
+ alarmed = true;
472
+ }
473
+ }
474
+ if (skipped2) compSkipped++;
475
+ if (alarmed || !skipped2) compTrials++;
476
+ if (alarmed) compEvents++;
477
+ }
478
+ const perComparison = counted(compEvents, compTrials);
479
+ const skippedComparisons = counted(compSkipped, runs.length);
480
+ const line = BENCH_CONTRACT.REQUIRED_FALSE_ALARM_RATE;
481
+ const needed = trialsForZeroEvents(line, BENCH_CONTRACT.REQUIRED_CONFIDENCE);
482
+ const pct2 = (x) => `${String(round(x * 100, 4))}%`;
483
+ const alarmsUnder = perComparison.upperBound < line;
484
+ const skipsUnder = skippedComparisons.upperBound < line;
485
+ const alarms = `${String(compEvents)} false alarm(s) in ${String(compTrials)} A/A comparisons that measured every case or called a regression`;
486
+ const skips = `${String(compSkipped)} of ${String(runs.length)} comparisons skipped a case`;
487
+ let requiredRule;
488
+ if (inject !== 0) {
489
+ requiredRule = {
490
+ applies: false,
491
+ met: false,
492
+ reason: "a calibration with an injected slowdown measures detection, not false alarms"
493
+ };
494
+ } else if (alarmsUnder && skipsUnder) {
495
+ requiredRule = {
496
+ applies: true,
497
+ met: true,
498
+ reason: `${alarms}, and ${skips}: both rates are under ${pct2(line)} with ${pct2(BENCH_CONTRACT.REQUIRED_CONFIDENCE)} confidence (upper bounds ${pct2(perComparison.upperBound)} and ${pct2(skippedComparisons.upperBound)})`
499
+ };
500
+ } else {
501
+ const why = [];
502
+ if (!alarmsUnder) {
503
+ why.push(
504
+ `${alarms}: the upper bound ${pct2(perComparison.upperBound)} is not under ${pct2(line)}, and a clean record needs at least ${String(needed)} of them`
505
+ );
506
+ }
507
+ if (!skipsUnder) {
508
+ why.push(
509
+ `${skips}, and a required check is red on each: the upper bound ${pct2(skippedComparisons.upperBound)} on that rate is not under ${pct2(line)}`
510
+ );
511
+ }
512
+ requiredRule = { applies: true, met: false, reason: why.join("; ") };
513
+ }
514
+ return {
515
+ inject,
516
+ comparisons: runs.length,
517
+ runs,
518
+ perCase: counted(caseEvents, caseTrials),
519
+ perComparison,
520
+ skippedComparisons,
521
+ verdicts,
522
+ observed: [...pooled.values()].map(({ ref, ratios }) => ({
523
+ ...ref,
524
+ rounds: ratios.length,
525
+ medianRatio: ratios.length === 0 ? null : round(median(ratios), 6)
526
+ })),
527
+ requiredRule
528
+ };
529
+ }
530
+ async function calibrateComparison(options) {
531
+ const runs = [];
532
+ for (let k = 0; k < options.comparisons; k++) {
533
+ const { onProgress: _quiet, ...quiet } = options;
534
+ const body = await compareBuilds(quiet);
535
+ runs.push([...body.cases]);
536
+ const alarms = body.cases.filter((c) => c.verdict === "regression").map(caseId);
537
+ options.onProgress?.(
538
+ `comparison ${String(k + 1)} of ${String(options.comparisons)}: ` + (alarms.length === 0 ? "no regression verdict" : `regression on ${alarms.join(", ")}`)
539
+ );
540
+ }
541
+ return summarizeCalibration(options.inject ?? 0, runs);
542
+ }
543
+
544
+ // src/reading.ts
545
+ function expectTiming(result) {
546
+ if (result.mode !== "timing")
547
+ throw new Error("@cosyte/bench: a timing job returned another kind");
548
+ return result;
549
+ }
550
+ function expectMemory(result) {
551
+ if (result.mode === "timing")
552
+ throw new Error("@cosyte/bench: a memory job returned a timing result");
553
+ return result;
554
+ }
555
+ function summarizeThroughput(first, rest) {
556
+ const measured = [];
557
+ for (const t of [first, ...rest]) {
558
+ if (t.status === "skipped") return { status: "skipped", reason: t.reason };
559
+ measured.push(t);
560
+ }
561
+ const per = measured.map((t) => ({
562
+ samplesMsPerOp: t.samplesMsPerOp,
563
+ medianMsPerOp: t.medianMsPerOp
564
+ }));
565
+ const mid = median(per.map((x) => x.medianMsPerOp));
566
+ const perOpBytes = first.bytesPerPass === null ? null : first.bytesPerPass / first.inputsPerPass;
567
+ return {
568
+ status: "measured",
569
+ processes: per,
570
+ medianMsPerOp: mid,
571
+ opsPerSecond: round(1e3 / mid, 2),
572
+ bytesPerSecond: perOpBytes === null ? null : round(perOpBytes * (1e3 / mid), 0)
573
+ };
574
+ }
575
+ async function readBuild(options) {
576
+ const runJob = options.runJob;
577
+ const processes = options.processes ?? 3;
578
+ if (!Number.isInteger(processes) || processes < 1) {
579
+ throw new TypeError("@cosyte/bench: processes must be a positive integer");
580
+ }
581
+ const base = { suite: options.suiteFile, target: options.target };
582
+ const cases = [];
583
+ for (const c of listCases(options.suite, options.only)) {
584
+ const timing = async (latency2) => expectTiming(await runJob({ mode: "timing", ...base, case: c, latency: latency2, inject: 0 }));
585
+ const first = await timing(true);
586
+ const rest = [];
587
+ for (let p = 1; p < processes; p++) rest.push(await timing(false));
588
+ const throughput = summarizeThroughput(first, rest);
589
+ const latency = first.status === "measured" ? first.latency : null;
590
+ let allocations = null;
591
+ let peak = null;
592
+ let longRun = null;
593
+ if (options.memory ?? true) {
594
+ allocations = expectMemory(await runJob({ mode: "alloc", ...base, case: c })).allocations;
595
+ const { benchmark } = findCase(options.suite, c);
596
+ const longRunSize = benchmark.longRun ?? benchmark.sizes[0].label;
597
+ const heap = expectMemory(
598
+ await runJob({ mode: "heap", ...base, case: c, longRun: c.size === longRunSize })
599
+ );
600
+ peak = heap.peak;
601
+ longRun = heap.longRun;
602
+ }
603
+ cases.push({
604
+ ...c,
605
+ inputsPerPass: first.inputsPerPass,
606
+ bytesPerPass: first.bytesPerPass,
607
+ throughput,
608
+ latency,
609
+ allocations,
610
+ peak,
611
+ longRun
612
+ });
613
+ options.onProgress?.(`read ${c.benchmark} @ ${c.size}`);
614
+ }
615
+ const importCost = options.importCost ?? true ? await options.measureImport(options.target) : null;
616
+ return { processes, cases, importCost };
617
+ }
618
+
619
+ // src/report.ts
620
+ function pct(x) {
621
+ const v = round(x * 100, 1);
622
+ return `${v > 0 ? "+" : ""}${String(v)}%`;
623
+ }
624
+ function bytes(n) {
625
+ if (Math.abs(n) >= 1024 * 1024) return `${String(round(n / (1024 * 1024), 2))} MiB`;
626
+ if (Math.abs(n) >= 1024) return `${String(round(n / 1024, 1))} KiB`;
627
+ return `${String(round(n, 1))} B`;
628
+ }
629
+ function share(x) {
630
+ return `${String(round(x * 100, 2))}%`;
631
+ }
632
+ function time(ms) {
633
+ if (ms >= 1) return `${String(round(ms, 3))} ms`;
634
+ if (ms >= 1e-3) return `${String(round(ms * 1e3, 2))} us`;
635
+ return `${String(round(ms * 1e6, 1))} ns`;
636
+ }
637
+ function ns(n) {
638
+ return n === null ? "n/a" : time(n / 1e6);
639
+ }
640
+ function header(doc, title) {
641
+ const e = doc.environment;
642
+ return [
643
+ `### ${title}: ${doc.suite.name}`,
644
+ "",
645
+ `Node ${e.node} on ${e.runner.class} (${e.cpuModel ?? "unknown CPU"}, ${String(e.logicalCpus)} logical CPUs), @cosyte/bench ${doc.tool.version}, ${doc.createdAt}.`,
646
+ ""
647
+ ];
648
+ }
649
+ function comparisonRow(c) {
650
+ const base = c.baseMsPerOp.length === 0 ? "n/a" : time(c.baseMsPerOp.reduce((a, b) => a + b, 0) / c.baseMsPerOp.length);
651
+ const head = c.headMsPerOp.length === 0 ? "n/a" : time(c.headMsPerOp.reduce((a, b) => a + b, 0) / c.headMsPerOp.length);
652
+ const change = c.medianRatio === null ? "n/a" : pct(c.medianRatio - 1);
653
+ const interval = c.interval === null ? "n/a" : `${pct(c.interval.low - 1)} to ${pct(c.interval.high - 1)}`;
654
+ const verdict = c.verdict === "skipped" ? `skipped (${String(c.skipReason)})` : c.verdict;
655
+ return `| ${c.benchmark} | ${c.size} | ${base} | ${head} | ${change} | ${interval} | ${verdict} |`;
656
+ }
657
+ function renderMarkdown(doc) {
658
+ const lines = [];
659
+ if (doc.kind === "comparison") {
660
+ const b = doc.comparison;
661
+ lines.push(...header(doc, "Base against head"));
662
+ lines.push(
663
+ `${String(b.rounds)} interleaved rounds, a fresh process per side per case per round. A regression is a change whose whole interval is above ${pct(b.threshold)}; each end of the interval is wrong at most ${String(round(b.alpha * 100, 1))}% of the time. Base and head times are means of the per-round fastest samples.`,
664
+ "",
665
+ "| Benchmark | Size | Base | Head | Change (median) | Interval | Verdict |",
666
+ "| --- | --- | --- | --- | --- | --- | --- |",
667
+ ...b.cases.map(comparisonRow)
668
+ );
669
+ } else if (doc.kind === "calibration") {
670
+ const c = doc.calibration;
671
+ lines.push(
672
+ ...header(
673
+ doc,
674
+ c.inject === 0 ? "A/A calibration" : `Calibration with a ${share(c.inject)} slowdown`
675
+ )
676
+ );
677
+ const what = c.inject === 0 ? "false alarms" : "detections";
678
+ lines.push(
679
+ `${String(c.comparisons)} comparisons. Regression verdicts (${what}): ${String(c.perCase.events)} of ${String(c.perCase.trials)} measured case verdicts (${share(c.perCase.rate)}, upper bound ${share(c.perCase.upperBound)}); ${String(c.perComparison.events)} of ${String(c.perComparison.trials)} comparisons that measured every case or called a regression (${share(c.perComparison.rate)}, upper bound ${share(c.perComparison.upperBound)}). Comparisons that skipped a case: ${String(c.skippedComparisons.events)} of ${String(c.skippedComparisons.trials)} (${share(c.skippedComparisons.rate)}, upper bound ${share(c.skippedComparisons.upperBound)}).`,
680
+ "",
681
+ `Verdicts: ${Object.entries(c.verdicts).map(([k, v]) => `${k} ${String(v)}`).join(", ")}.`,
682
+ "",
683
+ "The change each case was observed at, the median of every measured round's head / base:",
684
+ "",
685
+ "| Benchmark | Size | Measured rounds | Observed change |",
686
+ "| --- | --- | --- | --- |",
687
+ ...c.observed.map(
688
+ (o) => `| ${o.benchmark} | ${o.size} | ${String(o.rounds)} | ${o.medianRatio === null ? "n/a" : pct(o.medianRatio - 1)} |`
689
+ )
690
+ );
691
+ if (c.requiredRule.applies) {
692
+ lines.push(
693
+ "",
694
+ `Required check rule: ${c.requiredRule.met ? "met" : "not met"}. ${c.requiredRule.reason}.`
695
+ );
696
+ }
697
+ } else {
698
+ const r = doc.reading;
699
+ lines.push(...header(doc, "Reading"));
700
+ lines.push(
701
+ `Throughput is the median of ${String(r.processes)} fresh processes. Latency is per call, nearest rank. Memory: allocations per operation, the peak heap of one call above the settled heap, and whether the settled heap stays flat over a long run.`,
702
+ "",
703
+ "| Benchmark | Size | Per op | Ops/s | Bytes/s | p50 | p95 | p99 | Alloc/op | Peak heap | Long run |",
704
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |"
705
+ );
706
+ for (const c of r.cases) {
707
+ const t = c.throughput;
708
+ const perOp = t.status === "measured" ? time(t.medianMsPerOp) : `skipped (${t.reason})`;
709
+ const ops = t.status === "measured" ? String(t.opsPerSecond) : "n/a";
710
+ const bps = t.status === "measured" && t.bytesPerSecond !== null ? `${bytes(t.bytesPerSecond)}/s` : "n/a";
711
+ const l = c.latency;
712
+ const lat = l === null ? ["n/a", "n/a", "n/a"] : l.status === "measured" ? [ns(l.p50Ns), ns(l.p95Ns), ns(l.p99Ns)] : [`skipped (${l.reason})`, "n/a", "n/a"];
713
+ const a = c.allocations;
714
+ const alloc = a === null ? "n/a" : a.status === "measured" ? bytes(a.heapBytesPerOp + a.externalBytesPerOp) : `skipped (${a.reason})`;
715
+ const p = c.peak;
716
+ const peak = p === null ? "n/a" : p.status === "measured" ? bytes(p.peakHeapBytes) : `skipped (${p.reason})`;
717
+ const lr = c.longRun;
718
+ const long = lr === null ? "" : lr.status === "measured" ? `${lr.verdict} (${bytes(lr.growthBytes)} over ${String(lr.ops)} ops, ${String(lr.slopeBytesPerOp)} B/op)` : `skipped (${lr.reason})`;
719
+ lines.push(
720
+ `| ${c.benchmark} | ${c.size} | ${perOp} | ${ops} | ${bps} | ${lat.join(" | ")} | ${alloc} | ${peak} | ${long} |`
721
+ );
722
+ }
723
+ if (r.importCost !== null) {
724
+ const i = r.importCost;
725
+ lines.push(
726
+ "",
727
+ `Cold import: median ${time(i.medianMs)}, p95 ${time(i.p95Ms)} over ${String(i.runs)} fresh processes (an empty module: median ${time(i.emptyMedianMs)}). Loaded: ${String(i.files.length)} file(s), ${bytes(i.totalBytes)}, ${bytes(i.totalGzipBytes)} gzipped.`
728
+ );
729
+ }
730
+ }
731
+ return `${lines.join("\n")}
732
+ `;
733
+ }
734
+
735
+ // ../test-utils/src/perf/contract.ts
736
+ var PERF_CONTRACT = Object.freeze({
737
+ RATIO_CEILING: 8,
738
+ RATIO_FLOOR: 1.5,
739
+ REPS: 5,
740
+ SCALE_STEP: 4,
741
+ MIN_PHASE_MS: 4,
742
+ WARMUP_MIN_MS: 500,
743
+ WARMUP_BATCH_MS: 50,
744
+ WARMUP_MAX_MS: 5e3,
745
+ WARMUP_STABLE_TOL: 0.05,
746
+ WARMUP_STABLE_BATCHES: 3
747
+ });
748
+
749
+ // src/measure.ts
750
+ var WARMUP = Object.freeze({
751
+ MIN_MS: PERF_CONTRACT.WARMUP_MIN_MS,
752
+ BATCH_MS: PERF_CONTRACT.WARMUP_BATCH_MS,
753
+ MAX_MS: PERF_CONTRACT.WARMUP_MAX_MS,
754
+ STABLE_TOL: PERF_CONTRACT.WARMUP_STABLE_TOL,
755
+ STABLE_BATCHES: PERF_CONTRACT.WARMUP_STABLE_BATCHES
756
+ });
757
+
758
+ // src/results.ts
759
+ var SCHEMA_VERSION = 1;
760
+ function toolVersion() {
761
+ const require2 = createRequire(import.meta.url);
762
+ return require2("../package.json").version;
763
+ }
764
+ function documentBase(suite, environment2, now = /* @__PURE__ */ new Date()) {
765
+ return {
766
+ schemaVersion: SCHEMA_VERSION,
767
+ tool: { name: "@cosyte/bench", version: toolVersion() },
768
+ createdAt: now.toISOString(),
769
+ suite,
770
+ environment: environment2,
771
+ contract: { bench: BENCH_CONTRACT, warmup: WARMUP }
772
+ };
773
+ }
774
+ var num = { type: "number" };
775
+ var int = { type: "integer", minimum: 0 };
776
+ var str = { type: "string" };
777
+ var strOrNull = { type: ["string", "null"] };
778
+ var numOrNull = { type: ["number", "null"] };
779
+ var nums = { type: "array", items: num };
780
+ var skipped = {
781
+ type: "object",
782
+ required: ["status", "reason"],
783
+ properties: { status: { const: "skipped" }, reason: str }
784
+ };
785
+ var measuredOrSkipped = (measured) => ({ oneOf: [measured, skipped] });
786
+ var orNull = (s) => ({ oneOf: [{ type: "null" }, s] });
787
+ var RESULTS_SCHEMA = {
788
+ $schema: "https://json-schema.org/draft/2020-12/schema",
789
+ $id: "https://github.com/cosyte/config/blob/main/packages/bench/schema/results-v1.schema.json",
790
+ title: "@cosyte/bench results, version 1",
791
+ type: "object",
792
+ required: ["schemaVersion", "kind", "tool", "createdAt", "suite", "environment", "contract"],
793
+ properties: {
794
+ schemaVersion: { const: 1 },
795
+ kind: { enum: ["reading", "comparison", "calibration"] },
796
+ tool: {
797
+ type: "object",
798
+ required: ["name", "version"],
799
+ properties: { name: { const: "@cosyte/bench" }, version: str }
800
+ },
801
+ createdAt: str,
802
+ suite: { type: "object", required: ["name", "file"], properties: { name: str, file: str } },
803
+ environment: { $ref: "#/$defs/environment" },
804
+ contract: {
805
+ type: "object",
806
+ required: ["bench", "warmup"],
807
+ properties: { bench: { type: "object" }, warmup: { type: "object" } }
808
+ }
809
+ },
810
+ oneOf: [
811
+ {
812
+ required: ["subject", "reading"],
813
+ properties: {
814
+ kind: { const: "reading" },
815
+ subject: { $ref: "#/$defs/build" },
816
+ reading: { $ref: "#/$defs/reading" }
817
+ }
818
+ },
819
+ {
820
+ required: ["base", "head", "comparison"],
821
+ properties: {
822
+ kind: { const: "comparison" },
823
+ base: { $ref: "#/$defs/build" },
824
+ head: { $ref: "#/$defs/build" },
825
+ comparison: { $ref: "#/$defs/comparison" }
826
+ }
827
+ },
828
+ {
829
+ required: ["base", "head", "calibration"],
830
+ properties: {
831
+ kind: { const: "calibration" },
832
+ base: { $ref: "#/$defs/build" },
833
+ head: { $ref: "#/$defs/build" },
834
+ calibration: { $ref: "#/$defs/calibration" }
835
+ }
836
+ }
837
+ ],
838
+ $defs: {
839
+ build: {
840
+ type: "object",
841
+ required: ["target", "commit"],
842
+ properties: { target: str, commit: strOrNull }
843
+ },
844
+ environment: {
845
+ type: "object",
846
+ required: [
847
+ "node",
848
+ "v8",
849
+ "platform",
850
+ "arch",
851
+ "osRelease",
852
+ "cpuModel",
853
+ "logicalCpus",
854
+ "totalMemoryBytes",
855
+ "cgroupCpuMax",
856
+ "runner"
857
+ ],
858
+ properties: {
859
+ node: str,
860
+ v8: str,
861
+ platform: str,
862
+ arch: str,
863
+ osRelease: str,
864
+ cpuModel: strOrNull,
865
+ logicalCpus: int,
866
+ totalMemoryBytes: int,
867
+ cgroupCpuMax: strOrNull,
868
+ // Added within version 1, so not required: a document written before it is still valid.
869
+ loadAverage: nums,
870
+ runner: {
871
+ type: "object",
872
+ required: ["ci", "provider", "class"],
873
+ properties: { ci: { type: "boolean" }, provider: strOrNull, class: str }
874
+ }
875
+ }
876
+ },
877
+ interval: orNull({
878
+ type: "object",
879
+ required: ["low", "high", "rank", "tail"],
880
+ properties: { low: num, high: num, rank: int, tail: num }
881
+ }),
882
+ caseComparison: {
883
+ type: "object",
884
+ required: [
885
+ "benchmark",
886
+ "size",
887
+ "baseMsPerOp",
888
+ "headMsPerOp",
889
+ "ratios",
890
+ "medianRatio",
891
+ "interval",
892
+ "verdict",
893
+ "skipReason"
894
+ ],
895
+ properties: {
896
+ benchmark: str,
897
+ size: str,
898
+ baseMsPerOp: nums,
899
+ headMsPerOp: nums,
900
+ ratios: nums,
901
+ medianRatio: numOrNull,
902
+ interval: { $ref: "#/$defs/interval" },
903
+ verdict: {
904
+ enum: ["regression", "improvement", "no-regression", "inconclusive", "skipped"]
905
+ },
906
+ skipReason: strOrNull
907
+ }
908
+ },
909
+ comparison: {
910
+ type: "object",
911
+ required: ["rounds", "threshold", "alpha", "inject", "cases"],
912
+ properties: {
913
+ rounds: int,
914
+ threshold: num,
915
+ alpha: num,
916
+ inject: num,
917
+ cases: { type: "array", items: { $ref: "#/$defs/caseComparison" } }
918
+ }
919
+ },
920
+ rate: {
921
+ type: "object",
922
+ required: ["events", "trials", "rate", "upperBound"],
923
+ properties: { events: int, trials: int, rate: num, upperBound: num }
924
+ },
925
+ calibration: {
926
+ type: "object",
927
+ required: [
928
+ "inject",
929
+ "comparisons",
930
+ "runs",
931
+ "perCase",
932
+ "perComparison",
933
+ "verdicts",
934
+ "requiredRule"
935
+ ],
936
+ properties: {
937
+ inject: num,
938
+ comparisons: int,
939
+ runs: {
940
+ type: "array",
941
+ items: { type: "array", items: { $ref: "#/$defs/caseComparison" } }
942
+ },
943
+ perCase: { $ref: "#/$defs/rate" },
944
+ perComparison: { $ref: "#/$defs/rate" },
945
+ // Added within version 1, so optional: documents written before it validate unchanged.
946
+ skippedComparisons: { $ref: "#/$defs/rate" },
947
+ verdicts: { type: "object" },
948
+ observed: {
949
+ type: "array",
950
+ items: {
951
+ type: "object",
952
+ required: ["benchmark", "size", "rounds", "medianRatio"],
953
+ properties: { benchmark: str, size: str, rounds: int, medianRatio: numOrNull }
954
+ }
955
+ },
956
+ requiredRule: {
957
+ type: "object",
958
+ required: ["applies", "met", "reason"],
959
+ properties: { applies: { type: "boolean" }, met: { type: "boolean" }, reason: str }
960
+ }
961
+ }
962
+ },
963
+ reading: {
964
+ type: "object",
965
+ required: ["processes", "cases", "importCost"],
966
+ properties: {
967
+ processes: int,
968
+ cases: { type: "array", items: { $ref: "#/$defs/caseReading" } },
969
+ importCost: orNull({
970
+ type: "object",
971
+ required: [
972
+ "runs",
973
+ "coldImportMs",
974
+ "medianMs",
975
+ "p95Ms",
976
+ "emptyImportMs",
977
+ "emptyMedianMs",
978
+ "files",
979
+ "totalBytes",
980
+ "totalGzipBytes"
981
+ ],
982
+ properties: {
983
+ runs: int,
984
+ coldImportMs: nums,
985
+ medianMs: num,
986
+ p95Ms: num,
987
+ emptyImportMs: nums,
988
+ emptyMedianMs: num,
989
+ files: {
990
+ type: "array",
991
+ items: {
992
+ type: "object",
993
+ required: ["path", "bytes", "gzipBytes"],
994
+ properties: { path: str, bytes: int, gzipBytes: int }
995
+ }
996
+ },
997
+ totalBytes: int,
998
+ totalGzipBytes: int
999
+ }
1000
+ })
1001
+ }
1002
+ },
1003
+ caseReading: {
1004
+ type: "object",
1005
+ required: [
1006
+ "benchmark",
1007
+ "size",
1008
+ "inputsPerPass",
1009
+ "bytesPerPass",
1010
+ "throughput",
1011
+ "latency",
1012
+ "allocations",
1013
+ "peak",
1014
+ "longRun"
1015
+ ],
1016
+ properties: {
1017
+ benchmark: str,
1018
+ size: str,
1019
+ inputsPerPass: int,
1020
+ bytesPerPass: numOrNull,
1021
+ throughput: measuredOrSkipped({
1022
+ type: "object",
1023
+ required: ["status", "processes", "medianMsPerOp", "opsPerSecond", "bytesPerSecond"],
1024
+ properties: {
1025
+ status: { const: "measured" },
1026
+ processes: {
1027
+ type: "array",
1028
+ items: {
1029
+ type: "object",
1030
+ required: ["samplesMsPerOp", "medianMsPerOp"],
1031
+ properties: { samplesMsPerOp: nums, medianMsPerOp: num }
1032
+ }
1033
+ },
1034
+ medianMsPerOp: num,
1035
+ opsPerSecond: num,
1036
+ bytesPerSecond: numOrNull
1037
+ }
1038
+ }),
1039
+ latency: orNull({
1040
+ type: "object",
1041
+ required: ["status", "calls", "timerOverheadNs", "p50Ns"],
1042
+ properties: {
1043
+ status: { enum: ["measured", "skipped"] },
1044
+ calls: int,
1045
+ timerOverheadNs: num,
1046
+ p50Ns: numOrNull,
1047
+ p95Ns: numOrNull,
1048
+ p99Ns: numOrNull,
1049
+ maxNs: num
1050
+ }
1051
+ }),
1052
+ allocations: orNull(
1053
+ measuredOrSkipped({
1054
+ type: "object",
1055
+ required: ["status", "windowOps", "heapBytesPerOp", "externalBytesPerOp"],
1056
+ properties: {
1057
+ status: { const: "measured" },
1058
+ windowOps: int,
1059
+ heapBytesPerOp: num,
1060
+ externalBytesPerOp: num
1061
+ }
1062
+ })
1063
+ ),
1064
+ peak: orNull(
1065
+ measuredOrSkipped({
1066
+ type: "object",
1067
+ required: ["status", "calls", "peakHeapBytes", "medianPeakHeapBytes"],
1068
+ properties: {
1069
+ status: { const: "measured" },
1070
+ calls: int,
1071
+ peakHeapBytes: num,
1072
+ medianPeakHeapBytes: num
1073
+ }
1074
+ })
1075
+ ),
1076
+ longRun: orNull(
1077
+ measuredOrSkipped({
1078
+ type: "object",
1079
+ required: ["status", "verdict", "ops", "opsPerRound", "settledHeap", "slopeBytesPerOp"],
1080
+ properties: {
1081
+ status: { const: "measured" },
1082
+ verdict: { enum: ["flat", "growing"] },
1083
+ ops: int,
1084
+ opsPerRound: int,
1085
+ settledHeap: nums,
1086
+ slopeBytesPerOp: num
1087
+ }
1088
+ })
1089
+ )
1090
+ }
1091
+ }
1092
+ }
1093
+ };
1094
+ function typeOf(value) {
1095
+ if (value === null) return "null";
1096
+ if (Array.isArray(value)) return "array";
1097
+ if (Number.isInteger(value)) return "integer";
1098
+ return typeof value;
1099
+ }
1100
+ function typeMatches(value, type) {
1101
+ const actual = typeOf(value);
1102
+ return actual === type || type === "number" && actual === "integer";
1103
+ }
1104
+ function resolveRef(ref, root) {
1105
+ const target = root.$defs?.[ref.replace("#/$defs/", "")];
1106
+ if (target === void 0) throw new Error(`schema has no definition ${ref}`);
1107
+ return target;
1108
+ }
1109
+ function validate(value, schema, root = schema, at = "") {
1110
+ const problems = [];
1111
+ const here = at === "" ? "/" : at;
1112
+ if (typeof schema.$ref === "string")
1113
+ return validate(value, resolveRef(schema.$ref, root), root, at);
1114
+ if ("const" in schema && value !== schema.const)
1115
+ problems.push(`${here}: must be ${JSON.stringify(schema.const)}`);
1116
+ if (schema.enum !== void 0 && !schema.enum.includes(value)) {
1117
+ problems.push(
1118
+ `${here}: must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(", ")}`
1119
+ );
1120
+ }
1121
+ if (schema.type !== void 0) {
1122
+ const types = typeof schema.type === "string" ? [schema.type] : schema.type;
1123
+ if (!types.some((t) => typeMatches(value, t))) {
1124
+ problems.push(`${here}: must be ${types.join(" or ")}, is ${typeOf(value)}`);
1125
+ return problems;
1126
+ }
1127
+ }
1128
+ if (typeof value === "number" && value < (schema.minimum ?? -Infinity)) {
1129
+ problems.push(`${here}: must be at least ${String(schema.minimum)}`);
1130
+ }
1131
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1132
+ const record = value;
1133
+ for (const key of schema.required ?? []) {
1134
+ if (!(key in record)) problems.push(`${here}: is missing "${key}"`);
1135
+ }
1136
+ for (const [key, sub] of Object.entries(schema.properties ?? {})) {
1137
+ if (key in record) problems.push(...validate(record[key], sub, root, `${at}/${key}`));
1138
+ }
1139
+ }
1140
+ if (Array.isArray(value) && schema.items !== void 0) {
1141
+ const items = schema.items;
1142
+ value.forEach((item, i) => problems.push(...validate(item, items, root, `${at}/${String(i)}`)));
1143
+ }
1144
+ if (schema.oneOf !== void 0) {
1145
+ const results = schema.oneOf.map((s) => validate(value, s, root, at));
1146
+ const passing = results.filter((r) => r.length === 0).length;
1147
+ if (passing !== 1) {
1148
+ problems.push(
1149
+ `${here}: must match exactly one of ${String(results.length)} shapes, matches ${String(passing)}`
1150
+ );
1151
+ problems.push(...results.reduce((a, b) => b.length < a.length ? b : a));
1152
+ }
1153
+ }
1154
+ return problems;
1155
+ }
1156
+ function readResults(text) {
1157
+ let value;
1158
+ try {
1159
+ value = JSON.parse(text);
1160
+ } catch {
1161
+ throw new TypeError("@cosyte/bench: the results are not JSON");
1162
+ }
1163
+ const version = value?.schemaVersion;
1164
+ if (version !== SCHEMA_VERSION) {
1165
+ throw new TypeError(
1166
+ `@cosyte/bench: results version ${String(version)} is not one this package reads (${String(SCHEMA_VERSION)})`
1167
+ );
1168
+ }
1169
+ const problems = validate(value, RESULTS_SCHEMA);
1170
+ if (problems.length > 0)
1171
+ throw new TypeError(
1172
+ `@cosyte/bench: the results do not match the schema:
1173
+ ${problems.join("\n")}`
1174
+ );
1175
+ return value;
1176
+ }
1177
+
1178
+ // src/main.ts
1179
+ var USAGE = `Usage:
1180
+ cosyte-bench run --suite <file> --target <file> [--processes <n>] [--no-memory] [--no-import]
1181
+ [--commit <sha>] [--only <name>]... [--out <file.json>] [--summary <file.md>]
1182
+ cosyte-bench compare --suite <file> --base <file> --head <file> [--base-commit <sha>]
1183
+ [--head-commit <sha>] [--only <name>]... [--out <file.json>] [--summary <file.md>]
1184
+ cosyte-bench calibrate --suite <file> --base <file> --head <file> --comparisons <n>
1185
+ [--inject <fraction>] [--only <name>]... [--out <file.json>] [--summary <file.md>]
1186
+ cosyte-bench check <results.json>
1187
+
1188
+ Exit codes: 0 clean, 1 a finding (a regression, or results that do not match the schema),
1189
+ 2 could not measure or could not read its input.
1190
+ `;
1191
+ var UsageError = class extends Error {
1192
+ };
1193
+ var VALUE_FLAGS = /* @__PURE__ */ new Set([
1194
+ "--suite",
1195
+ "--target",
1196
+ "--base",
1197
+ "--head",
1198
+ "--out",
1199
+ "--summary",
1200
+ "--commit",
1201
+ "--base-commit",
1202
+ "--head-commit",
1203
+ "--processes",
1204
+ "--comparisons",
1205
+ "--inject",
1206
+ "--only"
1207
+ ]);
1208
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["--no-memory", "--no-import"]);
1209
+ var FLAGS_FOR = {
1210
+ run: [
1211
+ "--suite",
1212
+ "--target",
1213
+ "--processes",
1214
+ "--no-memory",
1215
+ "--no-import",
1216
+ "--commit",
1217
+ "--only",
1218
+ "--out",
1219
+ "--summary"
1220
+ ],
1221
+ compare: [
1222
+ "--suite",
1223
+ "--base",
1224
+ "--head",
1225
+ "--base-commit",
1226
+ "--head-commit",
1227
+ "--only",
1228
+ "--out",
1229
+ "--summary"
1230
+ ],
1231
+ calibrate: [
1232
+ "--suite",
1233
+ "--base",
1234
+ "--head",
1235
+ "--comparisons",
1236
+ "--inject",
1237
+ "--only",
1238
+ "--out",
1239
+ "--summary"
1240
+ ]
1241
+ };
1242
+ function parseArgs(argv) {
1243
+ const values = /* @__PURE__ */ new Map();
1244
+ const flags = /* @__PURE__ */ new Set();
1245
+ const positional = [];
1246
+ for (let i = 0; i < argv.length; i++) {
1247
+ const arg = argv[i];
1248
+ if (VALUE_FLAGS.has(arg)) {
1249
+ const value = argv[i + 1];
1250
+ if (value === void 0 || value.startsWith("--"))
1251
+ throw new UsageError(`${arg} needs a value`);
1252
+ values.set(arg, [...values.get(arg) ?? [], value]);
1253
+ i++;
1254
+ } else if (BOOLEAN_FLAGS.has(arg)) {
1255
+ flags.add(arg);
1256
+ } else if (arg.startsWith("--")) {
1257
+ throw new UsageError(`unknown flag ${arg}`);
1258
+ } else {
1259
+ positional.push(arg);
1260
+ }
1261
+ }
1262
+ return { values, flags, positional };
1263
+ }
1264
+ function one(args, flag) {
1265
+ const all = args.values.get(flag);
1266
+ if (all !== void 0 && all.length > 1) throw new UsageError(`${flag} given more than once`);
1267
+ return all?.[0];
1268
+ }
1269
+ function required(args, flag) {
1270
+ const value = one(args, flag);
1271
+ if (value === void 0) throw new UsageError(`${flag} is required`);
1272
+ return value;
1273
+ }
1274
+ function count(args, flag, fallback) {
1275
+ const raw = one(args, flag);
1276
+ if (raw === void 0) return fallback;
1277
+ const n = Number(raw);
1278
+ if (!Number.isInteger(n) || n < 1) throw new UsageError(`${flag} must be a positive integer`);
1279
+ return n;
1280
+ }
1281
+ function fraction(args, flag) {
1282
+ const raw = one(args, flag);
1283
+ if (raw === void 0) return 0;
1284
+ const n = Number(raw);
1285
+ if (!Number.isFinite(n) || n < 0 || n > 1)
1286
+ throw new UsageError(`${flag} must be a fraction from 0 to 1`);
1287
+ return n;
1288
+ }
1289
+ async function loadSuite(file) {
1290
+ const module = await import(pathToFileURL(file).href);
1291
+ return checkSuite(module.default);
1292
+ }
1293
+ async function main(argv, io) {
1294
+ const [command, ...rest] = argv;
1295
+ try {
1296
+ if (command === void 0 || command === "--help" || command === "help") {
1297
+ io.stdout(USAGE);
1298
+ return command === void 0 ? 2 : 0;
1299
+ }
1300
+ const args = parseArgs(rest);
1301
+ const abs = (p) => resolve(io.cwd, p);
1302
+ const rel = (p) => relative(io.cwd, abs(p));
1303
+ const progress = (line) => {
1304
+ io.stderr(`[cosyte-bench] ${line}
1305
+ `);
1306
+ };
1307
+ const finish = (doc, code) => {
1308
+ const out = one(args, "--out");
1309
+ const summary = one(args, "--summary");
1310
+ const markdown = renderMarkdown(doc);
1311
+ if (out !== void 0) writeFileSync(abs(out), `${JSON.stringify(doc, null, 2)}
1312
+ `);
1313
+ if (summary !== void 0) writeFileSync(abs(summary), markdown);
1314
+ io.stdout(markdown);
1315
+ return code;
1316
+ };
1317
+ const overrides = { runJob: io.runJob, onProgress: progress };
1318
+ if (command === "check") {
1319
+ if (args.values.size > 0 || args.flags.size > 0) throw new UsageError("check takes no flags");
1320
+ const file = args.positional[0];
1321
+ if (file === void 0) throw new UsageError("check needs a results file");
1322
+ let text;
1323
+ try {
1324
+ text = String(readFileSync(abs(file)));
1325
+ } catch {
1326
+ io.stderr(`[cosyte-bench] cannot read ${rel(file)}
1327
+ `);
1328
+ return 2;
1329
+ }
1330
+ try {
1331
+ const doc = readResults(text);
1332
+ io.stdout(
1333
+ `${rel(file)}: a valid ${doc.kind} document, schema version ${String(doc.schemaVersion)}
1334
+ `
1335
+ );
1336
+ return 0;
1337
+ } catch (error) {
1338
+ io.stderr(`[cosyte-bench] ${rel(file)}: ${error.message}
1339
+ `);
1340
+ return 1;
1341
+ }
1342
+ }
1343
+ if (command !== "run" && command !== "compare" && command !== "calibrate") {
1344
+ throw new UsageError(`unknown command ${command}`);
1345
+ }
1346
+ for (const flag of [...args.values.keys(), ...args.flags]) {
1347
+ if (!FLAGS_FOR[command].includes(flag))
1348
+ throw new UsageError(`${flag} does not apply to ${command}`);
1349
+ }
1350
+ if (args.positional.length > 0)
1351
+ throw new UsageError(`unexpected argument ${String(args.positional[0])}`);
1352
+ const suiteFile = abs(required(args, "--suite"));
1353
+ const only = args.values.get("--only") ?? [];
1354
+ if (command === "run") {
1355
+ const target = required(args, "--target");
1356
+ const processes = count(args, "--processes", 3);
1357
+ const suite = await loadSuite(suiteFile);
1358
+ const reading = await readBuild({
1359
+ suiteFile,
1360
+ suite,
1361
+ target: abs(target),
1362
+ only,
1363
+ processes,
1364
+ memory: !args.flags.has("--no-memory"),
1365
+ importCost: !args.flags.has("--no-import"),
1366
+ ...overrides,
1367
+ measureImport: io.measureImport
1368
+ });
1369
+ const commit = one(args, "--commit") ?? io.env["GITHUB_SHA"] ?? null;
1370
+ return finish(
1371
+ {
1372
+ ...documentBase({ name: suite.name, file: rel(suiteFile) }, io.environment(), io.now()),
1373
+ kind: "reading",
1374
+ subject: { target: rel(target), commit },
1375
+ reading
1376
+ },
1377
+ 0
1378
+ );
1379
+ }
1380
+ {
1381
+ const base = required(args, "--base");
1382
+ const head = required(args, "--head");
1383
+ const comparisons = count(args, "--comparisons", 0);
1384
+ if (command === "calibrate" && comparisons === 0)
1385
+ throw new UsageError("--comparisons is required");
1386
+ const inject = fraction(args, "--inject");
1387
+ const suite = await loadSuite(suiteFile);
1388
+ const common = {
1389
+ suiteFile,
1390
+ suite,
1391
+ base: abs(base),
1392
+ head: abs(head),
1393
+ only,
1394
+ inject,
1395
+ ...overrides
1396
+ };
1397
+ const sides = {
1398
+ base: { target: rel(base), commit: one(args, "--base-commit") ?? null },
1399
+ head: { target: rel(head), commit: one(args, "--head-commit") ?? null }
1400
+ };
1401
+ const docBase = documentBase(
1402
+ { name: suite.name, file: rel(suiteFile) },
1403
+ io.environment(),
1404
+ io.now()
1405
+ );
1406
+ if (command === "compare") {
1407
+ const comparison = await compareBuilds(common);
1408
+ return finish(
1409
+ { ...docBase, kind: "comparison", ...sides, comparison },
1410
+ comparisonExitCode(comparison)
1411
+ );
1412
+ }
1413
+ const calibration = await calibrateComparison({ ...common, comparisons });
1414
+ return finish({ ...docBase, kind: "calibration", ...sides, calibration }, 0);
1415
+ }
1416
+ } catch (error) {
1417
+ if (error instanceof UsageError) {
1418
+ io.stderr(`[cosyte-bench] ${error.message}
1419
+
1420
+ ${USAGE}`);
1421
+ return 2;
1422
+ }
1423
+ io.stderr(`[cosyte-bench] ${error instanceof Error ? error.message : String(error)}
1424
+ `);
1425
+ return 2;
1426
+ }
1427
+ }
1428
+
1429
+ // src/cli.ts
1430
+ process.exitCode = await main(process.argv.slice(2), {
1431
+ stdout: (text) => process.stdout.write(text),
1432
+ stderr: (text) => process.stderr.write(text),
1433
+ cwd: process.cwd(),
1434
+ env: process.env,
1435
+ runJob: (job) => runInChild(job),
1436
+ measureImport: (target) => measureImportCost(target),
1437
+ environment: () => environment(process.env),
1438
+ now: () => /* @__PURE__ */ new Date()
1439
+ });
1440
+ //# sourceMappingURL=cli.mjs.map
1441
+ //# sourceMappingURL=cli.mjs.map