simt-dev 0.2.2 → 0.3.0
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/CHANGELOG.md +37 -0
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/package.json +2 -1
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +36 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +359 -0
- package/src/commands/harness.mjs +156 -5
- package/src/commands/help.mjs +58 -1
- package/src/commands/index.mjs +25 -1
- package/src/commands/plan.mjs +99 -0
- package/src/commands/tokens.mjs +31 -0
- package/src/decisions/index.mjs +23 -5
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/server.mjs +37 -6
- package/src/mcp/tools.mjs +84 -0
- package/src/measure/context.mjs +177 -6
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/manifest.mjs +26 -3
- package/src/projection/rules.mjs +80 -5
- package/src/projection/surfaces.mjs +22 -8
- package/src/toolwords.mjs +69 -0
- package/templates/plan/tracker.json +36 -0
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
// Cost model, paired statistical tests and report generator (`simt bench report`, R03).
|
|
2
|
+
//
|
|
3
|
+
// Reads stored run records from `.simt-dev/bench/runs/` and optional token pricing
|
|
4
|
+
// from `.simt-dev/bench/prices.json` (per million tokens: `{in, cache_write, cache_read, out}`).
|
|
5
|
+
// Never performs network lookups. All statistical estimators (paired bootstrap 95% CIs,
|
|
6
|
+
// McNemar's test on R, Wilcoxon signed-rank test on C) are deterministic.
|
|
7
|
+
|
|
8
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { CONFIG_DIR } from '../name.mjs';
|
|
12
|
+
|
|
13
|
+
const STATE_DIR = `.${CONFIG_DIR}`;
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_BOOTSTRAP_SAMPLES = 1000;
|
|
16
|
+
export const DEFAULT_BOOTSTRAP_SEED = 0x51772026;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Deterministic 32-bit PRNG (Mulberry32) so bootstrap confidence intervals are
|
|
20
|
+
* byte-identical across machines and invocations (Principle P1).
|
|
21
|
+
*/
|
|
22
|
+
function createRng(seed = DEFAULT_BOOTSTRAP_SEED) {
|
|
23
|
+
let state = seed >>> 0;
|
|
24
|
+
return () => {
|
|
25
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
26
|
+
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
27
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
28
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizePriceEntry(raw, label) {
|
|
33
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
34
|
+
throw new Error(`${label} must be a JSON object with {in, cache_write, cache_read, out}`);
|
|
35
|
+
}
|
|
36
|
+
const pIn = Number(raw.in ?? raw.input ?? 0);
|
|
37
|
+
const pCw = Number(raw.cache_write ?? raw.cacheWrite ?? 0);
|
|
38
|
+
const pCr = Number(raw.cache_read ?? raw.cacheRead ?? 0);
|
|
39
|
+
const pOut = Number(raw.out ?? raw.output ?? 0);
|
|
40
|
+
for (const [k, v] of [
|
|
41
|
+
['in', pIn],
|
|
42
|
+
['cache_write', pCw],
|
|
43
|
+
['cache_read', pCr],
|
|
44
|
+
['out', pOut],
|
|
45
|
+
]) {
|
|
46
|
+
if (!Number.isFinite(v) || v < 0) {
|
|
47
|
+
throw new Error(`${label}.${k} must be a non-negative number`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
in: pIn,
|
|
52
|
+
cache_write: pCw,
|
|
53
|
+
cache_read: pCr,
|
|
54
|
+
out: pOut,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isFlatPriceObject(obj) {
|
|
59
|
+
return (
|
|
60
|
+
'in' in obj ||
|
|
61
|
+
'input' in obj ||
|
|
62
|
+
'cache_write' in obj ||
|
|
63
|
+
'cacheWrite' in obj ||
|
|
64
|
+
'cache_read' in obj ||
|
|
65
|
+
'cacheRead' in obj ||
|
|
66
|
+
'out' in obj ||
|
|
67
|
+
'output' in obj
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Load `.simt-dev/bench/prices.json` if present.
|
|
73
|
+
* Supports either a flat `{in, cache_write, cache_read, out}` object (prices per 1M tokens)
|
|
74
|
+
* or a map keyed by model/harness name with an optional `"default"` entry.
|
|
75
|
+
*/
|
|
76
|
+
export async function loadPrices(root) {
|
|
77
|
+
const pricesPath = join(resolve(root), STATE_DIR, 'bench', 'prices.json');
|
|
78
|
+
let rawText;
|
|
79
|
+
try {
|
|
80
|
+
rawText = await readFile(pricesPath, 'utf8');
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error && error.code === 'ENOENT') return null;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(rawText);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw new Error(`invalid JSON in ${pricesPath}: ${error.message}`);
|
|
91
|
+
}
|
|
92
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
93
|
+
throw new Error(`${pricesPath} must be a JSON object`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isFlatPriceObject(parsed)) {
|
|
97
|
+
return { kind: 'flat', entry: normalizePriceEntry(parsed, pricesPath) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const entries = {};
|
|
101
|
+
for (const [key, val] of Object.entries(parsed)) {
|
|
102
|
+
entries[key] = normalizePriceEntry(val, `${pricesPath}[${JSON.stringify(key)}]`);
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'map', entries };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolvePriceEntry(prices, run) {
|
|
108
|
+
if (!prices) return null;
|
|
109
|
+
if (prices.kind === 'flat') return normalizePriceEntry(prices.entry, 'prices');
|
|
110
|
+
if (prices.kind === 'map') {
|
|
111
|
+
const entry =
|
|
112
|
+
(run?.model && prices.entries[run.model]) ||
|
|
113
|
+
(run?.harness && prices.entries[run.harness]) ||
|
|
114
|
+
prices.entries.default ||
|
|
115
|
+
null;
|
|
116
|
+
return entry ? normalizePriceEntry(entry, 'prices') : null;
|
|
117
|
+
}
|
|
118
|
+
if (isFlatPriceObject(prices)) {
|
|
119
|
+
return normalizePriceEntry(prices, 'prices');
|
|
120
|
+
}
|
|
121
|
+
const entry =
|
|
122
|
+
(run?.model && prices[run.model]) ||
|
|
123
|
+
(run?.harness && prices[run.harness]) ||
|
|
124
|
+
prices.default ||
|
|
125
|
+
null;
|
|
126
|
+
return entry ? normalizePriceEntry(entry, 'prices') : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Compute billed cost in USD for a single run record.
|
|
131
|
+
* When a price entry is provided (per 1M tokens), computes:
|
|
132
|
+
* (input * in + cacheWrite * cache_write + cacheRead * cache_read + output * out) / 1e6
|
|
133
|
+
* Falls back to `run.costUsd` when no price table is supplied (or when all token counts are 0
|
|
134
|
+
* and the harness reported a direct `costUsd`).
|
|
135
|
+
*/
|
|
136
|
+
export function computeRunCost(run, prices = null) {
|
|
137
|
+
const usage = run?.usage ?? {};
|
|
138
|
+
const input = Number(usage.input ?? 0);
|
|
139
|
+
const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
|
|
140
|
+
const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? 0);
|
|
141
|
+
const output = Number(usage.output ?? 0);
|
|
142
|
+
const totalTokens = input + cacheWrite + cacheRead + output;
|
|
143
|
+
|
|
144
|
+
const entry = resolvePriceEntry(prices, run);
|
|
145
|
+
if (entry) {
|
|
146
|
+
if (totalTokens === 0 && Number(run?.costUsd ?? 0) > 0) {
|
|
147
|
+
return Number(run.costUsd);
|
|
148
|
+
}
|
|
149
|
+
return (
|
|
150
|
+
(input * entry.in +
|
|
151
|
+
cacheWrite * entry.cache_write +
|
|
152
|
+
cacheRead * entry.cache_read +
|
|
153
|
+
output * entry.out) /
|
|
154
|
+
1_000_000
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return Number(run?.costUsd ?? 0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether a run attempt counts as resolved for R: hidden tests pass AND declared gates pass.
|
|
162
|
+
*/
|
|
163
|
+
export function isRunResolved(run) {
|
|
164
|
+
return Boolean(run?.resolved && (run?.gatesPassed ?? true));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Compute quality score Q in [0, 1] for a run record:
|
|
169
|
+
* 0.5 * hiddenTests + 0.2 * gates + 0.2 * probe + 0.1 * diffHygiene
|
|
170
|
+
*/
|
|
171
|
+
export function computeRunQuality(run) {
|
|
172
|
+
const testsScore = run?.resolved ? 1 : 0;
|
|
173
|
+
const gatesScore = (run?.gatesPassed ?? true) ? 1 : 0;
|
|
174
|
+
|
|
175
|
+
let probeScore = 1;
|
|
176
|
+
if (run?.probe !== null && run?.probe !== undefined) {
|
|
177
|
+
if (typeof run.probe === 'boolean') {
|
|
178
|
+
probeScore = run.probe ? 1 : 0;
|
|
179
|
+
} else if (typeof run.probe === 'number' && Number.isFinite(run.probe)) {
|
|
180
|
+
probeScore = Math.max(0, Math.min(1, run.probe));
|
|
181
|
+
} else if (typeof run.probe === 'object') {
|
|
182
|
+
probeScore = run.probe.passed ? 1 : 0;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const files = Number(run?.diff?.files ?? 0);
|
|
187
|
+
const outside = Number(run?.diff?.outsideBlastRadius ?? 0);
|
|
188
|
+
let hygieneScore = 1;
|
|
189
|
+
if (outside > 0) {
|
|
190
|
+
hygieneScore = files > 0 ? Math.max(0, Math.min(1, (files - outside) / files)) : 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (5 * testsScore + 2 * gatesScore + 2 * probeScore + hygieneScore) / 10;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Evaluate per-run derived metrics used by arm aggregation and paired tests.
|
|
198
|
+
*/
|
|
199
|
+
export function evaluateRunRecord(run, prices = null) {
|
|
200
|
+
const usage = run?.usage ?? {};
|
|
201
|
+
const input = Number(usage.input ?? 0);
|
|
202
|
+
const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
|
|
203
|
+
const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? 0);
|
|
204
|
+
const output = Number(usage.output ?? 0);
|
|
205
|
+
const turns = Number(usage.turns ?? 0);
|
|
206
|
+
const toolCalls = Number(usage.toolCalls ?? usage.tool_calls ?? 0);
|
|
207
|
+
const R = isRunResolved(run) ? 1 : 0;
|
|
208
|
+
const Q = computeRunQuality(run);
|
|
209
|
+
const C = computeRunCost(run, prices);
|
|
210
|
+
const cacheDenom = input + cacheRead;
|
|
211
|
+
const cacheHitRatio = cacheDenom > 0 ? cacheRead / cacheDenom : 0;
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
task: String(run?.task ?? ''),
|
|
215
|
+
arm: String(run?.arm ?? 'default'),
|
|
216
|
+
harness: String(run?.harness ?? ''),
|
|
217
|
+
model: String(run?.model ?? ''),
|
|
218
|
+
rep: Number(run?.rep ?? 1),
|
|
219
|
+
R,
|
|
220
|
+
Q,
|
|
221
|
+
C,
|
|
222
|
+
turns,
|
|
223
|
+
toolCalls,
|
|
224
|
+
input,
|
|
225
|
+
cacheWrite,
|
|
226
|
+
cacheRead,
|
|
227
|
+
output,
|
|
228
|
+
cacheHitRatio,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function mean(values) {
|
|
233
|
+
if (values.length === 0) return 0;
|
|
234
|
+
let sum = 0;
|
|
235
|
+
for (const v of values) sum += v;
|
|
236
|
+
return sum / values.length;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Aggregate a list of task-level (or run-level) metric rows into arm summary metrics.
|
|
241
|
+
*/
|
|
242
|
+
function summarizeRows(rows) {
|
|
243
|
+
if (rows.length === 0) {
|
|
244
|
+
return {
|
|
245
|
+
R: 0,
|
|
246
|
+
Q: 0,
|
|
247
|
+
C: 0,
|
|
248
|
+
CPI: null,
|
|
249
|
+
turns: 0,
|
|
250
|
+
toolCalls: 0,
|
|
251
|
+
cacheHitRatio: 0,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const R = mean(rows.map((r) => r.R));
|
|
255
|
+
const Q = mean(rows.map((r) => r.Q));
|
|
256
|
+
const C = mean(rows.map((r) => r.C));
|
|
257
|
+
const turns = mean(rows.map((r) => r.turns));
|
|
258
|
+
const toolCalls = mean(rows.map((r) => r.toolCalls));
|
|
259
|
+
let totalInput = 0;
|
|
260
|
+
let totalCacheRead = 0;
|
|
261
|
+
for (const r of rows) {
|
|
262
|
+
totalInput += r.input;
|
|
263
|
+
totalCacheRead += r.cacheRead;
|
|
264
|
+
}
|
|
265
|
+
const cacheHitRatio =
|
|
266
|
+
totalInput + totalCacheRead > 0 ? totalCacheRead / (totalInput + totalCacheRead) : 0;
|
|
267
|
+
const rq = R * Q;
|
|
268
|
+
const CPI = rq > 0 ? C / rq : null;
|
|
269
|
+
|
|
270
|
+
return { R, Q, C, CPI, turns, toolCalls, cacheHitRatio };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Group evaluated runs for one arm by task so multi-repetition tasks form one task unit
|
|
275
|
+
* for task-level means and bootstrap resampling.
|
|
276
|
+
*/
|
|
277
|
+
function collapseArmByTask(evaluatedRuns) {
|
|
278
|
+
const byTask = new Map();
|
|
279
|
+
for (const run of evaluatedRuns) {
|
|
280
|
+
const list = byTask.get(run.task) ?? [];
|
|
281
|
+
list.push(run);
|
|
282
|
+
byTask.set(run.task, list);
|
|
283
|
+
}
|
|
284
|
+
const tasks = [...byTask.keys()].sort();
|
|
285
|
+
return tasks.map((task) => {
|
|
286
|
+
const group = byTask.get(task);
|
|
287
|
+
const R = mean(group.map((r) => r.R));
|
|
288
|
+
const Q = mean(group.map((r) => r.Q));
|
|
289
|
+
const C = mean(group.map((r) => r.C));
|
|
290
|
+
const turns = mean(group.map((r) => r.turns));
|
|
291
|
+
const toolCalls = mean(group.map((r) => r.toolCalls));
|
|
292
|
+
const input = mean(group.map((r) => r.input));
|
|
293
|
+
const cacheWrite = mean(group.map((r) => r.cacheWrite));
|
|
294
|
+
const cacheRead = mean(group.map((r) => r.cacheRead));
|
|
295
|
+
const output = mean(group.map((r) => r.output));
|
|
296
|
+
const cacheHitRatio = input + cacheRead > 0 ? cacheRead / (input + cacheRead) : 0;
|
|
297
|
+
return {
|
|
298
|
+
task,
|
|
299
|
+
reps: group.length,
|
|
300
|
+
R,
|
|
301
|
+
Q,
|
|
302
|
+
C,
|
|
303
|
+
turns,
|
|
304
|
+
toolCalls,
|
|
305
|
+
input,
|
|
306
|
+
cacheWrite,
|
|
307
|
+
cacheRead,
|
|
308
|
+
output,
|
|
309
|
+
cacheHitRatio,
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function percentilePair(sortedValues) {
|
|
315
|
+
if (sortedValues.length === 0) return [0, 0];
|
|
316
|
+
if (sortedValues.length === 1) return [sortedValues[0], sortedValues[0]];
|
|
317
|
+
const loIdx = Math.floor(0.025 * (sortedValues.length - 1));
|
|
318
|
+
const hiIdx = Math.ceil(0.975 * (sortedValues.length - 1));
|
|
319
|
+
return [sortedValues[loIdx], sortedValues[hiIdx]];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Paired bootstrap 95% confidence intervals over task rows.
|
|
324
|
+
*/
|
|
325
|
+
export function bootstrapArmCi(
|
|
326
|
+
taskRows,
|
|
327
|
+
{ samples = DEFAULT_BOOTSTRAP_SAMPLES, seed = DEFAULT_BOOTSTRAP_SEED } = {},
|
|
328
|
+
) {
|
|
329
|
+
const n = taskRows.length;
|
|
330
|
+
if (n === 0) {
|
|
331
|
+
return {
|
|
332
|
+
R: [0, 0],
|
|
333
|
+
Q: [0, 0],
|
|
334
|
+
C: [0, 0],
|
|
335
|
+
CPI: null,
|
|
336
|
+
turns: [0, 0],
|
|
337
|
+
toolCalls: [0, 0],
|
|
338
|
+
cacheHitRatio: [0, 0],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
if (n === 1) {
|
|
342
|
+
const s = summarizeRows(taskRows);
|
|
343
|
+
return {
|
|
344
|
+
R: [s.R, s.R],
|
|
345
|
+
Q: [s.Q, s.Q],
|
|
346
|
+
C: [s.C, s.C],
|
|
347
|
+
CPI: s.CPI === null ? null : [s.CPI, s.CPI],
|
|
348
|
+
turns: [s.turns, s.turns],
|
|
349
|
+
toolCalls: [s.toolCalls, s.toolCalls],
|
|
350
|
+
cacheHitRatio: [s.cacheHitRatio, s.cacheHitRatio],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const rng = createRng(seed);
|
|
355
|
+
const rArr = [];
|
|
356
|
+
const qArr = [];
|
|
357
|
+
const cArr = [];
|
|
358
|
+
const cpiArr = [];
|
|
359
|
+
const turnsArr = [];
|
|
360
|
+
const toolsArr = [];
|
|
361
|
+
const cacheArr = [];
|
|
362
|
+
|
|
363
|
+
for (let b = 0; b < samples; b += 1) {
|
|
364
|
+
const draw = new Array(n);
|
|
365
|
+
for (let i = 0; i < n; i += 1) {
|
|
366
|
+
draw[i] = taskRows[Math.floor(rng() * n)];
|
|
367
|
+
}
|
|
368
|
+
const s = summarizeRows(draw);
|
|
369
|
+
rArr.push(s.R);
|
|
370
|
+
qArr.push(s.Q);
|
|
371
|
+
cArr.push(s.C);
|
|
372
|
+
if (s.CPI !== null) cpiArr.push(s.CPI);
|
|
373
|
+
turnsArr.push(s.turns);
|
|
374
|
+
toolsArr.push(s.toolCalls);
|
|
375
|
+
cacheArr.push(s.cacheHitRatio);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const numSort = (a, b) => a - b;
|
|
379
|
+
rArr.sort(numSort);
|
|
380
|
+
qArr.sort(numSort);
|
|
381
|
+
cArr.sort(numSort);
|
|
382
|
+
cpiArr.sort(numSort);
|
|
383
|
+
turnsArr.sort(numSort);
|
|
384
|
+
toolsArr.sort(numSort);
|
|
385
|
+
cacheArr.sort(numSort);
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
R: percentilePair(rArr),
|
|
389
|
+
Q: percentilePair(qArr),
|
|
390
|
+
C: percentilePair(cArr),
|
|
391
|
+
CPI: cpiArr.length > 0 ? percentilePair(cpiArr) : null,
|
|
392
|
+
turns: percentilePair(turnsArr),
|
|
393
|
+
toolCalls: percentilePair(toolsArr),
|
|
394
|
+
cacheHitRatio: percentilePair(cacheArr),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Complementary error function erfc(x) for normal/chi-square tail probabilities.
|
|
400
|
+
* Maximum relative error < 1.5e-7 (Abramowitz & Stegun 7.1.26).
|
|
401
|
+
*/
|
|
402
|
+
function erfc(x) {
|
|
403
|
+
const z = Math.abs(x);
|
|
404
|
+
const t = 1 / (1 + 0.3275911 * z);
|
|
405
|
+
const a1 = 0.254829592;
|
|
406
|
+
const a2 = -0.284496736;
|
|
407
|
+
const a3 = 1.421413741;
|
|
408
|
+
const a4 = -1.453152027;
|
|
409
|
+
const a5 = 1.061405429;
|
|
410
|
+
const poly = (((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t) * Math.exp(-z * z);
|
|
411
|
+
return x >= 0 ? poly : 2 - poly;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Two-sided exact binomial p-value for discordant counts (b, c) under p = 0.5.
|
|
416
|
+
*/
|
|
417
|
+
function exactBinomialTwoSided(b, c) {
|
|
418
|
+
const m = b + c;
|
|
419
|
+
if (m === 0) return 1;
|
|
420
|
+
const k = Math.min(b, c);
|
|
421
|
+
// Sum_{j=0..k} C(m, j) * 0.5^m computed recurrence-style to avoid overflow.
|
|
422
|
+
let term = Math.pow(0.5, m);
|
|
423
|
+
let cdf = term;
|
|
424
|
+
for (let j = 1; j <= k; j += 1) {
|
|
425
|
+
term *= (m - j + 1) / j;
|
|
426
|
+
cdf += term;
|
|
427
|
+
}
|
|
428
|
+
return Math.min(1, 2 * cdf);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* McNemar's test on paired binary resolution outcomes (armA vs armB).
|
|
433
|
+
*
|
|
434
|
+
* Contingency counts:
|
|
435
|
+
* n11: both resolved
|
|
436
|
+
* n10 (b): armA resolved, armB did not
|
|
437
|
+
* n01 (c): armA did not, armB resolved
|
|
438
|
+
* n00: neither resolved
|
|
439
|
+
*
|
|
440
|
+
* Statistic: (b - c)^2 / (b + c) (0 when b + c === 0).
|
|
441
|
+
* pValue: exact two-sided binomial test on discordant pairs (b, c).
|
|
442
|
+
*/
|
|
443
|
+
export function mcnemarTest(pairs) {
|
|
444
|
+
let n11 = 0;
|
|
445
|
+
let n10 = 0;
|
|
446
|
+
let n01 = 0;
|
|
447
|
+
let n00 = 0;
|
|
448
|
+
|
|
449
|
+
for (const [a, b] of pairs) {
|
|
450
|
+
const aRes = typeof a === 'number' ? a >= 0.5 : Boolean(a);
|
|
451
|
+
const bRes = typeof b === 'number' ? b >= 0.5 : Boolean(b);
|
|
452
|
+
if (aRes && bRes) n11 += 1;
|
|
453
|
+
else if (aRes && !bRes) n10 += 1;
|
|
454
|
+
else if (!aRes && bRes) n01 += 1;
|
|
455
|
+
else n00 += 1;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const discordant = n10 + n01;
|
|
459
|
+
const statistic = discordant > 0 ? ((n10 - n01) * (n10 - n01)) / discordant : 0;
|
|
460
|
+
const pValue = exactBinomialTwoSided(n10, n01);
|
|
461
|
+
const pValueAsymptotic = discordant > 0 ? erfc(Math.sqrt(statistic / 2)) : 1;
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
n11,
|
|
465
|
+
n10,
|
|
466
|
+
n01,
|
|
467
|
+
n00,
|
|
468
|
+
b: n10,
|
|
469
|
+
c: n01,
|
|
470
|
+
statistic,
|
|
471
|
+
pValue,
|
|
472
|
+
pValueAsymptotic,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Wilcoxon signed-rank test on paired numerical observations (armA, armB),
|
|
478
|
+
* evaluating differences d_i = b_i - a_i.
|
|
479
|
+
*
|
|
480
|
+
* Uses average ranks for ties. For n_r <= 20 non-zero differences, computes the exact
|
|
481
|
+
* two-sided sign-permutation p-value over the assigned ranks; for n_r > 20, uses the
|
|
482
|
+
* tie-adjusted normal approximation.
|
|
483
|
+
*/
|
|
484
|
+
export function wilcoxonSignedRank(pairs) {
|
|
485
|
+
const diffs = [];
|
|
486
|
+
for (const [a, b] of pairs) {
|
|
487
|
+
const d = Number(b) - Number(a);
|
|
488
|
+
if (Math.abs(d) > 1e-12) {
|
|
489
|
+
diffs.push({ d, abs: Math.abs(d), sign: d > 0 ? 1 : -1 });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const n = pairs.length;
|
|
494
|
+
const nonZero = diffs.length;
|
|
495
|
+
if (nonZero === 0) {
|
|
496
|
+
return {
|
|
497
|
+
n,
|
|
498
|
+
nonZero: 0,
|
|
499
|
+
wPlus: 0,
|
|
500
|
+
wMinus: 0,
|
|
501
|
+
statistic: 0,
|
|
502
|
+
pValue: 1,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
diffs.sort((x, y) => x.abs - y.abs);
|
|
507
|
+
|
|
508
|
+
const ranks = new Array(nonZero);
|
|
509
|
+
const tieGroups = [];
|
|
510
|
+
let i = 0;
|
|
511
|
+
while (i < nonZero) {
|
|
512
|
+
let j = i + 1;
|
|
513
|
+
while (j < nonZero && Math.abs(diffs[j].abs - diffs[i].abs) <= 1e-12) {
|
|
514
|
+
j += 1;
|
|
515
|
+
}
|
|
516
|
+
const avgRank = (i + 1 + j) / 2;
|
|
517
|
+
for (let k = i; k < j; k += 1) {
|
|
518
|
+
ranks[k] = avgRank;
|
|
519
|
+
}
|
|
520
|
+
const groupSize = j - i;
|
|
521
|
+
if (groupSize > 1) tieGroups.push(groupSize);
|
|
522
|
+
i = j;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
let wPlus = 0;
|
|
526
|
+
let wMinus = 0;
|
|
527
|
+
for (let k = 0; k < nonZero; k += 1) {
|
|
528
|
+
if (diffs[k].sign > 0) wPlus += ranks[k];
|
|
529
|
+
else wMinus += ranks[k];
|
|
530
|
+
}
|
|
531
|
+
const statistic = Math.min(wPlus, wMinus);
|
|
532
|
+
|
|
533
|
+
let pValue;
|
|
534
|
+
if (nonZero <= 20) {
|
|
535
|
+
// Exact two-sided permutation distribution over the 2^nonZero sign assignments.
|
|
536
|
+
const totalConfigurations = 1 << nonZero;
|
|
537
|
+
const target = statistic + 1e-9;
|
|
538
|
+
let tailCount = 0;
|
|
539
|
+
for (let mask = 0; mask < totalConfigurations; mask += 1) {
|
|
540
|
+
let sum = 0;
|
|
541
|
+
for (let bit = 0; bit < nonZero; bit += 1) {
|
|
542
|
+
if ((mask & (1 << bit)) !== 0) {
|
|
543
|
+
sum += ranks[bit];
|
|
544
|
+
if (sum > target) break;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (sum <= target) tailCount += 1;
|
|
548
|
+
}
|
|
549
|
+
pValue = Math.min(1, (2 * tailCount) / totalConfigurations);
|
|
550
|
+
} else {
|
|
551
|
+
const meanW = (nonZero * (nonZero + 1)) / 4;
|
|
552
|
+
let tieCorrection = 0;
|
|
553
|
+
for (const t of tieGroups) {
|
|
554
|
+
tieCorrection += t * t * t - t;
|
|
555
|
+
}
|
|
556
|
+
const variance =
|
|
557
|
+
(nonZero * (nonZero + 1) * (2 * nonZero + 1) - 0.5 * tieCorrection) / 24;
|
|
558
|
+
const z = variance > 0 ? Math.abs(wPlus - meanW) / Math.sqrt(variance) : 0;
|
|
559
|
+
pValue = erfc(z / Math.SQRT2);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
n,
|
|
564
|
+
nonZero,
|
|
565
|
+
wPlus,
|
|
566
|
+
wMinus,
|
|
567
|
+
statistic,
|
|
568
|
+
pValue,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Pair two arms' evaluated runs and compute deltas, paired bootstrap 95% CIs,
|
|
574
|
+
* McNemar test on R, and Wilcoxon signed-rank test on C.
|
|
575
|
+
*/
|
|
576
|
+
export function compareArms(
|
|
577
|
+
armAName,
|
|
578
|
+
armARuns,
|
|
579
|
+
armBName,
|
|
580
|
+
armBRuns,
|
|
581
|
+
{ samples = DEFAULT_BOOTSTRAP_SAMPLES, seed = DEFAULT_BOOTSTRAP_SEED } = {},
|
|
582
|
+
) {
|
|
583
|
+
const tasksA = new Map(collapseArmByTask(armARuns).map((row) => [row.task, row]));
|
|
584
|
+
const tasksB = new Map(collapseArmByTask(armBRuns).map((row) => [row.task, row]));
|
|
585
|
+
const sharedTasks = [...tasksA.keys()].filter((t) => tasksB.has(t)).sort();
|
|
586
|
+
|
|
587
|
+
const pairedTasks = sharedTasks.map((task) => ({
|
|
588
|
+
task,
|
|
589
|
+
a: tasksA.get(task),
|
|
590
|
+
b: tasksB.get(task),
|
|
591
|
+
}));
|
|
592
|
+
|
|
593
|
+
// Pair individual (task, rep) records when both arms share the same (task, rep) keys;
|
|
594
|
+
// otherwise fall back to task-level pairs.
|
|
595
|
+
const runMapA = new Map(armARuns.map((r) => [`${r.task}#${r.rep}`, r]));
|
|
596
|
+
const runMapB = new Map(armBRuns.map((r) => [`${r.task}#${r.rep}`, r]));
|
|
597
|
+
const sharedRunKeys = [...runMapA.keys()].filter((k) => runMapB.has(k)).sort();
|
|
598
|
+
|
|
599
|
+
const useRunPairs =
|
|
600
|
+
sharedRunKeys.length > 0 &&
|
|
601
|
+
sharedRunKeys.length === armARuns.length &&
|
|
602
|
+
sharedRunKeys.length === armBRuns.length;
|
|
603
|
+
|
|
604
|
+
const rPairs = useRunPairs
|
|
605
|
+
? sharedRunKeys.map((k) => [runMapA.get(k).R, runMapB.get(k).R])
|
|
606
|
+
: pairedTasks.map((p) => [p.a.R, p.b.R]);
|
|
607
|
+
|
|
608
|
+
const cPairs = useRunPairs
|
|
609
|
+
? sharedRunKeys.map((k) => [runMapA.get(k).C, runMapB.get(k).C])
|
|
610
|
+
: pairedTasks.map((p) => [p.a.C, p.b.C]);
|
|
611
|
+
|
|
612
|
+
const sumA = summarizeRows(pairedTasks.map((p) => p.a));
|
|
613
|
+
const sumB = summarizeRows(pairedTasks.map((p) => p.b));
|
|
614
|
+
|
|
615
|
+
const deltas = {
|
|
616
|
+
R: sumB.R - sumA.R,
|
|
617
|
+
Q: sumB.Q - sumA.Q,
|
|
618
|
+
C: sumB.C - sumA.C,
|
|
619
|
+
CPI: sumB.CPI !== null && sumA.CPI !== null ? sumB.CPI - sumA.CPI : null,
|
|
620
|
+
cpiRatio:
|
|
621
|
+
sumB.CPI !== null && sumA.CPI !== null && sumA.CPI > 0 ? sumB.CPI / sumA.CPI : null,
|
|
622
|
+
turns: sumB.turns - sumA.turns,
|
|
623
|
+
toolCalls: sumB.toolCalls - sumA.toolCalls,
|
|
624
|
+
cacheHitRatio: sumB.cacheHitRatio - sumA.cacheHitRatio,
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
// Paired bootstrap 95% CIs on deltas (B - A).
|
|
628
|
+
const n = pairedTasks.length;
|
|
629
|
+
let ci95;
|
|
630
|
+
if (n <= 1) {
|
|
631
|
+
ci95 = {
|
|
632
|
+
R: [deltas.R, deltas.R],
|
|
633
|
+
Q: [deltas.Q, deltas.Q],
|
|
634
|
+
C: [deltas.C, deltas.C],
|
|
635
|
+
CPI: deltas.CPI === null ? null : [deltas.CPI, deltas.CPI],
|
|
636
|
+
turns: [deltas.turns, deltas.turns],
|
|
637
|
+
toolCalls: [deltas.toolCalls, deltas.toolCalls],
|
|
638
|
+
cacheHitRatio: [deltas.cacheHitRatio, deltas.cacheHitRatio],
|
|
639
|
+
};
|
|
640
|
+
} else {
|
|
641
|
+
const rng = createRng(seed);
|
|
642
|
+
const dR = [];
|
|
643
|
+
const dQ = [];
|
|
644
|
+
const dC = [];
|
|
645
|
+
const dCPI = [];
|
|
646
|
+
const dTurns = [];
|
|
647
|
+
const dTools = [];
|
|
648
|
+
const dCache = [];
|
|
649
|
+
|
|
650
|
+
for (let b = 0; b < samples; b += 1) {
|
|
651
|
+
const drawA = new Array(n);
|
|
652
|
+
const drawB = new Array(n);
|
|
653
|
+
for (let i = 0; i < n; i += 1) {
|
|
654
|
+
const pair = pairedTasks[Math.floor(rng() * n)];
|
|
655
|
+
drawA[i] = pair.a;
|
|
656
|
+
drawB[i] = pair.b;
|
|
657
|
+
}
|
|
658
|
+
const sA = summarizeRows(drawA);
|
|
659
|
+
const sB = summarizeRows(drawB);
|
|
660
|
+
dR.push(sB.R - sA.R);
|
|
661
|
+
dQ.push(sB.Q - sA.Q);
|
|
662
|
+
dC.push(sB.C - sA.C);
|
|
663
|
+
if (sB.CPI !== null && sA.CPI !== null) dCPI.push(sB.CPI - sA.CPI);
|
|
664
|
+
dTurns.push(sB.turns - sA.turns);
|
|
665
|
+
dTools.push(sB.toolCalls - sA.toolCalls);
|
|
666
|
+
dCache.push(sB.cacheHitRatio - sA.cacheHitRatio);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const numSort = (x, y) => x - y;
|
|
670
|
+
dR.sort(numSort);
|
|
671
|
+
dQ.sort(numSort);
|
|
672
|
+
dC.sort(numSort);
|
|
673
|
+
dCPI.sort(numSort);
|
|
674
|
+
dTurns.sort(numSort);
|
|
675
|
+
dTools.sort(numSort);
|
|
676
|
+
dCache.sort(numSort);
|
|
677
|
+
|
|
678
|
+
ci95 = {
|
|
679
|
+
R: percentilePair(dR),
|
|
680
|
+
Q: percentilePair(dQ),
|
|
681
|
+
C: percentilePair(dC),
|
|
682
|
+
CPI: dCPI.length > 0 ? percentilePair(dCPI) : null,
|
|
683
|
+
turns: percentilePair(dTurns),
|
|
684
|
+
toolCalls: percentilePair(dTools),
|
|
685
|
+
cacheHitRatio: percentilePair(dCache),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
baseline: armAName,
|
|
691
|
+
candidate: armBName,
|
|
692
|
+
pairedTasks: pairedTasks.length,
|
|
693
|
+
pairedRuns: rPairs.length,
|
|
694
|
+
deltas,
|
|
695
|
+
ci95,
|
|
696
|
+
mcnemar: mcnemarTest(rPairs),
|
|
697
|
+
wilcoxon: wilcoxonSignedRank(cPairs),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Load all stored run records under `.simt-dev/bench/runs/`.
|
|
703
|
+
*/
|
|
704
|
+
export async function loadRunRecords(root) {
|
|
705
|
+
const runsRoot = join(resolve(root), STATE_DIR, 'bench', 'runs');
|
|
706
|
+
const entries = await readdir(runsRoot, { withFileTypes: true }).catch(() => []);
|
|
707
|
+
const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
708
|
+
|
|
709
|
+
const records = [];
|
|
710
|
+
for (const entry of sortedEntries) {
|
|
711
|
+
if (entry.name.startsWith('.')) continue;
|
|
712
|
+
const fullPath = join(runsRoot, entry.name);
|
|
713
|
+
if (entry.isDirectory()) {
|
|
714
|
+
const files = (await readdir(fullPath).catch(() => []))
|
|
715
|
+
.filter((f) => f.endsWith('.json') && !f.startsWith('.'))
|
|
716
|
+
.sort();
|
|
717
|
+
for (const file of files) {
|
|
718
|
+
const record = JSON.parse(await readFile(join(fullPath, file), 'utf8'));
|
|
719
|
+
records.push(record);
|
|
720
|
+
}
|
|
721
|
+
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
722
|
+
const record = JSON.parse(await readFile(fullPath, 'utf8'));
|
|
723
|
+
records.push(record);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return records;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function fmtPct(v) {
|
|
730
|
+
return `${(v * 100).toFixed(1)}%`;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function fmtSignedPct(v) {
|
|
734
|
+
const sign = v > 0 ? '+' : '';
|
|
735
|
+
return `${sign}${(v * 100).toFixed(1)}%`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function fmtUsd(v) {
|
|
739
|
+
if (v === null || v === undefined || !Number.isFinite(v)) return 'n/a';
|
|
740
|
+
return `$${v.toFixed(4)}`;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function fmtSignedUsd(v) {
|
|
744
|
+
if (v === null || v === undefined || !Number.isFinite(v)) return 'n/a';
|
|
745
|
+
const sign = v > 0 ? '+' : '';
|
|
746
|
+
return `${sign}$${v.toFixed(4)}`;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function fmtNum(v, digits = 2) {
|
|
750
|
+
if (v === null || v === undefined || !Number.isFinite(v)) return 'n/a';
|
|
751
|
+
return v.toFixed(digits);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function fmtSignedNum(v, digits = 2) {
|
|
755
|
+
if (v === null || v === undefined || !Number.isFinite(v)) return 'n/a';
|
|
756
|
+
const sign = v > 0 ? '+' : '';
|
|
757
|
+
return `${sign}${v.toFixed(digits)}`;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Render the markdown table for `simt bench report` (and optional `--compare a b` delta block).
|
|
762
|
+
*/
|
|
763
|
+
export function formatReportMarkdown(report) {
|
|
764
|
+
const lines = [
|
|
765
|
+
'| Arm | Tasks | Runs | R | 95% CI (R) | Q | C ($) | CPI ($) | Turns | Tool calls | Cache hit |',
|
|
766
|
+
'| --- | ---: | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
|
767
|
+
];
|
|
768
|
+
|
|
769
|
+
for (const arm of report.arms) {
|
|
770
|
+
const rCi = `[${fmtPct(arm.ci95.R[0])}, ${fmtPct(arm.ci95.R[1])}]`;
|
|
771
|
+
lines.push(
|
|
772
|
+
`| ${arm.arm} | ${arm.tasks} | ${arm.runs} | ${fmtPct(arm.R)} | ${rCi} | ${fmtNum(arm.Q, 3)} | ${fmtUsd(arm.C)} | ${fmtUsd(arm.CPI)} | ${fmtNum(arm.turns, 2)} | ${fmtNum(arm.toolCalls, 2)} | ${fmtPct(arm.cacheHitRatio)} |`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (report.comparison) {
|
|
777
|
+
const cmp = report.comparison;
|
|
778
|
+
const d = cmp.deltas;
|
|
779
|
+
lines.push('');
|
|
780
|
+
lines.push(`### Comparison: \`${cmp.candidate}\` vs \`${cmp.baseline}\` (\`${cmp.candidate} - ${cmp.baseline}\`)`);
|
|
781
|
+
lines.push('');
|
|
782
|
+
lines.push('| Metric | Baseline | Candidate | Delta | 95% CI (Delta) | Test | Statistic | p-value |');
|
|
783
|
+
lines.push('| --- | ---: | ---: | ---: | --- | --- | ---: | ---: |');
|
|
784
|
+
|
|
785
|
+
const baseArm = report.arms.find((a) => a.arm === cmp.baseline);
|
|
786
|
+
const candArm = report.arms.find((a) => a.arm === cmp.candidate);
|
|
787
|
+
|
|
788
|
+
lines.push(
|
|
789
|
+
`| R | ${baseArm ? fmtPct(baseArm.R) : 'n/a'} | ${candArm ? fmtPct(candArm.R) : 'n/a'} | ${fmtSignedPct(d.R)} | [${fmtSignedPct(cmp.ci95.R[0])}, ${fmtSignedPct(cmp.ci95.R[1])}] | McNemar | ${fmtNum(cmp.mcnemar.statistic, 4)} | ${fmtNum(cmp.mcnemar.pValue, 4)} |`,
|
|
790
|
+
);
|
|
791
|
+
lines.push(
|
|
792
|
+
`| Q | ${baseArm ? fmtNum(baseArm.Q, 3) : 'n/a'} | ${candArm ? fmtNum(candArm.Q, 3) : 'n/a'} | ${fmtSignedNum(d.Q, 3)} | [${fmtSignedNum(cmp.ci95.Q[0], 3)}, ${fmtSignedNum(cmp.ci95.Q[1], 3)}] | — | — | — |`,
|
|
793
|
+
);
|
|
794
|
+
lines.push(
|
|
795
|
+
`| C ($) | ${baseArm ? fmtUsd(baseArm.C) : 'n/a'} | ${candArm ? fmtUsd(candArm.C) : 'n/a'} | ${fmtSignedUsd(d.C)} | [${fmtSignedUsd(cmp.ci95.C[0])}, ${fmtSignedUsd(cmp.ci95.C[1])}] | Wilcoxon | ${fmtNum(cmp.wilcoxon.statistic, 4)} | ${fmtNum(cmp.wilcoxon.pValue, 4)} |`,
|
|
796
|
+
);
|
|
797
|
+
lines.push(
|
|
798
|
+
`| CPI ($) | ${baseArm ? fmtUsd(baseArm.CPI) : 'n/a'} | ${candArm ? fmtUsd(candArm.CPI) : 'n/a'} | ${fmtSignedUsd(d.CPI)} | ${cmp.ci95.CPI ? `[${fmtSignedUsd(cmp.ci95.CPI[0])}, ${fmtSignedUsd(cmp.ci95.CPI[1])}]` : 'n/a'} | — | — | — |`,
|
|
799
|
+
);
|
|
800
|
+
lines.push(
|
|
801
|
+
`| Turns | ${baseArm ? fmtNum(baseArm.turns, 2) : 'n/a'} | ${candArm ? fmtNum(candArm.turns, 2) : 'n/a'} | ${fmtSignedNum(d.turns, 2)} | [${fmtSignedNum(cmp.ci95.turns[0], 2)}, ${fmtSignedNum(cmp.ci95.turns[1], 2)}] | — | — | — |`,
|
|
802
|
+
);
|
|
803
|
+
lines.push(
|
|
804
|
+
`| Tool calls | ${baseArm ? fmtNum(baseArm.toolCalls, 2) : 'n/a'} | ${candArm ? fmtNum(candArm.toolCalls, 2) : 'n/a'} | ${fmtSignedNum(d.toolCalls, 2)} | [${fmtSignedNum(cmp.ci95.toolCalls[0], 2)}, ${fmtSignedNum(cmp.ci95.toolCalls[1], 2)}] | — | — | — |`,
|
|
805
|
+
);
|
|
806
|
+
lines.push(
|
|
807
|
+
`| Cache hit | ${baseArm ? fmtPct(baseArm.cacheHitRatio) : 'n/a'} | ${candArm ? fmtPct(candArm.cacheHitRatio) : 'n/a'} | ${fmtSignedPct(d.cacheHitRatio)} | [${fmtSignedPct(cmp.ci95.cacheHitRatio[0])}, ${fmtSignedPct(cmp.ci95.cacheHitRatio[1])}] | — | — | — |`,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return lines.join('\n');
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Build the complete benchmark report for `root`.
|
|
816
|
+
*/
|
|
817
|
+
export async function buildBenchReport(
|
|
818
|
+
root,
|
|
819
|
+
{
|
|
820
|
+
arms: filterArms = [],
|
|
821
|
+
harness: filterHarness,
|
|
822
|
+
model: filterModel,
|
|
823
|
+
compare = null,
|
|
824
|
+
prices: customPrices,
|
|
825
|
+
samples = DEFAULT_BOOTSTRAP_SAMPLES,
|
|
826
|
+
seed = DEFAULT_BOOTSTRAP_SEED,
|
|
827
|
+
} = {},
|
|
828
|
+
) {
|
|
829
|
+
const resolvedRoot = resolve(root);
|
|
830
|
+
const prices = customPrices !== undefined ? customPrices : await loadPrices(resolvedRoot);
|
|
831
|
+
const rawRecords = await loadRunRecords(resolvedRoot);
|
|
832
|
+
|
|
833
|
+
const armFilterSet = new Set(filterArms);
|
|
834
|
+
const filtered = rawRecords.filter((rec) => {
|
|
835
|
+
if (armFilterSet.size > 0 && !armFilterSet.has(rec.arm)) return false;
|
|
836
|
+
if (filterHarness && rec.harness !== filterHarness) return false;
|
|
837
|
+
if (filterModel && rec.model !== filterModel) return false;
|
|
838
|
+
return true;
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
const evaluated = filtered.map((rec) => evaluateRunRecord(rec, prices));
|
|
842
|
+
const byArm = new Map();
|
|
843
|
+
for (const row of evaluated) {
|
|
844
|
+
const list = byArm.get(row.arm) ?? [];
|
|
845
|
+
list.push(row);
|
|
846
|
+
byArm.set(row.arm, list);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const armNames = [...byArm.keys()].sort();
|
|
850
|
+
const arms = armNames.map((armName) => {
|
|
851
|
+
const runs = byArm.get(armName);
|
|
852
|
+
const taskRows = collapseArmByTask(runs);
|
|
853
|
+
const summary = summarizeRows(taskRows);
|
|
854
|
+
const ci95 = bootstrapArmCi(taskRows, { samples, seed });
|
|
855
|
+
return {
|
|
856
|
+
arm: armName,
|
|
857
|
+
tasks: taskRows.length,
|
|
858
|
+
runs: runs.length,
|
|
859
|
+
R: summary.R,
|
|
860
|
+
Q: summary.Q,
|
|
861
|
+
C: summary.C,
|
|
862
|
+
CPI: summary.CPI,
|
|
863
|
+
turns: summary.turns,
|
|
864
|
+
toolCalls: summary.toolCalls,
|
|
865
|
+
cacheHitRatio: summary.cacheHitRatio,
|
|
866
|
+
ci95,
|
|
867
|
+
};
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
let comparison = null;
|
|
871
|
+
if (compare) {
|
|
872
|
+
if (!Array.isArray(compare) || compare.length !== 2 || !compare[0] || !compare[1]) {
|
|
873
|
+
throw new Error('--compare requires two arm names: --compare <arm-a> <arm-b>');
|
|
874
|
+
}
|
|
875
|
+
const [armA, armB] = compare;
|
|
876
|
+
const runsA = byArm.get(armA);
|
|
877
|
+
const runsB = byArm.get(armB);
|
|
878
|
+
if (!runsA || runsA.length === 0) {
|
|
879
|
+
throw new Error(`arm "${armA}" has no stored benchmark runs`);
|
|
880
|
+
}
|
|
881
|
+
if (!runsB || runsB.length === 0) {
|
|
882
|
+
throw new Error(`arm "${armB}" has no stored benchmark runs`);
|
|
883
|
+
}
|
|
884
|
+
comparison = compareArms(armA, runsA, armB, runsB, { samples, seed });
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const report = {
|
|
888
|
+
root: resolvedRoot,
|
|
889
|
+
pricesLoaded: prices !== null,
|
|
890
|
+
runCount: evaluated.length,
|
|
891
|
+
arms,
|
|
892
|
+
comparison,
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
return {
|
|
896
|
+
...report,
|
|
897
|
+
markdown: formatReportMarkdown(report),
|
|
898
|
+
};
|
|
899
|
+
}
|