@decohere/ci-tools 0.0.1-dev.109 → 0.0.1-dev.110
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/package.json +1 -1
- package/src/bench/bench.mjs +129 -373
- package/src/bench/file-regressions.mjs +75 -0
- package/src/bench/history.mjs +225 -0
- package/src/bench/regression-issue.mjs +51 -0
- package/src/pages/artifact.mjs +38 -183
- package/src/pages/bench-page.mjs +313 -0
- package/src/pages/restore-badges.mjs +58 -0
- package/src/pages/restore-bench-history.mjs +15 -28
- package/tests/bench/history.test.mjs +251 -0
- package/tests/bench/regression-issue.test.mjs +50 -0
- package/tests/pages/bench-page.test.mjs +106 -0
- package/src/pages/keep-prior-bench-badge.mjs +0 -38
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark history: the results of each `pnpm bench` run, kept per machine so runs are only
|
|
3
|
+
* compared like for like (#660). Pure functions over plain data; bench.mjs does the I/O.
|
|
4
|
+
*
|
|
5
|
+
* Shape (version 2):
|
|
6
|
+
* { version: 2, contexts: { [contextId]: { context, runs: [{ date, sha, results }] } } }
|
|
7
|
+
* newest run first. A result is { id, label, unit, median, p90?, min, max, samples, files? }.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
|
|
12
|
+
export const HISTORY_VERSION = 2;
|
|
13
|
+
/** Nightly runs: a month per machine, enough for a trend and a regression baseline. */
|
|
14
|
+
export const MAX_RUNS = 30;
|
|
15
|
+
/** Printed by each suite before its JSON array of results (projects/flux/bench/harness.ts). */
|
|
16
|
+
export const RESULT_PREFIX = "BENCH_RESULT ";
|
|
17
|
+
|
|
18
|
+
/** The machine a run happened on. Runs are compared only within one context. */
|
|
19
|
+
export function getContext(os, env, nodeVersion, date = new Date()) {
|
|
20
|
+
const cpus = os.cpus();
|
|
21
|
+
const nodeMajor = nodeVersion.match(/^v(\d+)/)?.[1] ?? "?";
|
|
22
|
+
const rawEnv = env.BENCH_ENV ?? null;
|
|
23
|
+
return {
|
|
24
|
+
benchEnv: rawEnv ? `${rawEnv}-node${nodeMajor}` : "(local)",
|
|
25
|
+
date: date.toISOString(),
|
|
26
|
+
os: { platform: os.platform(), arch: os.arch() },
|
|
27
|
+
cpu: { model: cpus[0]?.model?.trim() ?? "unknown", count: cpus.length },
|
|
28
|
+
memoryGb: Number.parseFloat((os.totalmem() / 1024 ** 3).toFixed(1)),
|
|
29
|
+
node: nodeVersion,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 16-char SHA-256 prefix of platform|arch|cpu|cores|memGB|node. */
|
|
34
|
+
export function contextId(ctx) {
|
|
35
|
+
const key = [
|
|
36
|
+
ctx.os.platform,
|
|
37
|
+
ctx.os.arch,
|
|
38
|
+
ctx.cpu.model,
|
|
39
|
+
ctx.cpu.count,
|
|
40
|
+
ctx.memoryGb,
|
|
41
|
+
ctx.node,
|
|
42
|
+
].join("|");
|
|
43
|
+
return crypto.createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The results a suite printed, from its whole output. Throws if it printed none. */
|
|
47
|
+
export function parseResults(output, suite) {
|
|
48
|
+
const line = output
|
|
49
|
+
.split(/\r?\n/)
|
|
50
|
+
.reverse()
|
|
51
|
+
.find((candidate) => candidate.startsWith(RESULT_PREFIX));
|
|
52
|
+
if (line == null)
|
|
53
|
+
throw new Error(`suite "${suite}" printed no ${RESULT_PREFIX.trim()} line`);
|
|
54
|
+
return JSON.parse(line.slice(RESULT_PREFIX.length));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const FORGE_BENCHMARKS = [
|
|
58
|
+
["forge build es6 + types", "forge.build.local"],
|
|
59
|
+
["forge build es6 only", "forge.build.debug"],
|
|
60
|
+
["forge build types only", "forge.build.types-only"],
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Results from `vitest bench --outputJson` for the forge build benchmark. Its names end in a
|
|
65
|
+
* file count, so they are mapped to stable ids; vitest reports no p90, so it is left out.
|
|
66
|
+
*/
|
|
67
|
+
export function fromVitest(vitestOutput) {
|
|
68
|
+
return vitestOutput.files
|
|
69
|
+
.flatMap((file) => file.groups)
|
|
70
|
+
.flatMap((group) => group.benchmarks)
|
|
71
|
+
.map((benchmark) => {
|
|
72
|
+
const id = FORGE_BENCHMARKS.find(([prefix]) => benchmark.name.startsWith(prefix))?.[1];
|
|
73
|
+
if (id == null) throw new Error(`unknown forge benchmark "${benchmark.name}"`);
|
|
74
|
+
const files = Number(benchmark.name.match(/\((\d+) files\)/)?.[1]);
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
label: benchmark.name.replace(/\s*\(\d+ files\)$/, ""),
|
|
78
|
+
unit: "ms",
|
|
79
|
+
median: benchmark.median ?? benchmark.mean,
|
|
80
|
+
min: benchmark.min,
|
|
81
|
+
max: benchmark.max,
|
|
82
|
+
samples: benchmark.sampleCount,
|
|
83
|
+
...(Number.isFinite(files) ? { files } : {}),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Throws when a result has no usable numbers: a suite that errored mid-run, not a slow one. */
|
|
89
|
+
export function assertValid(results) {
|
|
90
|
+
const invalid = results.filter(
|
|
91
|
+
(result) => !Number.isFinite(result.median) || !(result.samples > 0),
|
|
92
|
+
);
|
|
93
|
+
if (invalid.length > 0) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${invalid.length} benchmark(s) have no valid samples: ${invalid.map((r) => r.id).join(", ")}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reads a stored history. Version 1 (forge build only, raw vitest output) is converted so its
|
|
102
|
+
* trend carries on; anything unrecognised starts empty rather than mixing formats.
|
|
103
|
+
*/
|
|
104
|
+
export function normalize(raw) {
|
|
105
|
+
if (raw?.version === HISTORY_VERSION && raw.contexts != null) return raw;
|
|
106
|
+
const contexts = {};
|
|
107
|
+
for (const [id, entry] of Object.entries(raw?.runs ?? {})) {
|
|
108
|
+
const runs = (entry.runs ?? [])
|
|
109
|
+
.filter((run) => run.vitestOutput != null)
|
|
110
|
+
.map((run) => ({ date: run.date, sha: null, results: fromVitest(run.vitestOutput) }));
|
|
111
|
+
if (runs.length > 0) contexts[id] = { context: entry.context, runs };
|
|
112
|
+
}
|
|
113
|
+
return { version: HISTORY_VERSION, contexts };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The previous run for this context, if any: what a new run is compared against. */
|
|
117
|
+
export function previousRun(history, id) {
|
|
118
|
+
return history.contexts[id]?.runs[0];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A new history with `run` first in its context, trimmed to `max` runs. Does not mutate. */
|
|
122
|
+
export function appendRun(history, ctx, run, max = MAX_RUNS) {
|
|
123
|
+
const id = contextId(ctx);
|
|
124
|
+
const runs = [run, ...(history.contexts[id]?.runs ?? [])].slice(0, max);
|
|
125
|
+
return {
|
|
126
|
+
version: HISTORY_VERSION,
|
|
127
|
+
contexts: { ...history.contexts, [id]: { context: ctx, runs } },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One row per current result, with the change in median against the previous run. */
|
|
132
|
+
export function compare(results, previous) {
|
|
133
|
+
const before = new Map((previous?.results ?? []).map((result) => [result.id, result]));
|
|
134
|
+
return results.map((result) => {
|
|
135
|
+
const old = before.get(result.id);
|
|
136
|
+
if (old == null) return { id: result.id, current: result.median };
|
|
137
|
+
const delta = result.median - old.median;
|
|
138
|
+
return {
|
|
139
|
+
id: result.id,
|
|
140
|
+
current: result.median,
|
|
141
|
+
previous: old.median,
|
|
142
|
+
delta,
|
|
143
|
+
percent: old.median === 0 ? 0 : (delta / old.median) * 100,
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The comparison as an aligned text table. */
|
|
149
|
+
export function formatComparison(rows) {
|
|
150
|
+
const ms = (value) => (value == null ? "n/a" : `${value.toFixed(1)} ms`);
|
|
151
|
+
const signed = (value, suffix) =>
|
|
152
|
+
value == null ? "n/a" : `${value >= 0 ? "+" : ""}${value.toFixed(1)}${suffix}`;
|
|
153
|
+
const table = [
|
|
154
|
+
["Benchmark", "Median", "Previous", "Delta", "%"],
|
|
155
|
+
...rows.map((row) => [
|
|
156
|
+
row.id,
|
|
157
|
+
ms(row.current),
|
|
158
|
+
ms(row.previous),
|
|
159
|
+
signed(row.delta, " ms"),
|
|
160
|
+
signed(row.percent, "%"),
|
|
161
|
+
]),
|
|
162
|
+
];
|
|
163
|
+
const widths = table[0].map((_, column) =>
|
|
164
|
+
Math.max(...table.map((row) => row[column].length)),
|
|
165
|
+
);
|
|
166
|
+
const line = (row) =>
|
|
167
|
+
row
|
|
168
|
+
.map((cell, column) =>
|
|
169
|
+
(column === 0 ? cell.padEnd : cell.padStart).call(cell, widths[column]),
|
|
170
|
+
)
|
|
171
|
+
.join(" | ");
|
|
172
|
+
return [
|
|
173
|
+
line(table[0]),
|
|
174
|
+
widths.map((w) => "-".repeat(w)).join("-+-"),
|
|
175
|
+
...table.slice(1).map(line),
|
|
176
|
+
].join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* When a benchmark counts as slower (#660): its median beat the baseline by more than
|
|
181
|
+
* `threshold` on each of the last `confirmRuns` runs, where the baseline is the median of the
|
|
182
|
+
* `baselineRuns` runs before those. Two nights in a row, so one noisy runner files nothing.
|
|
183
|
+
*/
|
|
184
|
+
export const REGRESSION = { threshold: 0.2, confirmRuns: 2, baselineRuns: 7, minBaseline: 3 };
|
|
185
|
+
|
|
186
|
+
function medianOf(values) {
|
|
187
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
188
|
+
const middle = Math.floor(sorted.length / 2);
|
|
189
|
+
return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The benchmarks that regressed on one machine, given its runs newest first. */
|
|
193
|
+
export function findRegressions(runs, options = REGRESSION) {
|
|
194
|
+
const recent = runs.slice(0, options.confirmRuns);
|
|
195
|
+
const before = runs.slice(options.confirmRuns, options.confirmRuns + options.baselineRuns);
|
|
196
|
+
if (recent.length < options.confirmRuns) return [];
|
|
197
|
+
const medianIn = (run, id) => run.results.find((result) => result.id === id)?.median;
|
|
198
|
+
const regressions = [];
|
|
199
|
+
for (const latest of recent[0].results) {
|
|
200
|
+
const baselineValues = before
|
|
201
|
+
.map((run) => medianIn(run, latest.id))
|
|
202
|
+
.filter((value) => Number.isFinite(value));
|
|
203
|
+
if (baselineValues.length < options.minBaseline) continue;
|
|
204
|
+
const baseline = medianOf(baselineValues);
|
|
205
|
+
const recentValues = recent.map((run) => medianIn(run, latest.id));
|
|
206
|
+
const slower = recentValues.every(
|
|
207
|
+
(value) => Number.isFinite(value) && value > baseline * (1 + options.threshold),
|
|
208
|
+
);
|
|
209
|
+
if (!slower) continue;
|
|
210
|
+
regressions.push({
|
|
211
|
+
id: latest.id,
|
|
212
|
+
label: latest.label,
|
|
213
|
+
baseline,
|
|
214
|
+
percent: (recentValues[0] / baseline - 1) * 100,
|
|
215
|
+
recent: recent.map((run, index) => ({
|
|
216
|
+
date: run.date,
|
|
217
|
+
sha: run.sha,
|
|
218
|
+
median: recentValues[index],
|
|
219
|
+
})),
|
|
220
|
+
// The change landed between the newest baseline run and the oldest slow one.
|
|
221
|
+
lastGood: before[0] == null ? null : { date: before[0].date, sha: before[0].sha },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return regressions;
|
|
225
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub issue for a benchmark that got slower (#660): one open issue per benchmark,
|
|
3
|
+
* found again by its exact title and updated in place while the regression lasts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const PAGES_URL = "https://typesmiths.github.io/decohere/benchmark.html";
|
|
7
|
+
|
|
8
|
+
export function issueTitle(regression) {
|
|
9
|
+
return `perf(bench): ${regression.id} is slower`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function ms(value) {
|
|
13
|
+
return `${value.toFixed(1)} ms`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function commitLink(repoUrl, sha) {
|
|
17
|
+
return sha == null ? "unknown" : `[\`${sha.slice(0, 7)}\`](${repoUrl}/commit/${sha})`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function issueBody(regression, { repoUrl, runUrl, machine, threshold }) {
|
|
21
|
+
const newest = regression.recent[0];
|
|
22
|
+
const oldestSlow = regression.recent.at(-1);
|
|
23
|
+
const range =
|
|
24
|
+
regression.lastGood?.sha != null && newest.sha != null
|
|
25
|
+
? `[${regression.lastGood.sha.slice(0, 7)}...${newest.sha.slice(0, 7)}](${repoUrl}/compare/${regression.lastGood.sha}...${newest.sha})`
|
|
26
|
+
: "unknown (no baseline commit recorded)";
|
|
27
|
+
return [
|
|
28
|
+
"## Problem",
|
|
29
|
+
"",
|
|
30
|
+
`The nightly benchmark **${regression.label}** (\`${regression.id}\`) has a median ${regression.percent.toFixed(0)}% above its baseline, on each of the last ${regression.recent.length} nightly runs. A benchmark counts as slower above ${Math.round(threshold * 100)}%.`,
|
|
31
|
+
"",
|
|
32
|
+
`- Baseline (median of the runs before): **${ms(regression.baseline)}**`,
|
|
33
|
+
...regression.recent.map(
|
|
34
|
+
(run) =>
|
|
35
|
+
`- ${run.date.slice(0, 10)}, ${commitLink(repoUrl, run.sha)}: **${ms(run.median)}**`,
|
|
36
|
+
),
|
|
37
|
+
`- Machine: ${machine}`,
|
|
38
|
+
"",
|
|
39
|
+
`The change landed after the last normal run (${regression.lastGood?.date.slice(0, 10) ?? "unknown"}) and by ${oldestSlow.date.slice(0, 10)}: commits ${range}.`,
|
|
40
|
+
"",
|
|
41
|
+
"## What to do",
|
|
42
|
+
"",
|
|
43
|
+
"1. Check the trend on the [benchmark page](" +
|
|
44
|
+
PAGES_URL +
|
|
45
|
+
"). A step that holds is a real change; a single machine type jumping may be the runner.",
|
|
46
|
+
"2. Reproduce locally: check out the last normal commit, `pnpm bench:save`, then the newest commit, `pnpm bench`, and read the comparison.",
|
|
47
|
+
"3. Fix the cause, or if the slowdown is intended, say why here and close the issue.",
|
|
48
|
+
"",
|
|
49
|
+
`Updated by the nightly bench workflow: ${runUrl}`,
|
|
50
|
+
].join("\n");
|
|
51
|
+
}
|
package/src/pages/artifact.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { normalize } from "../bench/history.mjs";
|
|
5
|
+
import { buildBenchmarkHtml, buildStylesCss } from "./bench-page.mjs";
|
|
3
6
|
|
|
4
7
|
const branch = process.env.GITHUB_REF_NAME ?? "local";
|
|
5
8
|
const sha = process.env.GITHUB_SHA ?? "local";
|
|
@@ -8,201 +11,53 @@ const outputDir = process.env.PAGES_OUTPUT_DIR ?? "site";
|
|
|
8
11
|
const badgesDir = "projects/ci-tools/dist/ci/badges";
|
|
9
12
|
const benchHistoryPath = "projects/ci-tools/dist/ci/bench/bench-history.json";
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
function averageBenchmarks(runs) {
|
|
25
|
-
const first = runs[0]?.vitestOutput;
|
|
26
|
-
if (first == null) return [];
|
|
27
|
-
|
|
28
|
-
const latestBenchmarks = first.files
|
|
29
|
-
.flatMap((file) => file.groups)
|
|
30
|
-
.flatMap((group) => group.benchmarks);
|
|
31
|
-
|
|
32
|
-
return latestBenchmarks.map((benchmark) => {
|
|
33
|
-
const peers = runs
|
|
34
|
-
.flatMap((run) => run.vitestOutput?.files ?? [])
|
|
35
|
-
.flatMap((file) => file.groups ?? [])
|
|
36
|
-
.flatMap((group) => group.benchmarks ?? [])
|
|
37
|
-
.filter((candidate) => candidate.name === benchmark.name);
|
|
38
|
-
|
|
39
|
-
const averaged = { name: benchmark.name };
|
|
40
|
-
for (const field of numericFields) {
|
|
41
|
-
const values = peers
|
|
42
|
-
.map((peer) => peer[field])
|
|
43
|
-
.filter((value) => typeof value === "number");
|
|
44
|
-
if (values.length > 0) {
|
|
45
|
-
averaged[field] = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return averaged;
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function buildBenchmarkHtml(benchHistory) {
|
|
54
|
-
const contexts = Object.entries(benchHistory.runs ?? {})
|
|
55
|
-
.filter(([, entry]) => Array.isArray(entry.runs) && entry.runs.length > 0)
|
|
56
|
-
.sort((a, b) => new Date(b[1].runs[0].date) - new Date(a[1].runs[0].date));
|
|
57
|
-
|
|
58
|
-
if (contexts.length === 0) {
|
|
59
|
-
return `<!doctype html>
|
|
60
|
-
<html lang="en">
|
|
61
|
-
<head>
|
|
62
|
-
<meta charset="utf-8" />
|
|
63
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
64
|
-
<title>Ark - Benchmarks</title>
|
|
65
|
-
<link rel="stylesheet" href="./styles.css" />
|
|
66
|
-
</head>
|
|
67
|
-
<body>
|
|
68
|
-
<main class="container">
|
|
69
|
-
<header class="hero">
|
|
70
|
-
<h1>Benchmarks</h1>
|
|
71
|
-
<p class="note">No benchmark history is available yet.</p>
|
|
72
|
-
</header>
|
|
73
|
-
</main>
|
|
74
|
-
</body>
|
|
75
|
-
</html>
|
|
76
|
-
`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const rows = [];
|
|
80
|
-
for (const [, entry] of contexts) {
|
|
81
|
-
const benchmarks = averageBenchmarks(entry.runs);
|
|
82
|
-
const ctx = entry.context;
|
|
83
|
-
const environment = `${ctx.benchEnv ?? "unknown"}-${ctx.os.arch}`;
|
|
84
|
-
for (const benchmark of benchmarks) {
|
|
85
|
-
rows.push(`<tr>
|
|
86
|
-
<td>${environment}</td>
|
|
87
|
-
<td><code>${benchmark.name}</code></td>
|
|
88
|
-
<td>${fmtMs(benchmark.mean)}</td>
|
|
89
|
-
<td>${typeof benchmark.rme === "number" ? `±${benchmark.rme.toFixed(1)}%` : "-"}</td>
|
|
90
|
-
<td>${fmtMs(benchmark.p99)}</td>
|
|
91
|
-
<td>${fmtHz(benchmark.hz)}</td>
|
|
92
|
-
<td>${typeof benchmark.sampleCount === "number" ? Math.round(benchmark.sampleCount) : "-"}</td>
|
|
93
|
-
</tr>`);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const details = contexts
|
|
98
|
-
.map(([, entry]) => {
|
|
99
|
-
const ctx = entry.context;
|
|
100
|
-
return `<li><strong>${ctx.benchEnv ?? "unknown"}-${ctx.os.arch}</strong> - ${ctx.os.platform} - ${ctx.cpu.model} x ${ctx.cpu.count} - ${ctx.memoryGb} GB - Node ${ctx.node} - rolling mean of ${entry.runs.length} run(s)</li>`;
|
|
101
|
-
})
|
|
102
|
-
.join("\n");
|
|
103
|
-
|
|
104
|
-
const updatedDate = contexts[0][1].runs[0].date.slice(0, 10);
|
|
105
|
-
|
|
106
|
-
return `<!doctype html>
|
|
107
|
-
<html lang="en">
|
|
108
|
-
<head>
|
|
109
|
-
<meta charset="utf-8" />
|
|
110
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
111
|
-
<title>Ark - Benchmarks</title>
|
|
112
|
-
<link rel="stylesheet" href="./styles.css" />
|
|
113
|
-
</head>
|
|
114
|
-
<body>
|
|
115
|
-
<main class="container">
|
|
116
|
-
<div class="hero">
|
|
117
|
-
<h1>Benchmarks</h1>
|
|
118
|
-
<p class="note">Updated ${updatedDate} · Branch: ${branch} · Commit: ${sha.slice(0, 12)}</p>
|
|
119
|
-
</div>
|
|
120
|
-
<section class="section">
|
|
121
|
-
<h2>Performance Table</h2>
|
|
122
|
-
<table>
|
|
123
|
-
<thead>
|
|
124
|
-
<tr>
|
|
125
|
-
<th>Environment</th>
|
|
126
|
-
<th>Benchmark</th>
|
|
127
|
-
<th>Mean</th>
|
|
128
|
-
<th>±RME</th>
|
|
129
|
-
<th>P99</th>
|
|
130
|
-
<th>Ops/s</th>
|
|
131
|
-
<th>Runs</th>
|
|
132
|
-
</tr>
|
|
133
|
-
</thead>
|
|
134
|
-
<tbody>
|
|
135
|
-
${rows.join("\n")}
|
|
136
|
-
</tbody>
|
|
137
|
-
</table>
|
|
138
|
-
</section>
|
|
139
|
-
<section class="section">
|
|
140
|
-
<h2>Environment Notes</h2>
|
|
141
|
-
<ul>
|
|
142
|
-
${details}
|
|
143
|
-
</ul>
|
|
144
|
-
</section>
|
|
145
|
-
</main>
|
|
146
|
-
</body>
|
|
147
|
-
</html>
|
|
148
|
-
`;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function buildStylesCss() {
|
|
152
|
-
return `:root {
|
|
153
|
-
--bg: #f4f7f3;
|
|
154
|
-
--surface: #ffffff;
|
|
155
|
-
--text: #132b24;
|
|
156
|
-
--muted: #4f6f65;
|
|
157
|
-
--accent: #0f9d72;
|
|
158
|
-
--accent-strong: #0d7f5c;
|
|
159
|
-
--border: #d6e3dd;
|
|
160
|
-
--shadow: 0 14px 40px rgba(12, 58, 45, 0.08);
|
|
161
|
-
}
|
|
162
|
-
* { box-sizing: border-box; }
|
|
163
|
-
body { margin: 0; font-family: "Segoe UI", "Helvetica Neue", sans-serif; color: var(--text); background: radial-gradient(circle at top right, #dbefe7, var(--bg) 40%); }
|
|
164
|
-
a { color: var(--accent-strong); }
|
|
165
|
-
.container { max-width: 1100px; margin: 0 auto; padding: 28px 18px 60px; }
|
|
166
|
-
.hero { background: linear-gradient(145deg, #183a31, #0e7f5c); color: #f3fff8; padding: 26px; border-radius: 18px; box-shadow: var(--shadow); }
|
|
167
|
-
.hero h1 { margin: 6px 0 10px; }
|
|
168
|
-
.note { color: #d8f5eb; margin: 0; }
|
|
169
|
-
.section { margin-top: 26px; }
|
|
170
|
-
table { width: 100%; border-collapse: collapse; background: var(--surface); border-radius: 12px; overflow: hidden; }
|
|
171
|
-
th, td { border-bottom: 1px solid var(--border); padding: 9px 10px; text-align: left; }
|
|
172
|
-
th { color: var(--muted); font-weight: 600; background: #f3faf7; }
|
|
173
|
-
td code { color: var(--accent-strong); }
|
|
174
|
-
@media (max-width: 700px) { .hero { padding: 20px; } .container { padding: 18px 12px 40px; } }
|
|
175
|
-
`;
|
|
14
|
+
/** Every badge file under `dir`, relative to it: the manifest restore-badges.mjs reads back. */
|
|
15
|
+
async function listBadges(dir, prefix = "") {
|
|
16
|
+
const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
|
|
17
|
+
const nested = await Promise.all(
|
|
18
|
+
entries.map((entry) =>
|
|
19
|
+
entry.isDirectory()
|
|
20
|
+
? listBadges(dir, path.posix.join(prefix, entry.name))
|
|
21
|
+
: entry.name.endsWith(".svg")
|
|
22
|
+
? [path.posix.join(prefix, entry.name)]
|
|
23
|
+
: [],
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
return nested.flat().sort();
|
|
176
27
|
}
|
|
177
28
|
|
|
178
29
|
await rm(outputDir, { recursive: true, force: true });
|
|
179
|
-
await mkdir(outputDir, { recursive: true });
|
|
30
|
+
await mkdir(path.join(outputDir, "data"), { recursive: true });
|
|
180
31
|
|
|
181
32
|
await writeFile(path.join(outputDir, "styles.css"), buildStylesCss());
|
|
182
33
|
|
|
183
34
|
// index.html redirects to benchmark.html — no separate dashboard needed
|
|
184
35
|
await writeFile(
|
|
185
36
|
path.join(outputDir, "index.html"),
|
|
186
|
-
`<!doctype html><html><head><meta charset="utf-8"/><meta http-equiv="refresh" content="0;url=./benchmark.html"/><title>
|
|
37
|
+
`<!doctype html><html><head><meta charset="utf-8"/><meta http-equiv="refresh" content="0;url=./benchmark.html"/><title>Decohere Pages</title></head><body><p><a href="./benchmark.html">Benchmarks</a></p></body></html>\n`,
|
|
187
38
|
);
|
|
188
39
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
await
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
40
|
+
// No history yet (first deploy, or cleared) publishes the empty page, not a failure.
|
|
41
|
+
const benchHistory = normalize(
|
|
42
|
+
existsSync(benchHistoryPath) ? JSON.parse(await readFile(benchHistoryPath, "utf8")) : null,
|
|
43
|
+
);
|
|
44
|
+
await writeFile(
|
|
45
|
+
path.join(outputDir, "benchmark.html"),
|
|
46
|
+
buildBenchmarkHtml(benchHistory, { branch, sha }),
|
|
47
|
+
);
|
|
48
|
+
await writeFile(
|
|
49
|
+
path.join(outputDir, "data", "bench-history.json"),
|
|
50
|
+
`${JSON.stringify(benchHistory, null, 2)}\n`,
|
|
51
|
+
);
|
|
201
52
|
|
|
202
|
-
|
|
53
|
+
// Badges are generated by earlier steps (CI status, coverage) or restored from the live site.
|
|
54
|
+
if (existsSync(badgesDir)) {
|
|
203
55
|
await cp(badgesDir, path.join(outputDir, "badges"), { recursive: true });
|
|
204
|
-
|
|
205
|
-
|
|
56
|
+
const badges = await listBadges(badgesDir);
|
|
57
|
+
await writeFile(
|
|
58
|
+
path.join(outputDir, "badges", "manifest.json"),
|
|
59
|
+
`${JSON.stringify({ badges }, null, 2)}\n`,
|
|
60
|
+
);
|
|
206
61
|
}
|
|
207
62
|
|
|
208
63
|
await writeFile(path.join(outputDir, ".nojekyll"), "");
|