@otto-code/brain 0.7.5
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/bin/otto-brain +2 -0
- package/dist/bench/context-corpus.d.ts +58 -0
- package/dist/bench/context-corpus.js +210 -0
- package/dist/bench/corpus.d.ts +31 -0
- package/dist/bench/corpus.js +379 -0
- package/dist/bench/curated-repos.d.ts +35 -0
- package/dist/bench/curated-repos.js +41 -0
- package/dist/bench/health.d.ts +50 -0
- package/dist/bench/health.js +112 -0
- package/dist/bench/index.d.ts +103 -0
- package/dist/bench/index.js +209 -0
- package/dist/bench/mine.d.ts +32 -0
- package/dist/bench/mine.js +101 -0
- package/dist/bench/repo-task.d.ts +93 -0
- package/dist/bench/repo-task.js +380 -0
- package/dist/bench/repo.d.ts +125 -0
- package/dist/bench/repo.js +195 -0
- package/dist/bench/rescore.d.ts +41 -0
- package/dist/bench/rescore.js +127 -0
- package/dist/bench/tasks.d.ts +124 -0
- package/dist/bench/tasks.js +1249 -0
- package/dist/bench/verify.d.ts +142 -0
- package/dist/bench/verify.js +350 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +72 -0
- package/dist/commands/bench.d.ts +20 -0
- package/dist/commands/bench.js +222 -0
- package/dist/commands/calibrate.d.ts +19 -0
- package/dist/commands/calibrate.js +75 -0
- package/dist/commands/catalog.d.ts +32 -0
- package/dist/commands/catalog.js +48 -0
- package/dist/commands/config.d.ts +13 -0
- package/dist/commands/config.js +90 -0
- package/dist/commands/lifecycle.d.ts +29 -0
- package/dist/commands/lifecycle.js +194 -0
- package/dist/commands/pull.d.ts +22 -0
- package/dist/commands/pull.js +135 -0
- package/dist/commands/report.d.ts +10 -0
- package/dist/commands/report.js +34 -0
- package/dist/commands/rescore.d.ts +11 -0
- package/dist/commands/rescore.js +48 -0
- package/dist/commands/runtime.d.ts +21 -0
- package/dist/commands/runtime.js +62 -0
- package/dist/commands/scan.d.ts +24 -0
- package/dist/commands/scan.js +55 -0
- package/dist/commands/search.d.ts +31 -0
- package/dist/commands/search.js +135 -0
- package/dist/commands/share.d.ts +26 -0
- package/dist/commands/share.js +148 -0
- package/dist/commands/sweep.d.ts +21 -0
- package/dist/commands/sweep.js +69 -0
- package/dist/commands/ui.d.ts +9 -0
- package/dist/commands/ui.js +28 -0
- package/dist/config/env.d.ts +10 -0
- package/dist/config/env.js +48 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.js +9 -0
- package/dist/config/otto-home.d.ts +2 -0
- package/dist/config/otto-home.js +24 -0
- package/dist/config/paths.d.ts +20 -0
- package/dist/config/paths.js +33 -0
- package/dist/config/private-files.d.ts +3 -0
- package/dist/config/private-files.js +32 -0
- package/dist/config/profiles.d.ts +34 -0
- package/dist/config/profiles.js +116 -0
- package/dist/config/schema.d.ts +1035 -0
- package/dist/config/schema.js +191 -0
- package/dist/config/store.d.ts +11 -0
- package/dist/config/store.js +73 -0
- package/dist/gguf.d.ts +61 -0
- package/dist/gguf.js +231 -0
- package/dist/gpu.d.ts +12 -0
- package/dist/gpu.js +49 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +5 -0
- package/dist/models/dirs.d.ts +9 -0
- package/dist/models/dirs.js +21 -0
- package/dist/models/download.d.ts +31 -0
- package/dist/models/download.js +105 -0
- package/dist/models/enrich.d.ts +36 -0
- package/dist/models/enrich.js +68 -0
- package/dist/models/hf.d.ts +62 -0
- package/dist/models/hf.js +114 -0
- package/dist/models/index.d.ts +15 -0
- package/dist/models/index.js +49 -0
- package/dist/models/manage.d.ts +27 -0
- package/dist/models/manage.js +112 -0
- package/dist/models/pick.d.ts +3 -0
- package/dist/models/pick.js +36 -0
- package/dist/models/scan.d.ts +13 -0
- package/dist/models/scan.js +185 -0
- package/dist/ops/archive.d.ts +34 -0
- package/dist/ops/archive.js +105 -0
- package/dist/ops/calibrate.d.ts +49 -0
- package/dist/ops/calibrate.js +74 -0
- package/dist/ops/report.d.ts +26 -0
- package/dist/ops/report.js +532 -0
- package/dist/ops/results.d.ts +207 -0
- package/dist/ops/results.js +250 -0
- package/dist/ops/sweep.d.ts +77 -0
- package/dist/ops/sweep.js +126 -0
- package/dist/output/index.d.ts +5 -0
- package/dist/output/index.js +5 -0
- package/dist/output/render.d.ts +4 -0
- package/dist/output/render.js +96 -0
- package/dist/output/types.d.ts +52 -0
- package/dist/output/types.js +9 -0
- package/dist/output/with-output.d.ts +3 -0
- package/dist/output/with-output.js +42 -0
- package/dist/run.d.ts +5 -0
- package/dist/run.js +19 -0
- package/dist/runtime/args.d.ts +21 -0
- package/dist/runtime/args.js +71 -0
- package/dist/runtime/index.d.ts +13 -0
- package/dist/runtime/index.js +41 -0
- package/dist/runtime/lmstudio.d.ts +10 -0
- package/dist/runtime/lmstudio.js +88 -0
- package/dist/runtime/managed.d.ts +26 -0
- package/dist/runtime/managed.js +148 -0
- package/dist/service/model-selector.d.ts +36 -0
- package/dist/service/model-selector.js +123 -0
- package/dist/service/pid-lock.d.ts +18 -0
- package/dist/service/pid-lock.js +50 -0
- package/dist/service/router.d.ts +144 -0
- package/dist/service/router.js +746 -0
- package/dist/service/scheduler.d.ts +74 -0
- package/dist/service/scheduler.js +110 -0
- package/dist/service/serve.d.ts +41 -0
- package/dist/service/serve.js +278 -0
- package/dist/service/supervisor.d.ts +67 -0
- package/dist/service/supervisor.js +222 -0
- package/dist/service/tailscale.d.ts +19 -0
- package/dist/service/tailscale.js +62 -0
- package/dist/service/tls.d.ts +77 -0
- package/dist/service/tls.js +192 -0
- package/dist/sysmon.d.ts +50 -0
- package/dist/sysmon.js +103 -0
- package/dist/tui/app.d.ts +208 -0
- package/dist/tui/app.js +1801 -0
- package/dist/tui/screen.d.ts +72 -0
- package/dist/tui/screen.js +176 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.js +7 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +8 -0
- package/dist/vram.d.ts +67 -0
- package/dist/vram.js +132 -0
- package/package.json +58 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import * as results from "./results.js";
|
|
4
|
+
/**
|
|
5
|
+
* Renders stored benchmark runs as a self-contained HTML report.
|
|
6
|
+
*
|
|
7
|
+
* No external assets: a strict CSP environment must be able to open the file
|
|
8
|
+
* offline, so every chart is inline SVG generated here and all CSS is local.
|
|
9
|
+
*
|
|
10
|
+
* Colour roles come from a validated categorical palette (four slots, checked
|
|
11
|
+
* for CVD separation and lightness band in both light and dark surfaces). Three
|
|
12
|
+
* light-mode slots fall below 3:1 against the light surface, so the relief rule
|
|
13
|
+
* applies: every bar carries a visible direct label and a full table view ships
|
|
14
|
+
* alongside the charts. Identity is never colour alone.
|
|
15
|
+
*/
|
|
16
|
+
const SERIES = [
|
|
17
|
+
{ light: "#2a78d6", dark: "#3987e5" },
|
|
18
|
+
{ light: "#eb6834", dark: "#d95926" },
|
|
19
|
+
{ light: "#1baf7a", dark: "#199e70" },
|
|
20
|
+
{ light: "#eda100", dark: "#c98500" },
|
|
21
|
+
];
|
|
22
|
+
// Two measures of different scale never share an axis, so latency and
|
|
23
|
+
// throughput are separate charts.
|
|
24
|
+
const LINE_SERIES_CAP = 3;
|
|
25
|
+
function escapeHtml(text) {
|
|
26
|
+
return String(text).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
27
|
+
}
|
|
28
|
+
function formatGiB(bytes) {
|
|
29
|
+
return bytes ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : "—";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Per-model score card: each task labelled directly with its weight, then the
|
|
33
|
+
* weighted Overall on its own row so the headline number reads as the sum of its
|
|
34
|
+
* parts. Every bar measures the same thing (a 0-100% score), so it is one hue
|
|
35
|
+
* with a direct label - never the categorical palette, which is reserved for
|
|
36
|
+
* distinguishing model series in the line charts and only defines four slots.
|
|
37
|
+
* Colour-cycling it across a growing task list is what dropped later tasks
|
|
38
|
+
* (extra-long-horizon) off the chart when they landed in an undefined slot.
|
|
39
|
+
*/
|
|
40
|
+
function groupedBars(records, columns, weightOf) {
|
|
41
|
+
const rowHeight = 20;
|
|
42
|
+
const barHeight = 12;
|
|
43
|
+
const headerHeight = 30;
|
|
44
|
+
const groupGap = 22;
|
|
45
|
+
const labelWidth = 210;
|
|
46
|
+
const chartWidth = 430;
|
|
47
|
+
const valueGutter = 46;
|
|
48
|
+
const width = labelWidth + chartWidth + valueGutter;
|
|
49
|
+
const groupHeight = headerHeight + (columns.length + 1) * rowHeight + groupGap;
|
|
50
|
+
const height = records.length * groupHeight + 8;
|
|
51
|
+
const bar = (x, y, w, cls, title) => `<rect x="${x}" y="${y}" width="${chartWidth}" height="${barHeight}" class="track"/>` +
|
|
52
|
+
`<rect x="${x}" y="${y}" width="${Math.max(2, w).toFixed(1)}" height="${barHeight}" rx="4" class="${cls}">` +
|
|
53
|
+
(title ? `<title>${title}</title>` : "") +
|
|
54
|
+
`</rect>`;
|
|
55
|
+
const parts = [];
|
|
56
|
+
records.forEach((record, groupIndex) => {
|
|
57
|
+
const top = groupIndex * groupHeight + 8;
|
|
58
|
+
// Group header: model + config on the left, its overall score on the right.
|
|
59
|
+
parts.push(`<text x="0" y="${top + 13}" class="grp">${escapeHtml(record.model.displayName.slice(0, 38))}</text>`);
|
|
60
|
+
const meta = [
|
|
61
|
+
record.model.quant,
|
|
62
|
+
`ctx ${(record.profile?.contextSize || 0).toLocaleString()}`,
|
|
63
|
+
`rb ${record.profile?.reasoningBudget}`,
|
|
64
|
+
]
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join(" · ");
|
|
67
|
+
parts.push(`<text x="0" y="${top + 26}" class="grpmeta">${escapeHtml(meta)}</text>`);
|
|
68
|
+
parts.push(`<text x="${width}" y="${top + 15}" text-anchor="end" class="grpscore">${(record.overall * 100).toFixed(0)}%</text>`);
|
|
69
|
+
const bodyTop = top + headerHeight;
|
|
70
|
+
columns.forEach((column, i) => {
|
|
71
|
+
const task = record.tasks.find((t) => t.id === column.id);
|
|
72
|
+
const score = task ? task.score : 0;
|
|
73
|
+
const y = bodyTop + i * rowHeight;
|
|
74
|
+
const weight = weightOf.get(column.id);
|
|
75
|
+
const label = escapeHtml(column.category);
|
|
76
|
+
parts.push(`<text x="0" y="${y + barHeight - 2}" class="tlabel">${label}</text>` +
|
|
77
|
+
(weight
|
|
78
|
+
? `<text x="${labelWidth - 8}" y="${y + barHeight - 2}" text-anchor="end" class="twt">×${weight}</text>`
|
|
79
|
+
: "") +
|
|
80
|
+
bar(labelWidth, y, score * chartWidth, "scorebar", `${label}: ${(score * 100).toFixed(0)}%${weight ? ` (weight ${weight})` : ""} — ${escapeHtml(task ? task.summary : "not run")}`) +
|
|
81
|
+
`<text x="${labelWidth + chartWidth + 8}" y="${y + barHeight - 2}" class="val">${task ? `${(score * 100).toFixed(0)}%` : "—"}</text>`);
|
|
82
|
+
});
|
|
83
|
+
// The weighted Overall, set off by a divider so it reads as the sum of the
|
|
84
|
+
// task rows above it rather than one more task.
|
|
85
|
+
const oy = bodyTop + columns.length * rowHeight + 5;
|
|
86
|
+
parts.push(`<line x1="0" y1="${oy - 3}" x2="${width}" y2="${oy - 3}" class="grid"/>`);
|
|
87
|
+
parts.push(`<text x="0" y="${oy + barHeight - 2}" class="olabel">Overall</text>` +
|
|
88
|
+
`<text x="${labelWidth - 8}" y="${oy + barHeight - 2}" text-anchor="end" class="twt">weighted</text>` +
|
|
89
|
+
bar(labelWidth, oy, record.overall * chartWidth, "overallbar") +
|
|
90
|
+
`<text x="${labelWidth + chartWidth + 8}" y="${oy + barHeight - 2}" class="oval">${(record.overall * 100).toFixed(0)}%</text>`);
|
|
91
|
+
});
|
|
92
|
+
return (`<svg viewBox="0 0 ${width} ${height}" width="100%" role="img" ` +
|
|
93
|
+
`aria-label="Task scores and weighted overall by model"><g>${parts.join("")}</g></svg>`);
|
|
94
|
+
}
|
|
95
|
+
/** Line chart for one measure across prompt depth. */
|
|
96
|
+
function depthChart(series, { valueOf, label, unit, formatValue }) {
|
|
97
|
+
const width = 620;
|
|
98
|
+
const height = 240;
|
|
99
|
+
const pad = { top: 16, right: 18, bottom: 34, left: 58 };
|
|
100
|
+
const plotW = width - pad.left - pad.right;
|
|
101
|
+
const plotH = height - pad.top - pad.bottom;
|
|
102
|
+
const points = series.flatMap((s) => s.points);
|
|
103
|
+
const xs = points.map((p) => p.promptTokens || 0);
|
|
104
|
+
const ys = points.map(valueOf).filter((v) => typeof v === "number");
|
|
105
|
+
if (!ys.length)
|
|
106
|
+
return `<p class="empty">No ${escapeHtml(label)} data recorded.</p>`;
|
|
107
|
+
const xMax = Math.max(...xs, 1);
|
|
108
|
+
const yMax = Math.max(...ys, 1) * 1.15;
|
|
109
|
+
const sx = (x) => pad.left + (x / xMax) * plotW;
|
|
110
|
+
const sy = (y) => pad.top + plotH - (y / yMax) * plotH;
|
|
111
|
+
const parts = [];
|
|
112
|
+
// Recessive grid and axis.
|
|
113
|
+
for (let i = 0; i <= 4; i += 1) {
|
|
114
|
+
const y = pad.top + (plotH / 4) * i;
|
|
115
|
+
const value = yMax - (yMax / 4) * i;
|
|
116
|
+
parts.push(`<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" class="grid"/>`);
|
|
117
|
+
parts.push(`<text x="${pad.left - 8}" y="${y + 4}" class="tick" text-anchor="end">${formatValue(value)}</text>`);
|
|
118
|
+
}
|
|
119
|
+
parts.push(`<line x1="${pad.left}" y1="${pad.top + plotH}" x2="${width - pad.right}" y2="${pad.top + plotH}" class="axis"/>`);
|
|
120
|
+
for (let i = 0; i <= 3; i += 1) {
|
|
121
|
+
const x = pad.left + (plotW / 3) * i;
|
|
122
|
+
const value = (xMax / 3) * i;
|
|
123
|
+
parts.push(`<text x="${x}" y="${height - 12}" class="tick" text-anchor="middle">` +
|
|
124
|
+
`${value >= 1000 ? `${Math.round(value / 1000)}k` : Math.round(value)}</text>`);
|
|
125
|
+
}
|
|
126
|
+
series.forEach((s, i) => {
|
|
127
|
+
const ordered = [...s.points].sort((a, b) => (a.promptTokens || 0) - (b.promptTokens || 0));
|
|
128
|
+
const d = ordered
|
|
129
|
+
.filter((p) => typeof valueOf(p) === "number")
|
|
130
|
+
.map((p, j) => `${j === 0 ? "M" : "L"}${sx(p.promptTokens || 0).toFixed(1)},${sy(valueOf(p)).toFixed(1)}`)
|
|
131
|
+
.join(" ");
|
|
132
|
+
parts.push(`<path d="${d}" fill="none" stroke="var(--series-${i + 1})" stroke-width="2" ` +
|
|
133
|
+
`stroke-linejoin="round" stroke-linecap="round"/>`);
|
|
134
|
+
for (const p of ordered) {
|
|
135
|
+
const v = valueOf(p);
|
|
136
|
+
if (typeof v !== "number")
|
|
137
|
+
continue;
|
|
138
|
+
// 2px surface ring keeps overlapping markers separable.
|
|
139
|
+
parts.push(`<circle cx="${sx(p.promptTokens || 0).toFixed(1)}" cy="${sy(v).toFixed(1)}" r="4.5" ` +
|
|
140
|
+
`fill="var(--series-${i + 1})" stroke="var(--surface-1)" stroke-width="2">` +
|
|
141
|
+
`<title>${escapeHtml(s.name)} — ${(p.promptTokens || 0).toLocaleString()} tokens: ` +
|
|
142
|
+
`${formatValue(v)} ${escapeHtml(unit)}</title></circle>`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
return (`<svg viewBox="0 0 ${width} ${height}" width="100%" role="img" ` +
|
|
146
|
+
`aria-label="${escapeHtml(label)} against prompt depth">${parts.join("")}</svg>`);
|
|
147
|
+
}
|
|
148
|
+
/** Overall score across repeated runs (x = run number, not wall time). */
|
|
149
|
+
function runsChart(series) {
|
|
150
|
+
const width = 620;
|
|
151
|
+
const height = 240;
|
|
152
|
+
const pad = { top: 16, right: 18, bottom: 34, left: 42 };
|
|
153
|
+
const plotW = width - pad.left - pad.right;
|
|
154
|
+
const plotH = height - pad.top - pad.bottom;
|
|
155
|
+
const maxRun = Math.max(2, ...series.flatMap((s) => s.points.map((p) => p.runIndex)));
|
|
156
|
+
const sx = (x) => pad.left + ((x - 1) / (maxRun - 1)) * plotW;
|
|
157
|
+
const sy = (y) => pad.top + plotH - Math.max(0, Math.min(1, y)) * plotH;
|
|
158
|
+
const parts = [];
|
|
159
|
+
for (let i = 0; i <= 4; i += 1) {
|
|
160
|
+
const y = pad.top + (plotH / 4) * i;
|
|
161
|
+
parts.push(`<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" class="grid"/>`);
|
|
162
|
+
parts.push(`<text x="${pad.left - 8}" y="${y + 4}" class="tick" text-anchor="end">${100 - 25 * i}%</text>`);
|
|
163
|
+
}
|
|
164
|
+
parts.push(`<line x1="${pad.left}" y1="${pad.top + plotH}" x2="${width - pad.right}" y2="${pad.top + plotH}" class="axis"/>`);
|
|
165
|
+
for (let r = 1; r <= maxRun; r += 1) {
|
|
166
|
+
parts.push(`<text x="${sx(r).toFixed(1)}" y="${height - 12}" class="tick" text-anchor="middle">${r}</text>`);
|
|
167
|
+
}
|
|
168
|
+
series.forEach((s, i) => {
|
|
169
|
+
const ordered = [...s.points].sort((a, b) => a.runIndex - b.runIndex);
|
|
170
|
+
const d = ordered
|
|
171
|
+
.map((p, j) => `${j === 0 ? "M" : "L"}${sx(p.runIndex).toFixed(1)},${sy(p.score).toFixed(1)}`)
|
|
172
|
+
.join(" ");
|
|
173
|
+
parts.push(`<path d="${d}" fill="none" stroke="var(--series-${i + 1})" stroke-width="2" ` +
|
|
174
|
+
`stroke-linejoin="round" stroke-linecap="round"/>`);
|
|
175
|
+
for (const p of ordered) {
|
|
176
|
+
parts.push(`<circle cx="${sx(p.runIndex).toFixed(1)}" cy="${sy(p.score).toFixed(1)}" r="4.5" ` +
|
|
177
|
+
`fill="var(--series-${i + 1})" stroke="var(--surface-1)" stroke-width="2">` +
|
|
178
|
+
`<title>${escapeHtml(s.name)} — run ${p.runIndex}: ${(p.score * 100).toFixed(0)}%</title></circle>`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
return (`<svg viewBox="0 0 ${width} ${height}" width="100%" role="img" ` +
|
|
182
|
+
`aria-label="Overall score across repeated runs">${parts.join("")}</svg>`);
|
|
183
|
+
}
|
|
184
|
+
function legend(items) {
|
|
185
|
+
return `<div class="legend">${items
|
|
186
|
+
.map((item, i) => `<span class="lg"><i style="background:var(--series-${i + 1})"></i>${escapeHtml(item)}</span>`)
|
|
187
|
+
.join("")}</div>`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* A task whose scores barely differ across models carries no information,
|
|
191
|
+
* however good it looks. Measuring that spread here keeps a saturated task from
|
|
192
|
+
* quietly inflating every model's overall score.
|
|
193
|
+
*/
|
|
194
|
+
function discrimination(records, columns) {
|
|
195
|
+
return columns.map((column) => {
|
|
196
|
+
const scores = records
|
|
197
|
+
.map((r) => r.tasks.find((t) => t.id === column.id))
|
|
198
|
+
.filter((t) => Boolean(t))
|
|
199
|
+
.map((t) => t.score);
|
|
200
|
+
if (scores.length < 2)
|
|
201
|
+
return { ...column, spread: null, verdict: "insufficient data" };
|
|
202
|
+
const spread = Math.max(...scores) - Math.min(...scores);
|
|
203
|
+
let verdict = "discriminating";
|
|
204
|
+
if (spread < 0.05)
|
|
205
|
+
verdict = "saturated";
|
|
206
|
+
else if (spread < 0.15)
|
|
207
|
+
verdict = "weak";
|
|
208
|
+
return { ...column, spread, verdict, min: Math.min(...scores), max: Math.max(...scores) };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function statTile(label, value, note) {
|
|
212
|
+
return (`<div class="tile"><div class="tl">${escapeHtml(label)}</div>` +
|
|
213
|
+
`<div class="tv">${escapeHtml(String(value))}</div>` +
|
|
214
|
+
`<div class="tn">${escapeHtml(note || "")}</div></div>`);
|
|
215
|
+
}
|
|
216
|
+
function build(records, allRuns = records) {
|
|
217
|
+
if (!records.length) {
|
|
218
|
+
return "<h1>No benchmark results yet</h1><p>Run <code>node src/index.js bench --model <name></code> first.</p>";
|
|
219
|
+
}
|
|
220
|
+
const columns = results.taskColumns(records);
|
|
221
|
+
const ranked = [...records].sort((a, b) => b.overall - a.overall);
|
|
222
|
+
const best = ranked[0];
|
|
223
|
+
// Overall is a weighted mean of the task scores (see bench/index.ts runSuite);
|
|
224
|
+
// the report has to carry the weights or the headline number cannot be
|
|
225
|
+
// reconciled with the per-task bars. Weight is persisted per task, so read it
|
|
226
|
+
// from the records rather than re-importing the task table.
|
|
227
|
+
const weightOf = new Map();
|
|
228
|
+
for (const record of records) {
|
|
229
|
+
for (const task of record.tasks)
|
|
230
|
+
if (!weightOf.has(task.id))
|
|
231
|
+
weightOf.set(task.id, task.weight);
|
|
232
|
+
}
|
|
233
|
+
// Consistency: how much the same config's overall score moves across reruns.
|
|
234
|
+
const groups = results.grouped(allRuns);
|
|
235
|
+
const meanOverall = (g) => g.runs.reduce((a, r) => a + r.overall, 0) / g.runs.length;
|
|
236
|
+
const repeated = groups
|
|
237
|
+
.filter((g) => g.count > 1)
|
|
238
|
+
.sort((a, b) => meanOverall(b) - meanOverall(a));
|
|
239
|
+
const consistencySeries = repeated.slice(0, LINE_SERIES_CAP).map((g) => ({
|
|
240
|
+
name: g.model.displayName,
|
|
241
|
+
points: g.runs.map((r) => ({ runIndex: r.runIndex ?? 0, score: r.overall })),
|
|
242
|
+
}));
|
|
243
|
+
const varianceRows = results.variance(allRuns);
|
|
244
|
+
const healthRows = ranked.filter((r) => r.system && r.system.samples);
|
|
245
|
+
// Depth series come from the depth-scaling task detail.
|
|
246
|
+
const depthSeries = ranked
|
|
247
|
+
.slice(0, LINE_SERIES_CAP)
|
|
248
|
+
.map((r) => {
|
|
249
|
+
const task = r.tasks.find((t) => t.id === "context-depth");
|
|
250
|
+
return {
|
|
251
|
+
name: r.model.displayName,
|
|
252
|
+
points: Array.isArray(task?.detail) ? task?.detail : [],
|
|
253
|
+
};
|
|
254
|
+
})
|
|
255
|
+
.filter((s) => s.points.length);
|
|
256
|
+
const fastest = ranked
|
|
257
|
+
.flatMap((r) => (r.tasks.find((t) => t.id === "context-depth")?.detail || []))
|
|
258
|
+
.reduce((max, p) => Math.max(max, p.generatePerSecond || 0), 0);
|
|
259
|
+
const tableRows = ranked
|
|
260
|
+
.map((r) => {
|
|
261
|
+
const cells = columns
|
|
262
|
+
.map((c) => {
|
|
263
|
+
const task = r.tasks.find((t) => t.id === c.id);
|
|
264
|
+
return `<td class="num">${task ? `${(task.score * 100).toFixed(0)}%` : "—"}</td>`;
|
|
265
|
+
})
|
|
266
|
+
.join("");
|
|
267
|
+
return (`<tr><td>${escapeHtml(r.model.displayName)}</td><td>${escapeHtml(r.model.quant || "—")}</td>` +
|
|
268
|
+
`<td class="num">${(r.profile?.contextSize || 0).toLocaleString()}</td>` +
|
|
269
|
+
`<td class="num">${r.profile?.reasoningBudget ?? "—"}</td>` +
|
|
270
|
+
`${cells}<td class="num strong">${(r.overall * 100).toFixed(0)}%</td>` +
|
|
271
|
+
`<td class="num">${formatGiB(r.vramBytes)}</td>` +
|
|
272
|
+
`<td class="muted">${escapeHtml(r.ranAt.slice(0, 16).replace("T", " "))}</td></tr>`);
|
|
273
|
+
})
|
|
274
|
+
.join("");
|
|
275
|
+
const spreads = discrimination(ranked, columns);
|
|
276
|
+
const saturated = spreads.filter((s) => s.verdict === "saturated");
|
|
277
|
+
const notes = [];
|
|
278
|
+
if (depthSeries.length && ranked.length > LINE_SERIES_CAP) {
|
|
279
|
+
notes.push(`Depth charts show the top ${LINE_SERIES_CAP} models by overall score; ` +
|
|
280
|
+
`${ranked.length - LINE_SERIES_CAP} further run(s) are in the table.`);
|
|
281
|
+
}
|
|
282
|
+
if (records.some((r) => !r.executedCode)) {
|
|
283
|
+
notes.push("Some runs did not execute generated tests, so their long-horizon score is compile-only.");
|
|
284
|
+
}
|
|
285
|
+
return `
|
|
286
|
+
<div class="viz-root">
|
|
287
|
+
<header>
|
|
288
|
+
<h1>Agentic coding scorecard</h1>
|
|
289
|
+
<p class="sub">Locally measured on ${escapeHtml(best.gpu?.name || "this machine")}${best.runtime ? ` · llama.cpp ${escapeHtml(best.runtime)}` : ""}</p>
|
|
290
|
+
</header>
|
|
291
|
+
|
|
292
|
+
<section class="tiles">
|
|
293
|
+
${statTile("Best model", best.model.displayName.slice(0, 26), `${(best.overall * 100).toFixed(0)}% — ${best.grade}`)}
|
|
294
|
+
${statTile("Runs recorded", records.length, `${columns.length} task categories`)}
|
|
295
|
+
${statTile("Peak generation", fastest ? `${fastest.toFixed(0)} tok/s` : "—", "at shallowest depth measured")}
|
|
296
|
+
${statTile("VRAM at load", formatGiB(best.vramBytes), best.loadSeconds ? `loaded in ${best.loadSeconds.toFixed(1)}s` : "")}
|
|
297
|
+
</section>
|
|
298
|
+
|
|
299
|
+
<section class="card">
|
|
300
|
+
<h2>Scores by task</h2>
|
|
301
|
+
<p class="cap">Each row is one task, scored 0–100% from objectively verifiable outcomes — tool calls
|
|
302
|
+
checked against expected name and arguments, generated code compiled and its tests executed. The
|
|
303
|
+
<b>×N</b> after a task is its weight. <b>Overall</b> is the weighted mean of the task scores,
|
|
304
|
+
<span class="mono">Σ(score × weight) ÷ Σ(weight)</span> — so a heavier task moves the headline
|
|
305
|
+
number more, and it is not the plain average of the bars above it.</p>
|
|
306
|
+
${groupedBars(ranked, columns, weightOf)}
|
|
307
|
+
</section>
|
|
308
|
+
|
|
309
|
+
${depthSeries.length
|
|
310
|
+
? `
|
|
311
|
+
<section class="card">
|
|
312
|
+
<h2>Latency against prompt depth</h2>
|
|
313
|
+
<p class="cap">Time to first token as the prompt grows. Agent loops resend large prefixes,
|
|
314
|
+
so behaviour at depth matters more than on an empty context.</p>
|
|
315
|
+
${legend(depthSeries.map((s) => s.name.slice(0, 30)))}
|
|
316
|
+
${depthChart(depthSeries, {
|
|
317
|
+
valueOf: (p) => p.ttftSeconds,
|
|
318
|
+
label: "Time to first token",
|
|
319
|
+
unit: "seconds",
|
|
320
|
+
formatValue: (v) => `${v.toFixed(1)}s`,
|
|
321
|
+
})}
|
|
322
|
+
<p class="axl">prompt tokens</p>
|
|
323
|
+
</section>
|
|
324
|
+
|
|
325
|
+
<section class="card">
|
|
326
|
+
<h2>Throughput against prompt depth</h2>
|
|
327
|
+
<p class="cap">Generation speed at the same depths. A collapse here usually means the KV cache
|
|
328
|
+
spilled out of VRAM — the failure this tool's budget check exists to prevent.</p>
|
|
329
|
+
${legend(depthSeries.map((s) => s.name.slice(0, 30)))}
|
|
330
|
+
${depthChart(depthSeries, {
|
|
331
|
+
valueOf: (p) => p.generatePerSecond,
|
|
332
|
+
label: "Generation throughput",
|
|
333
|
+
unit: "tokens per second",
|
|
334
|
+
formatValue: (v) => v.toFixed(0),
|
|
335
|
+
})}
|
|
336
|
+
<p class="axl">prompt tokens</p>
|
|
337
|
+
</section>`
|
|
338
|
+
: ""}
|
|
339
|
+
|
|
340
|
+
<section class="card">
|
|
341
|
+
<h2>Does each task actually tell us anything?</h2>
|
|
342
|
+
<p class="cap">A task every model passes cannot rank them, however hard it looks. This is the
|
|
343
|
+
score spread across all runs — low spread means the task is saturated and its contribution to
|
|
344
|
+
the overall figure is inflating everyone equally. The weight is how hard that task pulls on the
|
|
345
|
+
overall score, so a saturated <em>heavy</em> task matters most.</p>
|
|
346
|
+
<div class="scroll">
|
|
347
|
+
<table>
|
|
348
|
+
<thead><tr><th>Task</th><th class="num">Weight</th><th class="num">Lowest</th><th class="num">Highest</th>
|
|
349
|
+
<th class="num">Spread</th><th>Verdict</th></tr></thead>
|
|
350
|
+
<tbody>${spreads
|
|
351
|
+
.map((s) => {
|
|
352
|
+
const tone = s.verdict === "saturated" ? "bad" : s.verdict === "weak" ? "warn" : "good";
|
|
353
|
+
return (`<tr><td>${escapeHtml(s.category)}</td>` +
|
|
354
|
+
`<td class="num">×${weightOf.get(s.id) ?? "?"}</td>` +
|
|
355
|
+
`<td class="num">${s.min === undefined ? "—" : `${(s.min * 100).toFixed(0)}%`}</td>` +
|
|
356
|
+
`<td class="num">${s.max === undefined ? "—" : `${(s.max * 100).toFixed(0)}%`}</td>` +
|
|
357
|
+
`<td class="num strong">${s.spread === null ? "—" : `${(s.spread * 100).toFixed(0)} pts`}</td>` +
|
|
358
|
+
`<td class="v-${tone}">${escapeHtml(s.verdict)}</td></tr>`);
|
|
359
|
+
})
|
|
360
|
+
.join("")}</tbody>
|
|
361
|
+
</table>
|
|
362
|
+
</div>
|
|
363
|
+
${saturated.length
|
|
364
|
+
? `<ul class="notes"><li><strong>${saturated.map((s) => escapeHtml(s.category)).join(", ")}</strong>
|
|
365
|
+
${saturated.length === 1 ? "is" : "are"} saturated — every model scores the same, so
|
|
366
|
+
${saturated.length === 1 ? "it needs" : "they need"} to get harder before
|
|
367
|
+
${saturated.length === 1 ? "it" : "they"} can contribute to a ranking.</li></ul>`
|
|
368
|
+
: ""}
|
|
369
|
+
</section>
|
|
370
|
+
|
|
371
|
+
<section class="card">
|
|
372
|
+
<h2>Is the benchmark consistent?</h2>
|
|
373
|
+
<p class="cap">The same model + settings, measured more than once. A method you can trust holds a
|
|
374
|
+
flat line across runs; a jagged one means the score is noisy and small gaps between models are
|
|
375
|
+
not real. Runs are numbered, not dated — only how many times we have measured matters.</p>
|
|
376
|
+
${consistencySeries.length
|
|
377
|
+
? `
|
|
378
|
+
${legend(consistencySeries.map((s) => s.name.slice(0, 30)))}
|
|
379
|
+
${runsChart(consistencySeries)}
|
|
380
|
+
<p class="axl">run number</p>`
|
|
381
|
+
: `
|
|
382
|
+
<p class="empty">No config has been run more than once yet. Re-run a model a few times to see
|
|
383
|
+
its spread — the table below shows mean ± spread as repeats accumulate.</p>`}
|
|
384
|
+
<div class="scroll">
|
|
385
|
+
<table>
|
|
386
|
+
<thead><tr><th>Model</th><th class="num">Runs</th><th class="num">Mean</th>
|
|
387
|
+
<th class="num">± spread</th><th class="num">Range</th></tr></thead>
|
|
388
|
+
<tbody>${varianceRows
|
|
389
|
+
.map((v) => {
|
|
390
|
+
const o = v.overall || {};
|
|
391
|
+
const spreadPts = (o.std ?? 0) * 100;
|
|
392
|
+
const tone = v.count < 2 ? "muted" : spreadPts > 8 ? "v-warn" : "v-good";
|
|
393
|
+
return (`<tr><td>${escapeHtml(v.model.displayName)}</td>` +
|
|
394
|
+
`<td class="num">${v.count}</td>` +
|
|
395
|
+
`<td class="num strong">${o.mean === undefined ? "—" : `${(o.mean * 100).toFixed(0)}%`}</td>` +
|
|
396
|
+
`<td class="num ${tone}">${v.count < 2 ? "—" : `${spreadPts.toFixed(1)} pts`}</td>` +
|
|
397
|
+
`<td class="num">${v.count < 2 ? "—" : `${((o.min ?? 0) * 100).toFixed(0)}–${((o.max ?? 0) * 100).toFixed(0)}%`}</td></tr>`);
|
|
398
|
+
})
|
|
399
|
+
.join("")}</tbody>
|
|
400
|
+
</table>
|
|
401
|
+
</div>
|
|
402
|
+
</section>
|
|
403
|
+
|
|
404
|
+
${healthRows.length
|
|
405
|
+
? `
|
|
406
|
+
<section class="card">
|
|
407
|
+
<h2>System health during runs</h2>
|
|
408
|
+
<p class="cap">The machine state each score was measured under. A throttled or power-capped run is
|
|
409
|
+
slower and lower for reasons that have nothing to do with the model — read those scores with
|
|
410
|
+
suspicion, or re-run them once the GPU has cooled.</p>
|
|
411
|
+
<div class="scroll">
|
|
412
|
+
<table>
|
|
413
|
+
<thead><tr><th>Model</th><th class="num">Peak temp</th><th class="num">Avg power</th>
|
|
414
|
+
<th class="num">Avg GPU</th><th class="num">Peak VRAM</th><th>Throttled</th></tr></thead>
|
|
415
|
+
<tbody>${healthRows
|
|
416
|
+
.map((r) => {
|
|
417
|
+
const h = r.system;
|
|
418
|
+
const throttled = h.thermalThrottle || h.powerThrottle;
|
|
419
|
+
const label = h.thermalThrottle ? "thermal" : h.powerThrottle ? "power" : "no";
|
|
420
|
+
return (`<tr><td>${escapeHtml(r.model.displayName)}</td>` +
|
|
421
|
+
`<td class="num">${h.tempC ? `${h.tempC.max}°C` : "—"}</td>` +
|
|
422
|
+
`<td class="num">${h.powerW ? `${h.powerW.avg.toFixed(0)} W` : "—"}</td>` +
|
|
423
|
+
`<td class="num">${h.gpuUtilPct ? `${h.gpuUtilPct.avg.toFixed(0)}%` : "—"}</td>` +
|
|
424
|
+
`<td class="num">${h.vramUsedMiB ? `${(h.vramUsedMiB.max / 1024).toFixed(1)} GB` : "—"}</td>` +
|
|
425
|
+
`<td class="${throttled ? "v-bad" : "v-good"}">${throttled ? `⚠ ${label}` : "no"}</td></tr>`);
|
|
426
|
+
})
|
|
427
|
+
.join("")}</tbody>
|
|
428
|
+
</table>
|
|
429
|
+
</div>
|
|
430
|
+
</section>`
|
|
431
|
+
: ""}
|
|
432
|
+
|
|
433
|
+
<section class="card">
|
|
434
|
+
<h2>All runs</h2>
|
|
435
|
+
<div class="scroll">
|
|
436
|
+
<table>
|
|
437
|
+
<thead><tr><th>Model</th><th>Quant</th><th class="num">Context</th><th class="num">Reasoning</th>
|
|
438
|
+
${columns.map((c) => `<th class="num">${escapeHtml(c.category)}<span class="wt">×${weightOf.get(c.id) ?? "?"}</span></th>`).join("")}
|
|
439
|
+
<th class="num">Overall<span class="wt">wtd</span></th><th class="num">VRAM</th><th>Run at</th></tr></thead>
|
|
440
|
+
<tbody>${tableRows}</tbody>
|
|
441
|
+
</table>
|
|
442
|
+
</div>
|
|
443
|
+
${notes.length ? `<ul class="notes">${notes.map((n) => `<li>${escapeHtml(n)}</li>`).join("")}</ul>` : ""}
|
|
444
|
+
</section>
|
|
445
|
+
</div>`;
|
|
446
|
+
}
|
|
447
|
+
const STYLE = `
|
|
448
|
+
<style>
|
|
449
|
+
.viz-root{
|
|
450
|
+
color-scheme:light;
|
|
451
|
+
--surface-1:#fcfcfb; --plane:#f9f9f7;
|
|
452
|
+
--text-primary:#0b0b0b; --text-secondary:#52514e; --muted:#898781;
|
|
453
|
+
--grid:#e1e0d9; --axis:#c3c2b7; --border:rgba(11,11,11,0.10);
|
|
454
|
+
--series-1:#2a78d6; --series-2:#eb6834; --series-3:#1baf7a; --series-4:#eda100;
|
|
455
|
+
background:var(--plane); color:var(--text-primary);
|
|
456
|
+
font-family:system-ui,-apple-system,"Segoe UI",sans-serif;
|
|
457
|
+
max-width:1000px; margin:0 auto; padding:28px 20px 56px;
|
|
458
|
+
}
|
|
459
|
+
@media (prefers-color-scheme:dark){
|
|
460
|
+
:root:where(:not([data-theme="light"])) .viz-root{
|
|
461
|
+
color-scheme:dark;
|
|
462
|
+
--surface-1:#1a1a19; --plane:#0d0d0d;
|
|
463
|
+
--text-primary:#ffffff; --text-secondary:#c3c2b7; --muted:#898781;
|
|
464
|
+
--grid:#2c2c2a; --axis:#383835; --border:rgba(255,255,255,0.10);
|
|
465
|
+
--series-1:#3987e5; --series-2:#d95926; --series-3:#199e70; --series-4:#c98500;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
:root[data-theme="dark"] .viz-root{
|
|
469
|
+
color-scheme:dark;
|
|
470
|
+
--surface-1:#1a1a19; --plane:#0d0d0d;
|
|
471
|
+
--text-primary:#ffffff; --text-secondary:#c3c2b7; --muted:#898781;
|
|
472
|
+
--grid:#2c2c2a; --axis:#383835; --border:rgba(255,255,255,0.10);
|
|
473
|
+
--series-1:#3987e5; --series-2:#d95926; --series-3:#199e70; --series-4:#c98500;
|
|
474
|
+
}
|
|
475
|
+
h1{font-size:1.55rem;margin:0 0 4px;letter-spacing:-0.01em}
|
|
476
|
+
h2{font-size:1.02rem;margin:0 0 4px}
|
|
477
|
+
.sub{color:var(--text-secondary);margin:0 0 22px;font-size:.88rem}
|
|
478
|
+
.cap{color:var(--text-secondary);font-size:.82rem;margin:0 0 14px;max-width:66ch;line-height:1.45}
|
|
479
|
+
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:20px}
|
|
480
|
+
.tile{background:var(--surface-1);border:1px solid var(--border);border-radius:10px;padding:14px 16px}
|
|
481
|
+
.tl{font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}
|
|
482
|
+
.tv{font-size:1.5rem;font-weight:600;margin:4px 0 2px;line-height:1.15}
|
|
483
|
+
.tn{font-size:.76rem;color:var(--text-secondary)}
|
|
484
|
+
.card{background:var(--surface-1);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin-bottom:16px}
|
|
485
|
+
.legend{display:flex;flex-wrap:wrap;gap:14px;margin-bottom:12px}
|
|
486
|
+
.lg{display:inline-flex;align-items:center;gap:6px;font-size:.78rem;color:var(--text-secondary)}
|
|
487
|
+
.lg i{width:11px;height:11px;border-radius:3px;display:inline-block;flex:none}
|
|
488
|
+
svg{display:block;overflow:visible}
|
|
489
|
+
.grp{font-size:11.5px;font-weight:600;fill:var(--text-primary)}
|
|
490
|
+
.grpmeta{font-size:10px;fill:var(--muted)}
|
|
491
|
+
.grpscore{font-size:13px;font-weight:700;fill:var(--text-primary);font-variant-numeric:tabular-nums}
|
|
492
|
+
.track{fill:var(--grid)}
|
|
493
|
+
.val{font-size:10.5px;fill:var(--text-secondary);font-variant-numeric:tabular-nums}
|
|
494
|
+
.tlabel{font-size:11px;fill:var(--text-secondary)}
|
|
495
|
+
.twt{font-size:9.5px;fill:var(--muted);font-variant-numeric:tabular-nums}
|
|
496
|
+
.scorebar{fill:var(--series-1)}
|
|
497
|
+
.overallbar{fill:var(--series-1);stroke:var(--text-primary);stroke-width:.75}
|
|
498
|
+
.olabel{font-size:11px;font-weight:600;fill:var(--text-primary)}
|
|
499
|
+
.oval{font-size:11px;font-weight:700;fill:var(--text-primary);font-variant-numeric:tabular-nums}
|
|
500
|
+
.grid{stroke:var(--grid);stroke-width:1}
|
|
501
|
+
.axis{stroke:var(--axis);stroke-width:1}
|
|
502
|
+
.tick{font-size:10px;fill:var(--muted);font-variant-numeric:tabular-nums}
|
|
503
|
+
.axl{text-align:center;font-size:.74rem;color:var(--muted);margin:6px 0 0}
|
|
504
|
+
.scroll{overflow-x:auto}
|
|
505
|
+
table{border-collapse:collapse;width:100%;font-size:.8rem}
|
|
506
|
+
th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
|
|
507
|
+
th{font-size:.71rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:600}
|
|
508
|
+
.num{text-align:right;font-variant-numeric:tabular-nums}
|
|
509
|
+
.strong{font-weight:600}
|
|
510
|
+
.muted{color:var(--muted)}
|
|
511
|
+
.mono{font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;font-size:.78rem;background:var(--plane);border:1px solid var(--border);border-radius:4px;padding:1px 5px;white-space:nowrap}
|
|
512
|
+
.wt{color:var(--muted);font-weight:400;font-size:.72em;margin-left:4px}
|
|
513
|
+
.notes{margin:12px 0 0;padding-left:18px;color:var(--text-secondary);font-size:.78rem}
|
|
514
|
+
.empty{color:var(--muted);font-size:.82rem}
|
|
515
|
+
.v-good{color:var(--text-secondary)}
|
|
516
|
+
.v-warn{color:#ec835a;font-weight:600}
|
|
517
|
+
.v-bad{color:#d03b3b;font-weight:600}
|
|
518
|
+
circle:hover{r:6}
|
|
519
|
+
rect[fill^="var"]:hover{opacity:.82}
|
|
520
|
+
</style>`;
|
|
521
|
+
/** Write the report and return its path. */
|
|
522
|
+
function write(outFile) {
|
|
523
|
+
const allRuns = results.loadAll();
|
|
524
|
+
const records = results.latestPerConfig(allRuns);
|
|
525
|
+
const html = `${STYLE}\n${build(records, allRuns)}`;
|
|
526
|
+
const target = outFile || path.join(results.RESULTS_DIR, "report.html");
|
|
527
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
528
|
+
fs.writeFileSync(target, html, "utf8");
|
|
529
|
+
return { file: target, count: records.length };
|
|
530
|
+
}
|
|
531
|
+
export { write, build, STYLE, SERIES };
|
|
532
|
+
//# sourceMappingURL=report.js.map
|