@qvac/harness 1.0.0-rc.2 → 1.0.0-rc.3

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.
Files changed (58) hide show
  1. package/README.md +12 -12
  2. package/bench/README.md +94 -0
  3. package/bench/bfcl/checker.ts +313 -0
  4. package/bench/bfcl/data.ts +93 -0
  5. package/bench/bfcl/observe.ts +93 -0
  6. package/bench/bfcl/run.ts +213 -0
  7. package/bench/bfcl/tools.ts +72 -0
  8. package/bench/bridge/bridge.ts +392 -0
  9. package/bench/bridge/openai.ts +150 -0
  10. package/bench/bridge/python/qvac_bench/__init__.py +3 -0
  11. package/bench/bridge/python/qvac_bench/airline.py +42 -0
  12. package/bench/bridge/python/qvac_bench/approval.py +26 -0
  13. package/bench/bridge/python/qvac_bench/bfcl_multi_turn.py +152 -0
  14. package/bench/bridge/python/qvac_bench/client.py +88 -0
  15. package/bench/bridge/python/qvac_bench/harbor_agent.py +85 -0
  16. package/bench/bridge/python/qvac_bench/session.py +107 -0
  17. package/bench/bridge/python/qvac_bench/speed.py +30 -0
  18. package/bench/bridge/python/qvac_bench/tau2_agent.py +106 -0
  19. package/bench/bridge/python/qvac_bench/tools.py +11 -0
  20. package/bench/bridge/python/run_bfcl_multi_turn.py +95 -0
  21. package/bench/bridge/python/run_tau2.py +150 -0
  22. package/bench/bridge/python/smoke.py +47 -0
  23. package/bench/bridge/python/test_airline.py +28 -0
  24. package/bench/bridge/python/test_approval.py +57 -0
  25. package/bench/bridge/python/test_bfcl_multi_turn.py +185 -0
  26. package/bench/bridge/python/test_harbor_agent.py +107 -0
  27. package/bench/bridge/serve.ts +52 -0
  28. package/bench/bridge/transcript.ts +52 -0
  29. package/bench/bun-tsconfig.json +15 -0
  30. package/bench/cpu-main.mjs +216 -0
  31. package/bench/cpu.ts +457 -0
  32. package/bench/evidence/2026-09-28-review15.md +225 -0
  33. package/bench/evidence/redact-bun1.3.14.json +92 -0
  34. package/bench/evidence/redact-node24.11.json +92 -0
  35. package/bench/graph-metrics.ts +43 -0
  36. package/bench/harness.ts +73 -0
  37. package/bench/issue129-matrix.mjs +722 -0
  38. package/bench/redact-adversarial.ts +256 -0
  39. package/bench/repeat-cpu.mjs +281 -0
  40. package/bench/report.ts +1075 -0
  41. package/bench/skill-eval/harness.ts +30 -0
  42. package/bench/skill-eval/laws.ts +37 -0
  43. package/bench/skill-eval/mermaid/client.ts +132 -0
  44. package/bench/skill-eval/mermaid/validate.mjs +115 -0
  45. package/bench/skill-eval/pack.ts +61 -0
  46. package/bench/skill-eval/packs/diagrams/cases.ts +281 -0
  47. package/bench/skill-eval/packs/diagrams/index.ts +50 -0
  48. package/bench/skill-eval/packs/diagrams/score.ts +74 -0
  49. package/bench/skill-eval/packs/index.ts +5 -0
  50. package/bench/skill-eval/packs/weather/cases.ts +279 -0
  51. package/bench/skill-eval/packs/weather/fixtures/wttr.json +566 -0
  52. package/bench/skill-eval/packs/weather/fixtures.ts +84 -0
  53. package/bench/skill-eval/packs/weather/index.ts +55 -0
  54. package/bench/skill-eval/packs/weather/score.ts +92 -0
  55. package/bench/skill-eval/report.ts +194 -0
  56. package/bench/skill-eval/run.ts +316 -0
  57. package/bench/speed.ts +80 -0
  58. package/package.json +4 -13
