@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.
- package/README.md +12 -12
- package/bench/README.md +94 -0
- package/bench/bfcl/checker.ts +313 -0
- package/bench/bfcl/data.ts +93 -0
- package/bench/bfcl/observe.ts +93 -0
- package/bench/bfcl/run.ts +213 -0
- package/bench/bfcl/tools.ts +72 -0
- package/bench/bridge/bridge.ts +392 -0
- package/bench/bridge/openai.ts +150 -0
- package/bench/bridge/python/qvac_bench/__init__.py +3 -0
- package/bench/bridge/python/qvac_bench/airline.py +42 -0
- package/bench/bridge/python/qvac_bench/approval.py +26 -0
- package/bench/bridge/python/qvac_bench/bfcl_multi_turn.py +152 -0
- package/bench/bridge/python/qvac_bench/client.py +88 -0
- package/bench/bridge/python/qvac_bench/harbor_agent.py +85 -0
- package/bench/bridge/python/qvac_bench/session.py +107 -0
- package/bench/bridge/python/qvac_bench/speed.py +30 -0
- package/bench/bridge/python/qvac_bench/tau2_agent.py +106 -0
- package/bench/bridge/python/qvac_bench/tools.py +11 -0
- package/bench/bridge/python/run_bfcl_multi_turn.py +95 -0
- package/bench/bridge/python/run_tau2.py +150 -0
- package/bench/bridge/python/smoke.py +47 -0
- package/bench/bridge/python/test_airline.py +28 -0
- package/bench/bridge/python/test_approval.py +57 -0
- package/bench/bridge/python/test_bfcl_multi_turn.py +185 -0
- package/bench/bridge/python/test_harbor_agent.py +107 -0
- package/bench/bridge/serve.ts +52 -0
- package/bench/bridge/transcript.ts +52 -0
- package/bench/bun-tsconfig.json +15 -0
- package/bench/cpu-main.mjs +216 -0
- package/bench/cpu.ts +457 -0
- package/bench/evidence/2026-09-28-review15.md +225 -0
- package/bench/evidence/redact-bun1.3.14.json +92 -0
- package/bench/evidence/redact-node24.11.json +92 -0
- package/bench/graph-metrics.ts +43 -0
- package/bench/harness.ts +73 -0
- package/bench/issue129-matrix.mjs +722 -0
- package/bench/redact-adversarial.ts +256 -0
- package/bench/repeat-cpu.mjs +281 -0
- package/bench/report.ts +1075 -0
- package/bench/skill-eval/harness.ts +30 -0
- package/bench/skill-eval/laws.ts +37 -0
- package/bench/skill-eval/mermaid/client.ts +132 -0
- package/bench/skill-eval/mermaid/validate.mjs +115 -0
- package/bench/skill-eval/pack.ts +61 -0
- package/bench/skill-eval/packs/diagrams/cases.ts +281 -0
- package/bench/skill-eval/packs/diagrams/index.ts +50 -0
- package/bench/skill-eval/packs/diagrams/score.ts +74 -0
- package/bench/skill-eval/packs/index.ts +5 -0
- package/bench/skill-eval/packs/weather/cases.ts +279 -0
- package/bench/skill-eval/packs/weather/fixtures/wttr.json +566 -0
- package/bench/skill-eval/packs/weather/fixtures.ts +84 -0
- package/bench/skill-eval/packs/weather/index.ts +55 -0
- package/bench/skill-eval/packs/weather/score.ts +92 -0
- package/bench/skill-eval/report.ts +194 -0
- package/bench/skill-eval/run.ts +316 -0
- package/bench/speed.ts +80 -0
- package/package.json +4 -13
package/bench/report.ts
ADDED
|
@@ -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('&', '&')
|
|
864
|
+
.replaceAll('<', '<')
|
|
865
|
+
.replaceAll('>', '>')
|
|
866
|
+
.replaceAll('"', '"')
|
|
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}`)
|