@@ -0,0 +1,1075 @@
1
+ // every benchmark on one page: bare bench/report.ts --bfcl l=<dir> --tau2 <dir> --out <f>
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import process from 'process'
5
+ import { BFCL_COMMIT, CATEGORIES, SHAPES, type Shape } from './bfcl/data.ts'
6
+ import { addSpeed, NO_SPEED, speedOf, type Speed } from './speed.ts'
7
+
8
+ interface BfclRow {
9
+ readonly column: string
10
+ readonly model: string
11
+ readonly category: string
12
+ readonly id: string
13
+ readonly shape?: Shape
14
+ readonly valid: boolean
15
+ readonly error: string | null
16
+ readonly wholeValid: boolean
17
+ readonly speed?: Speed | null
18
+ }
19
+
20
+ interface MultiTurnRow {
21
+ readonly column: string
22
+ readonly model: string
23
+ readonly category: string
24
+ readonly id: string
25
+ readonly valid: boolean
26
+ // BFCL's own error type, such as multi_turn:instance_state_mismatch
27
+ readonly error_type: string | null
28
+ readonly error: string | null
29
+ readonly speed?: Speed | null
30
+ }
31
+
32
+ interface Tau2Row {
33
+ readonly run: string
34
+ readonly domain: string
35
+ readonly model: string
36
+ readonly task: string
37
+ readonly writes: boolean
38
+ readonly reward: number
39
+ readonly termination: string
40
+ readonly seconds: number
41
+ readonly replies?: number
42
+ readonly failed_turns?: number
43
+ readonly customer_never_ended?: boolean
44
+ readonly speed?: Speed
45
+ }
46
+
47
+ interface TerminalTrial {
48
+ readonly task: string
49
+ readonly reward: number
50
+ readonly model: string | null
51
+ // absent when the trial left no log
52
+ readonly limit: number | 'default' | null
53
+ readonly calls: number
54
+ readonly speed: Speed | null
55
+ // the one-minute load average at the start and end of the trial
56
+ readonly load: number[]
57
+ }
58
+
59
+ interface BaselineTau2 {
60
+ readonly run: string
61
+ readonly model: string
62
+ readonly tasks: string
63
+ readonly pass: number
64
+ readonly writes: number
65
+ readonly refusals: number
66
+ readonly capped: string
67
+ readonly lost: string
68
+ readonly minutes: string
69
+ }
70
+
71
+ const TAU2_SAMPLE = 20
72
+ const MULTI_TURN_CATEGORIES = [
73
+ 'multi_turn_base',
74
+ 'multi_turn_miss_func',
75
+ 'multi_turn_miss_param',
76
+ 'multi_turn_long_context'
77
+ ]
78
+
79
+ // figures copied from the #151 / PR #35 page, whose run dirs were not retained
80
+ const BASELINE_COLUMNS = [
81
+ '#35 · Qwen3.5-2B',
82
+ '#35 · Qwen3.5-4B',
83
+ '#35 · Qwen3.5-9B',
84
+ 'prompt asks for independent calls together · Qwen3.5-2B'
85
+ ]
86
+ // strict then across rounds, per baseline column
87
+ const BASELINE_BFCL: Record<string, readonly (number | null)[]> = {
88
+ simple_python: [84, 84, 90, 90, 88, 88, 84, 84],
89
+ multiple: [84, 84, 86, 86, 90, 90, 82, 82],
90
+ parallel: [0, 76, 0, 84, 0, 86, 0, 76],
91
+ parallel_multiple: [0, 72, 0, 88, 0, 90, 0, 84],
92
+ irrelevance: [74, 74, 86, 86, 84, 84, null, null],
93
+ live_simple: [66, 66, 68, 68, 80, 80, 58, 58],
94
+ live_multiple: [50, 50, 72, 72, 74, 74, null, null],
95
+ live_parallel: [0, 63, 0, 63, 0, 81, 0, 56],
96
+ live_parallel_multiple: [0, 58, 0, 63, 0, 50, 0, 50],
97
+ live_irrelevance: [76, 76, 84, 84, 82, 82, null, null],
98
+ live_relevance: [88, 88, 75, 75, 88, 88, null, null]
99
+ }
100
+ const BEFORE_FIXES = '#35, before fixes (withdrawn)'
101
+ const BASELINE_TAU2: readonly BaselineTau2[] = [
102
+ {
103
+ run: BEFORE_FIXES,
104
+ model: 'Qwen3.5-2B',
105
+ tasks: '20/20',
106
+ pass: 20,
107
+ writes: 15,
108
+ refusals: 29,
109
+ capped: '9',
110
+ lost: '47/195 (24%)',
111
+ minutes: '6.2'
112
+ },
113
+ {
114
+ run: BEFORE_FIXES,
115
+ model: 'Qwen3.5-4B',
116
+ tasks: '20/20',
117
+ pass: 30,
118
+ writes: 15,
119
+ refusals: 57,
120
+ capped: '4',
121
+ lost: '37/170 (22%)',
122
+ minutes: '8.1'
123
+ },
124
+ {
125
+ run: BEFORE_FIXES,
126
+ model: 'Qwen3.5-9B',
127
+ tasks: '19/20',
128
+ pass: 11,
129
+ writes: 0,
130
+ refusals: 33,
131
+ capped: '17',
132
+ lost: '333/385 (86%)',
133
+ minutes: '9.5'
134
+ },
135
+ {
136
+ run: '#151, all fixes',
137
+ model: 'Qwen3.5-2B',
138
+ tasks: '20/20',
139
+ pass: 45,
140
+ writes: 38,
141
+ refusals: 57,
142
+ capped: '2',
143
+ lost: '8/135 (6%)',
144
+ minutes: '6.2'
145
+ },
146
+ {
147
+ run: '#151, all fixes',
148
+ model: 'Qwen3.5-9B',
149
+ tasks: '20/20',
150
+ pass: 60,
151
+ writes: 46,
152
+ refusals: 86,
153
+ capped: '1 (1 waiting on the customer)',
154
+ lost: '2/119 (2%)',
155
+ minutes: '11.5'
156
+ }
157
+ ]
158
+
159
+ const argv = process.argv.slice(2)
160
+ const all = (name: string) => argv.flatMap((arg, index) => (arg === name ? [argv[index + 1]] : []))
161
+ const out = argv[argv.indexOf('--out') + 1]
162
+
163
+ const bfcl: BfclRow[] = all('--bfcl').flatMap(columnRows)
164
+ const columns = [...new Set(bfcl.map((row) => row.column))]
165
+ // the first BFCL run is the branch under test; later ones are experiments beside it
166
+ const primary = (all('--bfcl')[0] ?? '').split('=')[0]
167
+ const categories = CATEGORIES.filter((category) => bfcl.some((row) => row.category === category))
168
+
169
+ const multiTurn: MultiTurnRow[] = all('--bfcl-multi-turn').flatMap(columnRows)
170
+ const multiTurnColumns = [...new Set(multiTurn.map((row) => row.column))]
171
+
172
+ // a tau2 run is label=dir, oldest first; a file is <domain>-<model>-<stamp>.jsonl, one row per task
173
+ const tau2Specs = all('--tau2').map(labelled)
174
+ const tau2: Tau2Row[] = tau2Specs.flatMap(({ label, dir }) =>
175
+ fs
176
+ .readdirSync(dir)
177
+ .filter((file) => file.endsWith('.jsonl'))
178
+ .flatMap((file) => {
179
+ const name = file.replace(/\.jsonl$/, '')
180
+ const domain = name.slice(0, name.indexOf('-'))
181
+ const model = name.slice(name.indexOf('-') + 1, name.lastIndexOf('-'))
182
+ return lines(path.join(dir, file)).map((row) => ({ ...row, run: label, domain, model }))
183
+ })
184
+ )
185
+ const tau2Models = [...new Set(tau2.map((row) => row.model))].sort(bySize)
186
+ // the latest run is the one the headline cards report
187
+ const currentRun = tau2Specs.at(-1)?.label ?? ''
188
+
189
+ // a Terminal-Bench run is label=<harbor job dir>; a trial dir holds result.json and agent/qvac.jsonl
190
+ const terminal = all('--terminal').map((spec) => {
191
+ const [label, dir] = spec.split('=', 2)
192
+ const trials = fs
193
+ .readdirSync(dir, { withFileTypes: true })
194
+ .filter(
195
+ (entry) => entry.isDirectory() && fs.existsSync(path.join(dir, entry.name, 'result.json'))
196
+ )
197
+ .map((entry): TerminalTrial => {
198
+ const trial = path.join(dir, entry.name)
199
+ const result = JSON.parse(fs.readFileSync(path.join(trial, 'result.json'), 'utf8'))
200
+ const logFile = path.join(trial, 'agent', 'qvac.jsonl')
201
+ const log = fs.existsSync(logFile) ? lines(logFile) : []
202
+ const start = log.find((row) => row.type === 'start')
203
+ return {
204
+ task: result.task_name,
205
+ reward: result.verifier_result?.rewards?.reward ?? 0,
206
+ model: start?.model ?? null,
207
+ limit: start ? (start.max_tool_rounds ?? 'default') : null,
208
+ calls: log.filter((row) => row.type === 'call').length,
209
+ speed: speedOf(log.find((row) => row.type === 'done')?.metrics ?? null),
210
+ load: log.filter((row) => row.load).map((row) => row.load[0])
211
+ }
212
+ })
213
+
214
+ return { label, trials }
215
+ })
216
+
217
+ interface RunSpec {
218
+ readonly label: string
219
+ readonly dir: string
220
+ }
221
+
222
+ // a run is label=dir, or a bare dir
223
+ function labelled(spec: string): RunSpec {
224
+ const [label, dir] = spec.includes('=') ? spec.split('=', 2) : ['', spec]
225
+ return { label, dir }
226
+ }
227
+
228
+ // each model of a run's runs.jsonl becomes a column
229
+ function columnRows(spec: string) {
230
+ const { label, dir } = labelled(spec)
231
+ return lines(path.join(dir, 'runs.jsonl')).map((row) => ({
232
+ ...row,
233
+ column: label ? `${label} · ${short(row.model)}` : short(row.model)
234
+ }))
235
+ }
236
+
237
+ function page(): string {
238
+ const models = [
239
+ ...new Set([...bfcl, ...multiTurn].map((row) => row.model).concat(tau2Models))
240
+ ].sort(bySize)
241
+ return `<!doctype html>
242
+ <html lang="en">
243
+ <head>
244
+ <meta charset="utf-8">
245
+ <meta name="viewport" content="width=device-width, initial-scale=1">
246
+ <title>Harness Benchmarks</title>
247
+ <link rel="preconnect" href="https://fonts.googleapis.com">
248
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
249
+ <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Inconsolata:wght@400;600&display=swap" rel="stylesheet">
250
+ <style>${BRAND_STYLE}${STYLE}</style>
251
+ </head>
252
+ <body>
253
+ <main>
254
+ <header class="topbar"><a class="brand" href="#">${WORDMARK}<span>Harness</span></a><button class="toggle" type="button" id="theme">Theme</button></header>
255
+ <div class="hero">
256
+ <p class="eyebrow">Known benchmarks</p>
257
+ <h1>@qvac/harness on published benchmarks</h1>
258
+ <p class="lede">Built on PR #35 (the completion state machine) and PR #151 (the benchmark fixes); the harness-alignment work (waves 1 to 8, #155 onwards) lands on <code>dev</code>, measured here at 3ca35a56. Local Qwen3.5 models, Q4_K_M, on one Mac. Every number comes from a published benchmark's own tasks and scoring; nothing here is a task we wrote.</p>
259
+ <div class="versions">${models.map((model) => `<span class="chip"><i class="dot ours"></i><b>${escape(short(model))}</b></span>`).join('')}<span class="chip">BFCL v4 · gorilla ${BFCL_COMMIT.slice(0, 7)}</span><span class="chip">τ²-bench airline</span></div>
260
+ </div>
261
+ ${overview()}
262
+ ${bfclSection()}
263
+ ${tau2Section()}
264
+ ${speedSection()}
265
+ ${terminalSection()}
266
+ <section><h2><span class="num">05</span>Findings</h2><div class="notes">${FINDINGS}</div></section>
267
+ </main>
268
+ <div id="tip" role="tooltip" hidden></div>
269
+ <script>${SCRIPT}${THEME_SCRIPT}</script>
270
+ </body>
271
+ </html>
272
+ `
273
+ }
274
+
275
+ function overview(): string {
276
+ const underTest = (model: string) => (primary ? `${primary} · ${short(model)}` : short(model))
277
+ const models = [...new Set([...bfcl.map((row) => row.model), ...tau2Models])].sort(bySize)
278
+ const cards = models.map((model) => {
279
+ const strict = bfclMean(bfcl.filter((row) => row.column === underTest(model)))
280
+ const own = tau2.filter((row) => row.model === model && row.run === currentRun)
281
+ const tau = own.length ? `${pct(mean(own.map((row) => row.reward)))}` : '–'
282
+ return `<div class="card stat"><h3><i class="dot ours"></i>${escape(short(model))}</h3><div class="pair"><div><div class="figure">${pct(strict)}</div><div class="figure-label">BFCL strict</div></div><div><div class="figure">${tau}</div><div class="figure-label">τ² airline pass^1${own.length && own.length < TAU2_SAMPLE ? ` · ${own.length}/${TAU2_SAMPLE} so far` : ''}</div></div></div></div>`
283
+ })
284
+
285
+ return `<div class="cards">${cards.join('')}</div>`
286
+ }
287
+
288
+ function bfclSection(): string {
289
+ if (!bfcl.length && !multiTurn.length) {
290
+ return ''
291
+ }
292
+
293
+ return `<section>
294
+ <h2><span class="num">01</span>BFCL v4 <span class="tag">function calling</span></h2>
295
+ <p class="section-lede">The Berkeley Function Calling Leaderboard's eleven single-turn categories, up to 50 cases each, data and checker from gorilla commit <code>${BFCL_COMMIT.slice(0, 7)}</code>. Each case's functions are the run's only tools. <em>Strict</em> is BFCL's rule, every call in the first response. <em>Across rounds</em> also credits parallel calls the harness made one per round. Hover a strict cell for its failures. The #35 columns are figures from the #151 / PR #35 page; their run dirs were not retained, so they carry no failure detail.</p>
296
+ <div class="table-wrap"><table class="data">
297
+ <thead><tr><th scope="col">Category</th>${[...columns, ...BASELINE_COLUMNS].map((column) => `<th scope="col" colspan="2">${escape(column)}</th>`).join('')}</tr>
298
+ <tr><th></th>${[...columns, ...BASELINE_COLUMNS].map(() => '<th class="sub">strict</th><th class="sub">across rounds</th>').join('')}</tr></thead>
299
+ <tbody>${categories.map(bfclRow).join('')}</tbody>
300
+ </table></div>
301
+ ${shapeTable()}
302
+ ${multiTurnTable()}
303
+ </section>`
304
+ }
305
+
306
+ function multiTurnTable(): string {
307
+ if (!multiTurn.length) {
308
+ return ''
309
+ }
310
+
311
+ const rows = MULTI_TURN_CATEGORIES.map((category) => {
312
+ const cells = multiTurnColumns.map((column) => {
313
+ const own = multiTurn.filter((row) => row.column === column && row.category === category)
314
+ const tip = own
315
+ .filter((row) => !row.valid)
316
+ .slice(0, 6)
317
+ .map((row) => `${row.id}: ${row.error_type}`)
318
+ .join('\n')
319
+
320
+ return `<td>${own.length}</td>${cell(
321
+ own.map((row) => row.valid),
322
+ tip
323
+ )}`
324
+ })
325
+ return `<tr><th scope="row">${category}</th>${cells.join('')}</tr>`
326
+ })
327
+
328
+ return `<p class="section-lede" style="margin-top:20px">Multi-turn: BFCL v4's four multi-turn categories, each user turn one harness run on gorilla's own backends, scored by BFCL's <code>multi_turn_checker</code> applied to the harness's own behaviour (its system prompt, nudges, result cap, and retries that count toward the round limit), not to BFCL's bare prompt.</p>
329
+ <div class="table-wrap"><table class="data">
330
+ <thead><tr><th scope="col">Category</th>${multiTurnColumns.map((column) => `<th scope="col" colspan="2">${escape(column)}</th>`).join('')}</tr>
331
+ <tr><th></th>${multiTurnColumns.map(() => '<th class="sub">entries</th><th class="sub">pass</th>').join('')}</tr></thead>
332
+ <tbody>${rows.join('')}</tbody>
333
+ </table></div>
334
+ ${multiTurnFailures()}`
335
+ }
336
+
337
+ function multiTurnFailures(): string {
338
+ const types = [...new Set(multiTurn.flatMap((row) => (row.error_type ? [row.error_type] : [])))]
339
+ const classes = types.map((type) => {
340
+ const cells = multiTurnColumns.map(
341
+ (column) =>
342
+ `<td>${multiTurn.filter((row) => row.column === column && row.error_type === type).length}</td>`
343
+ )
344
+ return `<tr><th scope="row">${escape(type.replace('multi_turn:', ''))}</th>${cells.join('')}</tr>`
345
+ })
346
+
347
+ return `<p class="section-lede" style="margin-top:20px">Multi-turn failures by BFCL's own error type.</p>
348
+ <div class="table-wrap"><table class="data">
349
+ <thead><tr><th scope="col">Failure</th>${multiTurnColumns.map((column) => `<th scope="col">${escape(column)}</th>`).join('')}</tr></thead>
350
+ <tbody>${classes.join('')}</tbody>
351
+ </table></div>`
352
+ }
353
+
354
+ // runs from before the shape tag carry none, so the baseline is compared by its text
355
+ function shapeTable(): string {
356
+ const tagged = bfcl.filter((row) => row.shape)
357
+ const shaped = columns.filter((column) => tagged.some((row) => row.column === column))
358
+ if (!shaped.length) {
359
+ return ''
360
+ }
361
+
362
+ const rows = SHAPES.map((shape) => {
363
+ const cells = shaped.map((column) => {
364
+ const own = tagged.filter((row) => row.column === column && row.shape === shape)
365
+ return `<td>${own.length}</td>${cell(
366
+ own.map((row) => row.valid),
367
+ ''
368
+ )}${cell(
369
+ own.map((row) => row.wholeValid),
370
+ ''
371
+ )}`
372
+ })
373
+ return `<tr><th scope="row">${shape}</th>${cells.join('')}</tr>`
374
+ })
375
+
376
+ return `<p class="section-lede" style="margin-top:20px">By schema shape (#157). <em>Nested</em> cases have a parameter with fields or items, <em>default-only</em> ones a default and no nesting: the two the harness spells out in the tool description because the SDK drops them. <em>Untouched</em> reach the model as written. The #151 baseline was not tagged; it measured nested cases at 57% strict against 92% for flat ones. Strict here includes the parallel categories, which score 0% under the one-call limit and weigh most on untouched cases; without them 9B passes 77% nested, 80% default-only and 88% untouched.</p>
377
+ <div class="table-wrap"><table class="data">
378
+ <thead><tr><th scope="col">Shape</th>${shaped.map((column) => `<th scope="col" colspan="3">${escape(column)}</th>`).join('')}</tr>
379
+ <tr><th></th>${shaped.map(() => '<th class="sub">cases</th><th class="sub">strict</th><th class="sub">across rounds</th>').join('')}</tr></thead>
380
+ <tbody>${rows.join('')}</tbody>
381
+ </table></div>`
382
+ }
383
+
384
+ function bfclRow(category: string): string {
385
+ const cells = columns.map((column) => {
386
+ const own = bfcl.filter((row) => row.column === column && row.category === category)
387
+ const tip = own
388
+ .filter((row) => !row.valid)
389
+ .slice(0, 6)
390
+ .map((row) => `${row.id}: ${row.error ?? ''}`)
391
+ .join('\n')
392
+
393
+ return (
394
+ cell(
395
+ own.map((row) => row.valid),
396
+ tip
397
+ ) +
398
+ cell(
399
+ own.map((row) => row.wholeValid),
400
+ ''
401
+ )
402
+ )
403
+ })
404
+
405
+ const baseline = (BASELINE_BFCL[category] ?? []).map((percent) => figure(percent, ''))
406
+
407
+ return `<tr><th scope="row">${escape(category)}</th>${cells.join('')}${baseline.join('')}</tr>`
408
+ }
409
+
410
+ function tau2Section(): string {
411
+ const pairs = tau2Specs.flatMap(({ label }) =>
412
+ tau2Models
413
+ .filter((model) => tau2.some((row) => row.run === label && row.model === model))
414
+ .map((model) => ({ label, model }))
415
+ )
416
+ const rows = pairs.map(({ label, model }) => {
417
+ const own = tau2.filter((row) => row.model === model && row.run === label)
418
+ const writes = own.filter((row) => row.writes)
419
+ const refusals = own.filter((row) => !row.writes)
420
+ const capped = own.filter((row) => row.termination.endsWith('MAX_STEPS')).length
421
+ const unanswered = own.filter((row) => row.customer_never_ended).length
422
+ const lost = failedShare(own)
423
+ const tip = own
424
+ .map(
425
+ (row) =>
426
+ `task ${row.task} ${row.writes ? 'write' : 'read'}: ${row.reward} in ${Math.round(row.seconds)}s, ${row.termination.split('.').pop()}${row.customer_never_ended ? ', the customer never ended it' : ''}`
427
+ )
428
+ .join('\n')
429
+
430
+ return `<tr${label === currentRun ? ' class="current"' : ''}><th scope="row">${escape(label)}</th><th scope="row">${escape(short(model))}</th><td>${own.length}/${TAU2_SAMPLE}</td>${cell(
431
+ own.map((row) => row.reward === 1),
432
+ tip
433
+ )}${cell(
434
+ writes.map((row) => row.reward === 1),
435
+ ''
436
+ )}${cell(
437
+ refusals.map((row) => row.reward === 1),
438
+ ''
439
+ )}<td>${capped}${unanswered ? ` (${unanswered} waiting on the customer)` : ''}</td><td>${lost}</td><td>${own.length ? (mean(own.map((row) => row.seconds)) / 60).toFixed(1) : '–'} min</td></tr>`
440
+ })
441
+
442
+ const baseline = BASELINE_TAU2.map(
443
+ (row) =>
444
+ `<tr><th scope="row">${escape(row.run)}</th><th scope="row">${escape(row.model)}</th><td>${row.tasks}</td>${figure(row.pass, '')}${figure(row.writes, '')}${figure(row.refusals, '')}<td>${escape(row.capped)}</td><td>${row.lost}</td><td>${row.minutes} min</td></tr>`
445
+ )
446
+
447
+ return `<section>
448
+ <h2><span class="num">02</span>τ²-bench <span class="tag">multi-turn agent with a policy</span></h2>
449
+ <p class="section-lede">Sierra's τ²-bench airline domain: the harness is the customer-service agent, τ²'s orchestrator runs the tools against its own database, and τ²'s reward (database state and what was communicated) scores each task. ${TAU2_SAMPLE} tasks spread evenly over the 50, 13 of them needing database changes. The simulated customer is local Qwen3.5-9B, not the leaderboard's GPT-4.1, so scores are τ²'s own but not leaderboard-comparable. A round is cut off at three minutes. <em>Replies lost to errors</em> counts turns where the harness returned no answer; in the first run almost all came from one SDK grammar error (see findings). Those turns count against the agent, as the app would show the user an error. <em>Waiting on the customer</em> counts tasks that hit the step limit because the simulated customer never sent τ²'s end token: it answered a correct hand-off with \"I will continue to hold\" instead of <code>###TRANSFER###</code>, or wrote a bare STOP instead of <code>###STOP###</code>. τ² scores those 0; the loss is the local customer model's, not the agent's.</p>
450
+ ${
451
+ rows.length
452
+ ? `<div class="table-wrap"><table class="data">
453
+ <thead><tr><th scope="col">Run</th><th scope="col">Agent model</th><th scope="col">Tasks</th><th scope="col">pass^1</th><th scope="col">needs a change</th><th scope="col">policy says refuse</th><th scope="col">hit 60 steps</th><th scope="col">replies lost to errors</th><th scope="col">avg per task</th></tr></thead>
454
+ <tbody>${[...baseline, ...rows].join('')}</tbody>
455
+ </table></div>`
456
+ : '<p class="pending">Running; no task finished yet.</p>'
457
+ }
458
+ ${TAU2_CHANGES}
459
+ </section>`
460
+ }
461
+
462
+ // every failure traced to a cause, and the issue that acts on it
463
+ const FINDINGS = `
464
+ <h2>Re-run on dev</h2>
465
+ <p>
466
+ <a href="https://github.com/tetherto/qvac-harness/issues/184">#184</a> re-runs both benchmarks on
467
+ <code>dev</code> at 3ca35a56, after waves 1 to 8, with the #151 settings. BFCL across rounds,
468
+ all 456 cases against the #35 figures: 2B 72.1% to 74.8%, 4B 80.3% to 83.8%, 9B 82.5% to 82.9%.
469
+ Simple, multiple and parallel calls gained (9B <code>simple_python</code> 88% to 98%, 9B
470
+ <code>live_parallel_multiple</code> 50% to 75% across rounds). The irrelevance categories
471
+ regressed, most on 9B <code>live_irrelevance</code>, 82% to 64%: models call an offered tool
472
+ where none applies (<a href="https://github.com/tetherto/qvac-harness/issues/252">#252</a>, being
473
+ bisected). τ²-bench regressed against #151: pass^1 45% to 30% on 2B and 60% to 25% on 9B, with
474
+ 1 of 13 tasks that need a database change passed by each model (38% and 46% at #151). Replies
475
+ lost to errors stayed low, 5 of 130 on 2B and 9 of 179 on 9B, all empty replies, none to the
476
+ grammar fault. Not yet traced; the runs overlapped heavy load on the Mac (see speed).
477
+ </p>
478
+ <h2>Every failure, traced</h2>
479
+ <p>
480
+ Every failure in both benchmarks was traced to a root cause: 619 BFCL strict failures, each τ²
481
+ transcript, and the original BFCL <code>ast_checker.py</code> re-run on our rows, which flips 0
482
+ verdicts. They split into bugs that code can fix, limits in the SDK, model weaknesses, and gaps
483
+ that need a design change.
484
+ </p>
485
+ <table class="findings">
486
+ <thead>
487
+ <tr>
488
+ <th>Failure</th>
489
+ <th>Kind</th>
490
+ <th>Evidence</th>
491
+ <th>Status</th>
492
+ </tr>
493
+ </thead>
494
+ <tbody>
495
+ <tr>
496
+ <td>
497
+ <strong>τ² runs leaked into later tasks.</strong> A task ended on an unanswered call left
498
+ its harness run alive, so later tasks got replies one step behind and earlier writes hit
499
+ later tasks' databases.
500
+ </td>
501
+ <td>Code bug (our τ² adapter)</td>
502
+ <td>Decided 15 of 16 2B and 8 of 14 4B τ² failures</td>
503
+ <td>
504
+ Fixed in PR #151; the first τ² run is withdrawn (see the before and after table above).
505
+ </td>
506
+ </tr>
507
+ <tr>
508
+ <td>
509
+ <strong>Wrong clock.</strong> The harness stamps today (2026) on the user's turn; τ²'s
510
+ policy is set on 2024-05-15, so the agent refuses "past" flights. BFCL dates drift to 2026
511
+ too.
512
+ </td>
513
+ <td>Harness gap</td>
514
+ <td>τ² refusals; 11 BFCL rows</td>
515
+ <td>
516
+ Fixed: <code>CompletionContext.now</code>. BFCL re-run on <code>dev</code> done
517
+ (<a href="https://github.com/tetherto/qvac-harness/issues/184">#184</a>).
518
+ </td>
519
+ </tr>
520
+ <tr>
521
+ <td>
522
+ <strong>llama.cpp tool-grammar fault</strong> ("Unexpected empty grammar stack after
523
+ accepting piece") ends long tool-calling turns.
524
+ </td>
525
+ <td>SDK bug</td>
526
+ <td>
527
+ 86% of 9B's τ² replies before #151; about a quarter of 2B's and 4B's. None lost
528
+ to it on <code>dev</code> at 3ca35a56: 5 of 130 2B and 9 of 179 9B replies lost, all empty
529
+ replies
530
+ </td>
531
+ <td>
532
+ Worked around: the harness retries the round without the grammar. Draft PR #146 finds the
533
+ cause, past calls written back in a non-Qwen format the model copies. Structured history
534
+ merged (<a href="https://github.com/tetherto/qvac-harness/issues/169">#169</a>, on PR #146).
535
+ SDK ask in <a href="https://github.com/tetherto/qvac-harness/issues/185">#185</a>.
536
+ </td>
537
+ </tr>
538
+ <tr>
539
+ <td>
540
+ <strong>One tool call per response.</strong> The SDK returns exactly one parsed call, so
541
+ independent calls each cost a full round.
542
+ </td>
543
+ <td>SDK limit</td>
544
+ <td>
545
+ 410 BFCL rows; parallel 0% strict vs 72–90% across rounds; a prompt rewording changed
546
+ nothing
547
+ </td>
548
+ <td>
549
+ The cap comes from llama.cpp's tool grammar (<a
550
+ href="https://github.com/tetherto/qvac-harness/issues/161"
551
+ >#161</a
552
+ >); SDK ask in <a href="https://github.com/tetherto/qvac-harness/issues/185">#185</a>.
553
+ Concurrent completions on one model:
554
+ <a href="https://github.com/tetherto/qvac-harness/issues/192">#192</a>.
555
+ </td>
556
+ </tr>
557
+ <tr>
558
+ <td>
559
+ <strong>Nested tool schemas stripped.</strong> The SDK's <code>toolSchema</code> drops
560
+ nested <code>properties</code>, <code>items</code> enums, <code>required</code> and
561
+ <code>default</code>, so models invent field names.
562
+ </td>
563
+ <td>SDK bug</td>
564
+ <td>32 strict and 7 parallel BFCL rows; nested-schema cases pass 57% vs 92%</td>
565
+ <td>
566
+ Harness stopgap merged: nested fields are described in the tool description (<a
567
+ href="https://github.com/tetherto/qvac-harness/issues/157"
568
+ >#157</a
569
+ >); dev re-run: 9B <code>simple_python</code> 88% to 98%. SDK ask in <a href="https://github.com/tetherto/qvac-harness/issues/185">#185</a>.
570
+ </td>
571
+ </tr>
572
+ <tr>
573
+ <td>
574
+ <strong>A tool's own <code>intent</code> argument stripped</strong> along with the
575
+ harness's.
576
+ </td>
577
+ <td>Code bug (harness)</td>
578
+ <td>6 BFCL rows; hits any MCP or skill tool with an intent field</td>
579
+ <td>Fixed in PR #151.</td>
580
+ </tr>
581
+ <tr>
582
+ <td>
583
+ <strong>Run tools clashed with same-named built-ins</strong>; the refused request also broke
584
+ the next run's cache write.
585
+ </td>
586
+ <td>Code bug (harness)</td>
587
+ <td>6 BFCL rows</td>
588
+ <td>Fixed (run tool replaces the built-in). The cache cascade needs its own look.</td>
589
+ </tr>
590
+ <tr>
591
+ <td><strong>An SDK error mid-stream leaves an unhandled promise rejection.</strong></td>
592
+ <td>Code bug (harness, pre-existing)</td>
593
+ <td>Reproduced on #35 without this PR</td>
594
+ <td>
595
+ Does not reproduce on <code>dev</code>; regression tests merged (<a
596
+ href="https://github.com/tetherto/qvac-harness/issues/160"
597
+ >#160</a
598
+ >).
599
+ </td>
600
+ </tr>
601
+ <tr>
602
+ <td>
603
+ <strong>Text-only history between turns.</strong> Earlier tool results reach later turns
604
+ only as a short text log, so facts get lost and results get invented.
605
+ </td>
606
+ <td>Adapter, and a design question for the app</td>
607
+ <td>Invented flight in one τ² task; a flight number cut off in another</td>
608
+ <td>
609
+ Log raised from 600 to 4000 characters. Structured tool turns merged
610
+ (<a href="https://github.com/tetherto/qvac-harness/issues/169">#169</a>).
611
+ </td>
612
+ </tr>
613
+ <tr>
614
+ <td>
615
+ <strong>Calls a tool when none applies</strong>, or
616
+ <strong>asks for clarification instead of calling</strong>.
617
+ </td>
618
+ <td>Model, plus a prompt trade-off</td>
619
+ <td>51 and 22 BFCL rows</td>
620
+ <td>Worse on <code>dev</code>: live_irrelevance 82% to 64% (9B), 84% to 78% (4B) and 76% to
621
+ 72% (2B); irrelevance 74% to 70% (2B). Bisecting in
622
+ <a href="https://github.com/tetherto/qvac-harness/issues/252">#252</a>.</td>
623
+ </tr>
624
+ <tr>
625
+ <td><strong>Wrong values, wrong function, invented parameters</strong></td>
626
+ <td>Model weakness</td>
627
+ <td>About 50 BFCL rows, 13 of the 20 genuine value errors on 2B</td>
628
+ <td>
629
+ Argument conversion to the schema's types merged (<a
630
+ href="https://github.com/tetherto/qvac-harness/issues/156"
631
+ >#156</a
632
+ >); refusing cut-off calls merged (<a
633
+ href="https://github.com/tetherto/qvac-harness/issues/155"
634
+ >#155</a
635
+ >). The rest: model routing, constrained decoding or fine-tuning.
636
+ </td>
637
+ </tr>
638
+ <tr>
639
+ <td>
640
+ <strong>Policy slips in τ²:</strong> quoting a price for one passenger instead of three,
641
+ upgrading without quoting the cost, cancelling against policy, retrying with a wrong
642
+ argument name.
643
+ </td>
644
+ <td>Model, plus a design gap</td>
645
+ <td>The clean 4B τ² tasks</td>
646
+ <td>
647
+ Design: quote and confirm before any write, and a policy check before cancellations or
648
+ compensation (<a href="https://github.com/tetherto/qvac-harness/issues/183">#183</a>).
649
+ </td>
650
+ </tr>
651
+ <tr>
652
+ <td>
653
+ <strong>BFCL answers we'd dispute</strong> (percentages expected as decimals, "City, ST"
654
+ against the schema's own example)
655
+ </td>
656
+ <td>Benchmark key</td>
657
+ <td>26 rows</td>
658
+ <td>Kept as scored, so the numbers stay comparable with the leaderboard.</td>
659
+ </tr>
660
+ </tbody>
661
+ </table>
662
+ <h2>Skill coverage gaps (inventory, not a benchmark)</h2>
663
+ <ul>
664
+ <li>
665
+ <strong>No browser tool.</strong> Nothing can open, click, fill or screenshot a page.
666
+ <code>web_fetch</code> reads a URL and <code>http_request</code> calls an allow-listed API;
667
+ neither drives a browser. The same goes for timers, notifications, the clipboard and the screen.
668
+ </li>
669
+ <li>
670
+ <strong>Mobile gets 5 of 20 skills</strong>: diagrams, github, notion, spotify and weather.
671
+ Word, Excel, PowerPoint and PDF run Python in Pyodide and are desktop-only; image and music
672
+ generation are desktop-only too.
673
+ </li>
674
+ <li>
675
+ <strong>9 skills need a connected account</strong> (Asana, GitHub, Gmail, Google
676
+ Calendar/Docs/Drive/Sheets, Notion, Spotify). Apple Notes and Reminders are macOS-only and need
677
+ a host binary.
678
+ </li>
679
+ <li>
680
+ <strong>No skill is gated by model size.</strong> The catalog supports
681
+ <code>minCapabilityProfile</code>, but no bundled skill sets it, so the 2B model is offered the
682
+ document skills that are flaky on small models (<a
683
+ href="https://github.com/tetherto/qvac-harness/issues/182"
684
+ >#182</a
685
+ >). None of the benchmarks here covers documents.
686
+ </li>
687
+ <li>
688
+ <strong>Other harness findings:</strong> exec's approval label hides a Python run;
689
+ <code>rag_search</code> was never registered and is removed (<a
690
+ href="https://github.com/tetherto/qvac-harness/issues/162"
691
+ >#162</a
692
+ >; retrieval returns as a plugin in
693
+ <a href="https://github.com/tetherto/qvac-harness/issues/197">#197</a>); the round limit is an
694
+ input since <a href="https://github.com/tetherto/qvac-harness/issues/159">#159</a>; BFCL and
695
+ τ² on <code>dev</code> ran on the default limit with no regression; an agent with
696
+ <code>skillsScope</code> unset gets no skills.
697
+ </li>
698
+ </ul>
699
+ `
700
+
701
+ // what separates the runs in the table; each line is a commit on PR #151
702
+ const TAU2_CHANGES = `<div class="cards" style="margin-top:14px"><div class="card"><h3>What changed between the runs</h3>
703
+ <p>The first run is withdrawn: tracing every failure showed most were ours, not the model's. Each fix below is a commit on PR #151 with its own test.</p>
704
+ <ul class="marks plus">
705
+ <li><strong>Runs leaked across tasks.</strong> A task that ended on an unanswered call left its harness run alive, so later tasks got replies one step behind and writes hit the wrong database. The bridge now matches frames by run and cancels an abandoned run.</li>
706
+ <li><strong>The wrong date.</strong> The harness stamped today on every turn while τ²'s policy is set in May 2024, so the agent refused "past" flights. A run can now set its clock (<code>CompletionContext.now</code>).</li>
707
+ <li><strong>The llama.cpp tool-grammar fault</strong> ("Unexpected empty grammar stack") ended 86% of 9B's replies. The harness now retries such a round without the grammar.</li>
708
+ <li><strong>A tool's own <code>intent</code> argument</strong> was stripped with the harness's, and a run's tools clashed with same-named built-ins.</li>
709
+ <li><strong>The runaway deadline</strong> cut off whole turns of many short rounds; it now applies to each round.</li>
710
+ <li><strong>The simulated customer</strong> dropped the hashes from τ²'s end tokens, so finished tasks ran to the step limit; a bare <code>STOP</code> is now read as <code>###STOP###</code>.</li>
711
+ </ul></div></div>`
712
+
713
+ function terminalSection(): string {
714
+ const rows = terminal.map(({ label, trials }) => {
715
+ const models = [
716
+ ...new Set(trials.flatMap((trial) => (trial.model ? [short(trial.model)] : [])))
717
+ ]
718
+ const limits = [
719
+ ...new Set(trials.flatMap((trial) => (trial.limit === null ? [] : [trial.limit])))
720
+ ]
721
+ const speed = trials.reduce(
722
+ (sum, trial) => (trial.speed ? addSpeed(sum, trial.speed) : sum),
723
+ NO_SPEED
724
+ )
725
+ const decode = speed.decodeMs ? (speed.generatedTokens / speed.decodeMs) * 1000 : null
726
+ const prefill = speed.prefillMs ? (speed.promptTokens / speed.prefillMs) * 1000 : null
727
+ const load = trials.flatMap((trial) => trial.load)
728
+ const tip = trials
729
+ .map((trial) => `${trial.task}: ${trial.reward}, ${trial.calls} calls`)
730
+ .join('\n')
731
+
732
+ return `<tr><th scope="row">${escape(label)}</th><th scope="row">${escape(models.join(', ') || '–')}</th><td>${trials.length}</td>${cell(
733
+ trials.map((trial) => trial.reward === 1),
734
+ tip
735
+ )}<td>${escape(limits.join(', '))}</td><td>${trials.length ? (trials.reduce((sum, trial) => sum + trial.calls, 0) / trials.length).toFixed(1) : '–'}</td><td>${prefill === null ? '–' : Math.round(prefill)}</td><td>${decode === null ? '–' : decode.toFixed(1)}</td><td>${load.length ? `${Math.min(...load).toFixed(1)} to ${Math.max(...load).toFixed(1)}` : '–'}</td></tr>`
736
+ })
737
+
738
+ return `<section>
739
+ <h2><span class="num">04</span>Terminal-Bench 2.0 <span class="tag">agentic terminal tasks</span></h2>
740
+ <p class="section-lede">Harbor runs each task in its own container and its verifier scores it; the harness is the agent, with one bash tool that runs in the container (<code>bench/bridge/python/qvac_bench/harbor_agent.py</code>). <em>Pass</em> is the share of tasks the verifier rewarded 1. <em>Round limit</em> is the run's tool-round limit. <em>Load</em> is the one-minute load average at the start and end of each task. Tracked in <a href="https://github.com/tetherto/qvac-harness/issues/249">#249</a>.</p>
741
+ ${
742
+ rows.length
743
+ ? `<div class="table-wrap"><table class="data">
744
+ <thead><tr><th scope="col">Run</th><th scope="col">Model</th><th scope="col">Tasks</th><th scope="col">Pass</th><th scope="col">Round limit</th><th scope="col">Calls per task</th><th scope="col">Prefill tok/s</th><th scope="col">Decode tok/s</th><th scope="col">Load</th></tr></thead>
745
+ <tbody>${rows.join('')}</tbody>
746
+ </table></div>`
747
+ : '<p class="pending">Not run yet: it needs a container runtime and the <code>harbor</code> CLI. See <code>bench/README.md</code>.</p>'
748
+ }
749
+ </section>`
750
+ }
751
+
752
+ function speedSection(): string {
753
+ const sources = [
754
+ ...bfcl.map((row) => ({ bench: 'BFCL', model: row.model, speed: row.speed })),
755
+ ...multiTurn.map((row) => ({ bench: 'BFCL multi-turn', model: row.model, speed: row.speed })),
756
+ ...tau2
757
+ .filter((row) => row.run === currentRun)
758
+ .map((row) => ({ bench: 'τ² airline', model: row.model, speed: row.speed }))
759
+ ]
760
+ const groups = new Map<string, { bench: string; model: string; speed: Speed }>()
761
+ for (const { bench, model, speed } of sources) {
762
+ if (!speed) continue
763
+ const key = `${bench}\0${model}`
764
+ const group = groups.get(key) ?? { bench, model, speed: NO_SPEED }
765
+ groups.set(key, { ...group, speed: addSpeed(group.speed, speed) })
766
+ }
767
+
768
+ const rows = [...groups.values()]
769
+ .sort((left, right) => bySize(left.model, right.model))
770
+ .map(({ bench, model, speed }) => {
771
+ const prefill = speed.prefillMs ? (speed.promptTokens / speed.prefillMs) * 1000 : null
772
+ const decode = speed.decodeMs ? (speed.generatedTokens / speed.decodeMs) * 1000 : null
773
+ const ttft = median(speed.ttftMs)
774
+ const share = (ms: number) => (speed.wallMs ? pct(ms / speed.wallMs) : '–')
775
+ return `<tr><th scope="row">${escape(bench)}</th><th scope="row">${escape(short(model))}</th><td>${speed.runs}</td><td>${(speed.rounds / Math.max(1, speed.runs)).toFixed(1)}</td><td>${prefill === null ? '–' : Math.round(prefill)}</td><td>${decode === null ? '–' : decode.toFixed(1)}</td><td>${ttft === null ? '–' : `${Math.round(ttft)} ms`}</td><td>${share(speed.modelMs)}</td><td>${share(speed.toolMs)}</td></tr>`
776
+ })
777
+
778
+ return `<section>
779
+ <h2><span class="num">03</span>Speed <span class="tag">per model</span></h2>
780
+ <p class="section-lede">From each run's <code>metrics</code> event. Prefill and decode rates are weighted by tokens: all the tokens a model prefilled or generated, over the time it took. <em>First token</em> is the median wait for each run's first round. <em>Model</em> and <em>tools</em> are shares of wall time. τ² runs carry no cache key, so every τ² round prefills cold. The <code>dev</code> runs at 3ca35a56 overlapped other work on the Mac: the one-minute load average went from 4 to 330 on 14 cores. Read their rates as lower bounds, not against #151.</p>
781
+ ${
782
+ rows.length
783
+ ? `<div class="table-wrap"><table class="data">
784
+ <thead><tr><th scope="col">Benchmark</th><th scope="col">Model</th><th scope="col">Runs</th><th scope="col">Rounds per run</th><th scope="col">Prefill tok/s</th><th scope="col">Decode tok/s</th><th scope="col">First token</th><th scope="col">Model</th><th scope="col">Tools</th></tr></thead>
785
+ <tbody>${rows.join('')}</tbody>
786
+ </table></div>`
787
+ : '<p class="pending">No run here recorded speed yet; runs from before issue #195 carry none.</p>'
788
+ }
789
+ </section>`
790
+ }
791
+
792
+ function median(values: readonly number[]): number | null {
793
+ if (!values.length) {
794
+ return null
795
+ }
796
+
797
+ const sorted = [...values].sort((left, right) => left - right)
798
+ const middle = Math.floor(sorted.length / 2)
799
+
800
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
801
+ }
802
+
803
+ function failedShare(rows: readonly Tau2Row[]): string {
804
+ const replies = rows.reduce((sum, row) => sum + (row.replies ?? 0), 0)
805
+ const failed = rows.reduce((sum, row) => sum + (row.failed_turns ?? 0), 0)
806
+
807
+ return replies ? `${failed}/${replies} (${pct(failed / replies)})` : '–'
808
+ }
809
+
810
+ function cell(results: readonly boolean[], tip: string): string {
811
+ return figure(results.length ? Math.round(share(results) * 100) : null, tip)
812
+ }
813
+
814
+ function figure(percent: number | null, tip: string): string {
815
+ const data = tip ? ` data-tip="${escape(tip)}"` : ''
816
+
817
+ return `<td${data}><div class="bar"><span style="width:${percent ?? 0}%"></span></div><span class="value">${percent === null ? '–' : `${percent}%`}</span></td>`
818
+ }
819
+
820
+ // a column that ran a subset is averaged over what it ran, not over categories it never saw
821
+ function bfclMean(rows: readonly BfclRow[]): number {
822
+ const perCategory = categories
823
+ .map((category) => rows.filter((row) => row.category === category).map((row) => row.valid))
824
+ .filter((results) => results.length)
825
+ .map(share)
826
+
827
+ return mean(perCategory)
828
+ }
829
+
830
+ function share(results: readonly boolean[]): number {
831
+ return results.length ? results.filter(Boolean).length / results.length : 0
832
+ }
833
+
834
+ function mean(values: readonly number[]): number {
835
+ return values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length)
836
+ }
837
+
838
+ function pct(value: number): string {
839
+ return `${Math.round(value * 100)}%`
840
+ }
841
+
842
+ function bySize(left: string, right: string): number {
843
+ return (
844
+ parseFloat(left.replace(/^\D+/, '').replace(/^[\d.]+-/, '')) -
845
+ parseFloat(right.replace(/^\D+/, '').replace(/^[\d.]+-/, ''))
846
+ )
847
+ }
848
+
849
+ function short(model: string): string {
850
+ return model.replace(/-Q\d.*$/, '')
851
+ }
852
+
853
+ function lines(file: string) {
854
+ return fs
855
+ .readFileSync(file, 'utf8')
856
+ .split('\n')
857
+ .filter(Boolean)
858
+ .map((line) => JSON.parse(line))
859
+ }
860
+
861
+ function escape(text: string): string {
862
+ return text
863
+ .replaceAll('&', '&amp;')
864
+ .replaceAll('<', '&lt;')
865
+ .replaceAll('>', '&gt;')
866
+ .replaceAll('"', '&quot;')
867
+ }
868
+
869
+ // the QVAC look: tokens from qvac-workbench ui/src/tokens.ts
870
+ const WORDMARK = `<svg aria-label="QVAC" role="img" height="18" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 13" fill="none"><path d="M98.1597 0.199383C98.0421 0.0727478 97.8793 0 97.7057 0H80.0164C79.8454 0 79.6799 0.0727478 79.5624 0.199383L76.6921 3.30059C76.5853 3.41645 76.5266 3.57003 76.5266 3.7263V8.15314C76.5266 8.31211 76.5853 8.46299 76.6921 8.57885L79.5624 11.6801C79.6799 11.8067 79.8428 11.8794 80.0164 11.8794H97.7057C97.8766 11.8794 98.0421 11.8067 98.1597 11.6801L101.03 8.57885C101.137 8.46299 101.195 8.30941 101.195 8.15314V7.38524C101.195 7.04037 100.918 6.76015 100.576 6.76015H82.2218C82.051 6.76015 81.8854 6.68741 81.768 6.56077C81.6611 6.44491 81.6024 6.29133 81.6024 6.13506V5.74438C81.6024 5.58541 81.6611 5.43453 81.768 5.31867C81.8854 5.19203 82.0483 5.11929 82.2218 5.11929H100.576C100.918 5.11929 101.195 4.83907 101.195 4.4942V3.7263C101.195 3.56733 101.137 3.41645 101.03 3.30059L98.1597 0.199383Z" fill="currentColor" /><path d="M26.708 6.78433H25.1486V3.75048C25.1486 3.59151 25.09 3.44063 24.9831 3.32477L22.1127 0.220867C21.9953 0.0942321 21.8324 0.0214844 21.6589 0.0214844H3.96954C3.79865 0.0214844 3.63311 0.0942321 3.51563 0.220867L0.645281 3.32208C0.538478 3.43794 0.479736 3.59151 0.479736 3.74779V8.17732C0.479736 8.33629 0.538478 8.48717 0.645281 8.60303L3.51563 11.7042C3.63311 11.8308 3.79599 11.9036 3.96954 11.9036H30.9188C31.0896 11.9009 31.2551 11.8281 31.3727 11.7015C31.3727 11.7015 27.0524 6.78164 26.7106 6.78164L26.708 6.78433ZM20.0756 6.15655C20.0756 6.50142 19.7978 6.78164 19.4561 6.78164H6.17236C6.00148 6.78164 5.83593 6.70889 5.71845 6.58226C5.61164 6.4664 5.55291 6.31282 5.55291 6.15655V5.76856C5.55291 5.60959 5.61164 5.45871 5.72112 5.34285C5.8386 5.21621 6.00148 5.14347 6.17503 5.14347H19.4454C19.6109 5.14347 19.7685 5.20813 19.8833 5.32668L19.8966 5.34015C20.0141 5.45871 20.0781 5.61767 20.0781 5.78203V6.15655H20.0756Z" fill="currentColor" /><path d="M51.2249 3.05271L48.5842 0.199383C48.4666 0.0727478 48.3037 0 48.1302 0H40.7181C40.3763 0 40.0986 0.280214 40.0986 0.625092V6.13506C40.0986 6.3075 40.0265 6.47455 39.901 6.5931C39.7862 6.70088 39.634 6.76285 39.4791 6.76285H39.092C38.9344 6.76285 38.7823 6.70357 38.6701 6.5931C38.5446 6.47455 38.4725 6.31019 38.4725 6.13506V0.625092C38.4725 0.280214 38.1948 0 37.853 0H30.4409C30.27 0 30.1045 0.0727478 29.9869 0.199383L27.3462 3.05271C27.1247 3.29251 27.1247 3.66703 27.3462 3.90682L34.5368 11.6828C34.6543 11.8094 34.8171 11.8821 34.9907 11.8821H43.5804C43.7513 11.8821 43.9168 11.8094 44.0343 11.6828L51.2249 3.90682C51.4464 3.66703 51.4464 3.29251 51.2249 3.05271Z" fill="currentColor" /><path d="M67.3815 0.199383C67.264 0.0727478 67.1012 0 66.9275 0H58.3379C58.167 0 58.0015 0.0727478 57.8839 0.199383L50.6934 7.97531C50.4718 8.21511 50.4718 8.58962 50.6934 8.82942L53.3341 11.6828C53.4516 11.8094 53.6145 11.8821 53.7881 11.8821H61.2002C61.542 11.8821 61.8197 11.6019 61.8197 11.257V5.74707C61.8197 5.57463 61.8918 5.40758 62.0173 5.28903C62.132 5.18126 62.2842 5.11929 62.4391 5.11929H62.8263C62.9838 5.11929 63.136 5.17856 63.2482 5.28903C63.3736 5.40758 63.4458 5.57194 63.4458 5.74707V11.257C63.4458 11.6019 63.7235 11.8821 64.0652 11.8821H71.4774C71.6482 11.8821 71.8138 11.8094 71.9313 11.6828L74.572 8.82942C74.7936 8.58962 74.7936 8.21511 74.572 7.97531L67.3815 0.199383Z" fill="currentColor" /><path d="M109 8.67795L107.954 6.86464C107.911 6.7919 107.831 6.74609 107.746 6.74609H104.082C103.997 6.74609 103.916 6.7919 103.874 6.86464L102.827 8.67795C102.802 8.72106 102.795 8.76956 102.8 8.81536C102.8 8.81536 102.805 8.83422 102.805 8.8423C102.813 8.88811 102.829 8.93391 102.867 8.96894L105.743 11.8007C105.79 11.8465 105.852 11.8708 105.913 11.8681C105.975 11.8681 106.036 11.8465 106.085 11.8007L108.96 8.96894C108.995 8.93391 109.011 8.8908 109.019 8.845C109.019 8.83422 109.024 8.82614 109.024 8.81536C109.027 8.76956 109.024 8.72106 108.997 8.67795H109Z" fill="currentColor" /><path d="M98.1597 0.199383C98.0421 0.0727478 97.8793 0 97.7057 0H80.0164C79.8454 0 79.6799 0.0727478 79.5624 0.199383L76.6921 3.30059C76.5853 3.41645 76.5266 3.57003 76.5266 3.7263V8.15314C76.5266 8.31211 76.5853 8.46299 76.6921 8.57885L79.5624 11.6801C79.6799 11.8067 79.8428 11.8794 80.0164 11.8794H97.7057C97.8766 11.8794 98.0421 11.8067 98.1597 11.6801L101.03 8.57885C101.137 8.46299 101.195 8.30941 101.195 8.15314V7.38525C101.195 7.04037 100.918 6.76015 100.576 6.76015H82.2218C82.051 6.76015 81.8854 6.68741 81.768 6.56077C81.6611 6.44491 81.6024 6.29133 81.6024 6.13506V5.74438C81.6024 5.58541 81.6611 5.43453 81.768 5.31867C81.8854 5.19203 82.0483 5.11929 82.2218 5.11929H100.576C100.918 5.11929 101.195 4.83907 101.195 4.4942V3.7263C101.195 3.56734 101.137 3.41645 101.03 3.30059L98.1597 0.199383Z" fill="currentColor" /><path d="M26.708 6.78433H25.1486V3.75048C25.1486 3.59151 25.09 3.44063 24.9831 3.32477L22.1128 0.220867C21.9953 0.0942322 21.8324 0.0214844 21.6589 0.0214844H3.96954C3.79865 0.0214844 3.63311 0.0942322 3.51563 0.220867L0.645281 3.32208C0.538478 3.43794 0.479736 3.59151 0.479736 3.74779V8.17732C0.479736 8.33629 0.538478 8.48717 0.645281 8.60303L3.51563 11.7042C3.63311 11.8308 3.79599 11.9036 3.96954 11.9036H30.9188C31.0896 11.9009 31.2551 11.8281 31.3727 11.7015C31.3727 11.7015 27.0524 6.78164 26.7106 6.78164L26.708 6.78433ZM20.0756 6.15655C20.0756 6.50142 19.7978 6.78164 19.4561 6.78164H6.17236C6.00148 6.78164 5.83593 6.70889 5.71845 6.58226C5.61164 6.4664 5.55291 6.31282 5.55291 6.15655V5.76856C5.55291 5.60959 5.61164 5.45871 5.72112 5.34285C5.8386 5.21621 6.00148 5.14347 6.17503 5.14347H19.4454C19.6109 5.14347 19.7685 5.20813 19.8833 5.32668L19.8966 5.34015C20.0141 5.45871 20.0781 5.61767 20.0781 5.78203V6.15655H20.0756Z" fill="currentColor" /><path d="M51.2249 3.05271L48.5842 0.199383C48.4666 0.0727478 48.3038 0 48.1302 0H40.7181C40.3763 0 40.0986 0.280214 40.0986 0.625092V6.13506C40.0986 6.3075 40.0265 6.47455 39.901 6.5931C39.7862 6.70088 39.634 6.76285 39.4791 6.76285H39.092C38.9344 6.76285 38.7823 6.70357 38.6701 6.5931C38.5446 6.47455 38.4725 6.3102 38.4725 6.13506V0.625092C38.4725 0.280214 38.1948 0 37.853 0H30.4409C30.27 0 30.1045 0.0727478 29.9869 0.199383L27.3462 3.05271C27.1247 3.29251 27.1247 3.66703 27.3462 3.90682L34.5368 11.6828C34.6543 11.8094 34.8171 11.8821 34.9907 11.8821H43.5804C43.7513 11.8821 43.9168 11.8094 44.0343 11.6828L51.2249 3.90682C51.4464 3.66703 51.4464 3.29251 51.2249 3.05271Z" fill="currentColor" /><path d="M67.3815 0.199383C67.264 0.0727478 67.1012 0 66.9275 0H58.3379C58.167 0 58.0015 0.0727478 57.8839 0.199383L50.6934 7.97531C50.4718 8.21511 50.4718 8.58963 50.6934 8.82942L53.3341 11.6828C53.4516 11.8094 53.6145 11.8821 53.7881 11.8821H61.2002C61.542 11.8821 61.8197 11.6019 61.8197 11.257V5.74707C61.8197 5.57463 61.8918 5.40758 62.0173 5.28903C62.132 5.18126 62.2842 5.11929 62.4391 5.11929H62.8263C62.9839 5.11929 63.136 5.17856 63.2482 5.28903C63.3736 5.40758 63.4458 5.57194 63.4458 5.74707V11.257C63.4458 11.6019 63.7235 11.8821 64.0652 11.8821H71.4774C71.6482 11.8821 71.8138 11.8094 71.9313 11.6828L74.572 8.82942C74.7936 8.58963 74.7936 8.21511 74.572 7.97531L67.3815 0.199383Z" fill="currentColor" /><path d="M109 8.67795L107.954 6.86464C107.911 6.7919 107.831 6.74609 107.746 6.74609H104.082C103.997 6.74609 103.916 6.7919 103.874 6.86464L102.827 8.67795C102.802 8.72106 102.795 8.76956 102.8 8.81536C102.8 8.81536 102.805 8.83422 102.805 8.8423C102.813 8.88811 102.829 8.93391 102.867 8.96894L105.743 11.8007C105.79 11.8465 105.852 11.8708 105.913 11.8681C105.975 11.8681 106.036 11.8465 106.085 11.8007L108.96 8.96894C108.995 8.93391 109.011 8.8908 109.019 8.845C109.019 8.83422 109.024 8.82614 109.024 8.81536C109.027 8.76956 109.024 8.72106 108.997 8.67795H109Z" fill="currentColor" /></svg>`
871
+
872
+ const BRAND_STYLE = `:root {
873
+ color-scheme: light;
874
+ --canvas: #eef0f3; --bg: #f8f9fa; --surface: #ffffff; --raised: #eef0f3;
875
+ --fg: #1c1e21; --fg-muted: #646a73; --border: #e9ecef;
876
+ --primary: #08735f; --primary-soft: #e3f4ef; --on-primary: #ffffff;
877
+ --ours-bg: #c4eae1; --ours-fg: #0a6e62;
878
+ --pi-bg: #ddd3fa; --pi-fg: #5737b5;
879
+ --dsh-bg: #ccdff8; --dsh-fg: #1e56a3;
880
+ --good-bg: #e4f6ea; --good-fg: #1a7a43;
881
+ --bad-bg: #fde8e8; --bad-fg: #c53030;
882
+ --warn-bg: #fef3d7; --warn-fg: #8a5a0f;
883
+ --shadow-soft: 0 1px 2px rgb(16 24 40 / 0.04), 0 1px 3px rgb(16 24 40 / 0.06);
884
+ --shadow-card: 0 2px 6px -2px rgb(16 24 40 / 0.06), 0 8px 24px -6px rgb(16 24 40 / 0.10);
885
+ }
886
+ @media (prefers-color-scheme: dark) {
887
+ :root:not([data-theme="light"]) {
888
+ color-scheme: dark;
889
+ --canvas: #161718; --bg: #161718; --surface: #252728; --raised: #1e1f20;
890
+ --fg: #ffffff; --fg-muted: #9a9b9c; --border: #2e3031;
891
+ --primary: #16e3c1; --primary-soft: #095b4d; --on-primary: #0f1010;
892
+ --ours-bg: #173833; --ours-fg: #5ed3bf;
893
+ --pi-bg: #2b2442; --pi-fg: #b6a3f6;
894
+ --dsh-bg: #1b2b42; --dsh-fg: #8db9f5;
895
+ --good-bg: #18482d; --good-fg: #5cc98a;
896
+ --bad-bg: #602222; --bad-fg: #f58b8b;
897
+ --warn-bg: #65511a; --warn-fg: #fdca40;
898
+ --shadow-soft: inset 0 1px 0 rgb(255 255 255 / 0.03), 0 1px 2px rgb(0 0 0 / 0.30);
899
+ --shadow-card: inset 0 1px 0 rgb(255 255 255 / 0.04), 0 2px 8px -2px rgb(0 0 0 / 0.35), 0 12px 32px -8px rgb(0 0 0 / 0.45);
900
+ }
901
+ }
902
+ :root[data-theme="dark"] {
903
+ color-scheme: dark;
904
+ --canvas: #161718; --bg: #161718; --surface: #252728; --raised: #1e1f20;
905
+ --fg: #ffffff; --fg-muted: #9a9b9c; --border: #2e3031;
906
+ --primary: #16e3c1; --primary-soft: #095b4d; --on-primary: #0f1010;
907
+ --ours-bg: #173833; --ours-fg: #5ed3bf;
908
+ --pi-bg: #2b2442; --pi-fg: #b6a3f6;
909
+ --dsh-bg: #1b2b42; --dsh-fg: #8db9f5;
910
+ --good-bg: #18482d; --good-fg: #5cc98a;
911
+ --bad-bg: #602222; --bad-fg: #f58b8b;
912
+ --warn-bg: #65511a; --warn-fg: #fdca40;
913
+ --shadow-soft: inset 0 1px 0 rgb(255 255 255 / 0.03), 0 1px 2px rgb(0 0 0 / 0.30);
914
+ --shadow-card: inset 0 1px 0 rgb(255 255 255 / 0.04), 0 2px 8px -2px rgb(0 0 0 / 0.35), 0 12px 32px -8px rgb(0 0 0 / 0.45);
915
+ }
916
+ * { box-sizing: border-box; }
917
+ html { background: var(--canvas); }
918
+ body { margin: 0; background: var(--canvas); color: var(--fg); font: 15px/1.6 "Geist", "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; }
919
+ code { font: 600 0.92em/1 "Inconsolata", ui-monospace, monospace; background: var(--raised); padding: 2px 5px; border-radius: 4px; }
920
+ a { color: var(--primary); text-underline-offset: 3px; }
921
+ main { max-width: 1200px; margin: 0 auto; padding: 0 16px 80px; }
922
+
923
+ .topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 0; }
924
+ .brand { display: flex; align-items: center; gap: 12px; color: var(--primary); text-decoration: none; }
925
+ .brand svg { display: block; }
926
+ .brand span { color: var(--fg-muted); font: 500 13px/1 "Inconsolata", ui-monospace, monospace; letter-spacing: 0.06em; text-transform: uppercase; padding-left: 12px; border-left: 1px solid var(--border); }
927
+ .toggle { font: inherit; font-size: 13px; color: var(--fg-muted); background: var(--surface); border: 0; border-radius: 999px; padding: 6px 12px; box-shadow: var(--shadow-soft); cursor: pointer; }
928
+ .toggle:hover { color: var(--fg); }
929
+
930
+ .hero { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow-card); padding: 40px 40px 32px; margin-bottom: 40px; position: relative; overflow: hidden; }
931
+ .hero::after { content: ""; position: absolute; inset: 0 0 auto auto; width: 320px; height: 320px; background: radial-gradient(circle at 70% 30%, var(--primary-soft), transparent 65%); pointer-events: none; }
932
+ .eyebrow { font: 600 12px/1 "Inconsolata", ui-monospace, monospace; letter-spacing: 0.1em; text-transform: uppercase; color: var(--primary); margin: 0 0 12px; }
933
+ h1 { font-size: clamp(28px, 4vw, 40px); line-height: 1.15; letter-spacing: -0.02em; margin: 0 0 14px; max-width: 760px; position: relative; z-index: 1; }
934
+ .lede { color: var(--fg-muted); max-width: 820px; margin: 0; position: relative; z-index: 1; }
935
+ .versions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px; position: relative; z-index: 1; }
936
+ .chip { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; border-radius: 999px; padding: 4px 12px; background: var(--raised); color: var(--fg-muted); }
937
+ .chip b { font-weight: 600; }
938
+ .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: none; }
939
+ .ours .dot, .dot.ours { background: var(--ours-fg); }
940
+ .pi .dot, .dot.pi { background: var(--pi-fg); }
941
+ .dsh .dot, .dot.dsh { background: var(--dsh-fg); }
942
+
943
+ section { margin-top: 56px; }
944
+ h2 { font-size: 22px; letter-spacing: -0.01em; margin: 0 0 6px; }
945
+ .section-lede { color: var(--fg-muted); margin: 0 0 20px; max-width: 820px; }
946
+ .num { font: 600 13px/1 "Inconsolata", ui-monospace, monospace; color: var(--primary); margin-right: 10px; }
947
+
948
+ .stack { display: grid; gap: 8px; }
949
+ .layer { display: grid; grid-template-columns: 220px 1fr; gap: 16px; align-items: center; background: var(--surface); border-radius: 12px; box-shadow: var(--shadow-soft); padding: 14px 18px; }
950
+ .layer .tag { font: 600 13px/1.2 "Inconsolata", ui-monospace, monospace; }
951
+ .layer.now { box-shadow: var(--shadow-card); outline: 2px solid var(--primary); outline-offset: -2px; }
952
+ .layer .note { color: var(--fg-muted); font-size: 14px; }
953
+ .layer strong { color: var(--fg); }
954
+
955
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
956
+ .card { background: var(--surface); border-radius: 12px; box-shadow: var(--shadow-card); padding: 22px; display: flex; flex-direction: column; gap: 10px; }
957
+ .card h3 { margin: 0; font-size: 17px; display: flex; align-items: center; gap: 8px; }
958
+ .card p { margin: 0; color: var(--fg-muted); font-size: 14px; }
959
+ .facts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 6px; }
960
+ .fact { font-size: 12px; padding: 3px 9px; border-radius: 6px; }
961
+ .ours .fact { background: var(--ours-bg); color: var(--ours-fg); }
962
+ .pi .fact { background: var(--pi-bg); color: var(--pi-fg); }
963
+ .dsh .fact { background: var(--dsh-bg); color: var(--dsh-fg); }
964
+
965
+ .table-wrap { background: var(--surface); border-radius: 12px; box-shadow: var(--shadow-card); overflow-x: auto; }
966
+ table { border-collapse: collapse; width: 100%; min-width: 900px; }
967
+ th, td { text-align: left; vertical-align: top; padding: 12px 14px; font-size: 14px; border-bottom: 1px solid var(--border); }
968
+ thead th { position: sticky; top: 0; background: var(--surface); font-size: 13px; z-index: 1; }
969
+ thead th span { display: inline-flex; align-items: center; gap: 8px; }
970
+ tbody th { font-weight: 600; width: 170px; white-space: nowrap; }
971
+ tr.group th { font: 600 12px/1 "Inconsolata", ui-monospace, monospace; letter-spacing: 0.08em; text-transform: uppercase; color: var(--primary); background: var(--raised); padding: 10px 14px; border-bottom: 0; }
972
+ td.lead.ours { background: color-mix(in srgb, var(--ours-bg) 55%, transparent); }
973
+ td.lead.pi { background: color-mix(in srgb, var(--pi-bg) 55%, transparent); }
974
+ td.lead.dsh { background: color-mix(in srgb, var(--dsh-bg) 55%, transparent); }
975
+ .badge { display: inline-flex; align-items: center; gap: 4px; font: 600 11px/1 "Inconsolata", ui-monospace, monospace; letter-spacing: 0.04em; text-transform: uppercase; border-radius: 4px; padding: 3px 6px; margin-bottom: 6px; }
976
+ td.lead.ours .badge { background: var(--ours-fg); color: var(--surface); }
977
+ td.lead.pi .badge { background: var(--pi-fg); color: var(--surface); }
978
+ td.lead.dsh .badge { background: var(--dsh-fg); color: var(--surface); }
979
+ .badge::before { content: "▲"; font-size: 8px; }
980
+ td .badge { display: flex; width: fit-content; }
981
+
982
+ .pc { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; }
983
+ .pc .card { gap: 14px; }
984
+ .list-title { font: 600 12px/1 "Inconsolata", ui-monospace, monospace; letter-spacing: 0.08em; text-transform: uppercase; color: var(--fg-muted); margin: 4px 0 0; }
985
+ ul.marks { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; font-size: 14px; }
986
+ ul.marks li { position: relative; padding-left: 30px; }
987
+ ul.marks li::before { position: absolute; left: 0; top: 1px; width: 20px; height: 20px; border-radius: 6px; display: grid; place-items: center; font: 700 13px/1 "Geist", sans-serif; }
988
+ ul.plus li::before { content: "+"; background: var(--good-bg); color: var(--good-fg); }
989
+ ul.minus li::before { content: "−"; background: var(--bad-bg); color: var(--bad-fg); }
990
+
991
+ ol.plan { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; counter-reset: step; }
992
+ ol.plan > li { counter-increment: step; display: grid; grid-template-columns: 44px 1fr; gap: 16px; background: var(--surface); border-radius: 12px; box-shadow: var(--shadow-soft); padding: 18px 20px; }
993
+ ol.plan > li::before { content: counter(step, decimal-leading-zero); font: 600 15px/1 "Inconsolata", ui-monospace, monospace; color: var(--on-primary); background: var(--primary); border-radius: 10px; width: 44px; height: 44px; display: grid; place-items: center; }
994
+ .step-title { font-weight: 600; font-size: 16px; margin: 0 0 4px; }
995
+ .step-body { margin: 0; color: var(--fg); font-size: 14px; }
996
+ .why { display: block; margin-top: 8px; color: var(--fg-muted); font-size: 13px; padding-left: 12px; border-left: 2px solid var(--primary-soft); }
997
+ .areas { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
998
+ .area { font: 600 11px/1 "Inconsolata", ui-monospace, monospace; letter-spacing: 0.04em; text-transform: uppercase; color: var(--fg-muted); background: var(--raised); border-radius: 4px; padding: 4px 7px; }
999
+
1000
+ .avoid { background: var(--surface); border-radius: 12px; box-shadow: var(--shadow-card); padding: 22px; display: grid; gap: 12px; }
1001
+ .avoid ul.marks li::before { content: "×"; background: var(--warn-bg); color: var(--warn-fg); }
1002
+
1003
+ td.nw { white-space: nowrap; min-width: 0; }
1004
+ footer { margin-top: 56px; padding-top: 20px; border-top: 1px solid var(--border); color: var(--fg-muted); font-size: 12px; }
1005
+ footer code { font-size: 11px; }
1006
+
1007
+ @media (max-width: 720px) {
1008
+ .hero { padding: 28px 20px 24px; }
1009
+ .layer { grid-template-columns: 1fr; gap: 4px; }
1010
+ ol.plan > li { grid-template-columns: 1fr; }
1011
+ .brand span { display: none; }
1012
+ }
1013
+ `
1014
+
1015
+ const THEME_SCRIPT = `
1016
+ const root = document.documentElement
1017
+ const button = document.getElementById('theme')
1018
+ const dark = () => root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches
1019
+ const label = () => { button.textContent = dark() ? 'Light mode' : 'Dark mode' }
1020
+ try { const saved = localStorage.getItem('qvac-theme'); if (saved) root.dataset.theme = saved } catch {}
1021
+ label()
1022
+ button.addEventListener('click', () => {
1023
+ root.dataset.theme = dark() ? 'light' : 'dark'
1024
+ try { localStorage.setItem('qvac-theme', root.dataset.theme) } catch {}
1025
+ label()
1026
+ })
1027
+ `
1028
+
1029
+ const STYLE = `
1030
+ section { margin-top: 48px; }
1031
+ .tag { font: 500 12px/1 "Inconsolata", ui-monospace, monospace; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fg-muted); margin-left: 8px; }
1032
+ .cards { margin-top: 0; }
1033
+ .stat .pair { display: flex; gap: 28px; }
1034
+ .figure { font-size: 34px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
1035
+ .figure-label { color: var(--fg-muted); font-size: 12px; }
1036
+ .pending { color: var(--fg-muted); background: var(--surface); border-radius: 12px; box-shadow: var(--shadow-soft); padding: 16px 20px; }
1037
+ table.data { min-width: 640px; font-variant-numeric: tabular-nums; }
1038
+ table.data th, table.data td { white-space: nowrap; }
1039
+ table.data thead th { text-align: center; }
1040
+ table.data th.sub { color: var(--fg-muted); font-weight: 400; font-size: 11px; }
1041
+ table.data th[scope=row] { text-align: left; }
1042
+ td[data-tip] { cursor: default; }
1043
+ td[data-tip]:hover { background: var(--raised); }
1044
+ .bar { display: inline-block; width: 64px; height: 6px; background: var(--raised); border-radius: 3px; vertical-align: middle; overflow: hidden; }
1045
+ .bar span { display: block; height: 100%; background: var(--primary); border-radius: 3px; }
1046
+ .value { margin-left: 8px; }
1047
+ .notes { background: var(--surface); border-radius: 12px; box-shadow: var(--shadow-card); padding: 4px 24px 20px; }
1048
+ .notes h2 { font-size: 17px; margin: 22px 0 8px; }
1049
+ .notes p, .notes li { color: var(--fg); font-size: 14px; }
1050
+ .notes ul { padding-left: 20px; }
1051
+ tr.current th, tr.current td { background: color-mix(in srgb, var(--primary-soft) 40%, transparent); }
1052
+ .findings { margin: 8px 0 16px; }
1053
+ .findings th, .findings td { white-space: normal; text-align: left; vertical-align: top; font-size: 13px; }
1054
+ .findings td:first-child { min-width: 300px; }
1055
+ #tip { position: fixed; z-index: 10; max-width: 480px; white-space: pre-wrap; background: var(--surface); color: var(--fg); border-radius: 8px; padding: 8px 10px; font-size: 12px; box-shadow: var(--shadow-card); pointer-events: none; }
1056
+ `
1057
+
1058
+ const SCRIPT = `
1059
+ const tip = document.getElementById('tip')
1060
+ document.addEventListener('pointerover', (event) => {
1061
+ const cell = event.target.closest('td[data-tip]')
1062
+ if (!cell) { tip.hidden = true; return }
1063
+ tip.textContent = cell.dataset.tip
1064
+ tip.hidden = false
1065
+ })
1066
+ document.addEventListener('pointermove', (event) => {
1067
+ if (tip.hidden) return
1068
+ tip.style.left = Math.min(event.clientX + 14, innerWidth - tip.offsetWidth - 8) + 'px'
1069
+ tip.style.top = event.clientY + 14 + 'px'
1070
+ })
1071
+ `
1072
+
1073
+ fs.mkdirSync(path.dirname(out), { recursive: true })
1074
+ fs.writeFileSync(out, page())
1075
+ console.log(`wrote ${out}`)