simt-dev 0.7.1 → 0.8.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 +36 -1
- package/README.md +169 -80
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
- package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +3 -1
- package/rules/mdc/intake-questions.mdc +8 -0
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +7 -5
- package/src/argv.mjs +19 -0
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +155 -113
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +12 -26
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +17 -535
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +15 -1037
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +116 -971
- package/src/bench/run.mjs +164 -881
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +11 -13
- package/src/ci/dependabot.mjs +25 -55
- package/src/commands/agent.mjs +1 -9
- package/src/commands/agenthook.mjs +66 -35
- package/src/commands/bench.mjs +2 -2
- package/src/commands/ci.mjs +11 -13
- package/src/commands/docs.mjs +88 -0
- package/src/commands/gate.mjs +10 -13
- package/src/commands/harness.mjs +3 -3
- package/src/commands/help.mjs +44 -3
- package/src/commands/hooks.mjs +20 -20
- package/src/commands/ignore.mjs +11 -7
- package/src/commands/impact.mjs +4 -8
- package/src/commands/index.mjs +49 -9
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +122 -104
- package/src/commands/lease.mjs +67 -18
- package/src/commands/map.mjs +10 -5
- package/src/commands/mcp.mjs +2 -17
- package/src/commands/repoindex.mjs +8 -8
- package/src/commands/rules.mjs +8 -4
- package/src/commands/shared.mjs +12 -16
- package/src/commands/skills.mjs +5 -6
- package/src/commands/skillscompile.mjs +3 -3
- package/src/commands/skillsinstall.mjs +5 -4
- package/src/commands/slice.mjs +199 -64
- package/src/commands/spec.mjs +78 -62
- package/src/commands/status.mjs +24 -12
- package/src/commands/sync.mjs +14 -14
- package/src/commands/tests.mjs +12 -12
- package/src/commands/tokens.mjs +19 -24
- package/src/commands/upgrade.mjs +8 -4
- package/src/commands/why.mjs +6 -13
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/decisions/index.mjs +3 -2
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +2 -1
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +5 -3
- package/src/hooks/dispatcher.mjs +26 -32
- package/src/hooks/gates.mjs +12 -2
- package/src/hooks/resolve.mjs +2 -7
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +2 -11
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +16 -11
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +124 -17
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +49 -9
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -1005
- package/src/{bench → measure}/corpus.mjs +1 -1
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/output/index.mjs +122 -31
- package/src/packs/manage.mjs +15 -9
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/manifest.mjs +10 -435
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +16 -547
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +13 -1
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +0 -7
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +8 -12
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +1 -1
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +3 -1
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/src/bench/tasks.mjs +0 -36
package/src/bench/report.mjs
CHANGED
|
@@ -1,961 +1,59 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The benchmark report, as data.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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/local/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
|
-
// The pre-move path is a fallback for one release (review 0003 Q19).
|
|
78
|
-
const pricesPath = join(resolve(root), LOCAL_DIR, 'bench', 'prices.json');
|
|
79
|
-
const legacyPath = join(resolve(root), '.simt', 'bench', 'prices.json');
|
|
80
|
-
let rawText;
|
|
81
|
-
try {
|
|
82
|
-
rawText = await readFile(pricesPath, 'utf8');
|
|
83
|
-
} catch (error) {
|
|
84
|
-
if (error && error.code === 'ENOENT') {
|
|
85
|
-
try {
|
|
86
|
-
rawText = await readFile(legacyPath, 'utf8');
|
|
87
|
-
} catch (legacyError) {
|
|
88
|
-
if (legacyError && legacyError.code === 'ENOENT') return null;
|
|
89
|
-
throw legacyError;
|
|
90
|
-
}
|
|
91
|
-
} else throw error;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
let parsed;
|
|
95
|
-
try {
|
|
96
|
-
parsed = JSON.parse(rawText);
|
|
97
|
-
} catch (error) {
|
|
98
|
-
throw new Error(`invalid JSON in ${pricesPath}: ${error.message}`);
|
|
99
|
-
}
|
|
100
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
101
|
-
throw new Error(`${pricesPath} must be a JSON object`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (isFlatPriceObject(parsed)) {
|
|
105
|
-
return { kind: 'flat', entry: normalizePriceEntry(parsed, pricesPath) };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const entries = {};
|
|
109
|
-
for (const [key, val] of Object.entries(parsed)) {
|
|
110
|
-
entries[key] = normalizePriceEntry(val, `${pricesPath}[${JSON.stringify(key)}]`);
|
|
111
|
-
}
|
|
112
|
-
return { kind: 'map', entries };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function resolvePriceEntry(prices, run) {
|
|
116
|
-
if (!prices) return null;
|
|
117
|
-
if (prices.kind === 'flat') return normalizePriceEntry(prices.entry, 'prices');
|
|
118
|
-
if (prices.kind === 'map') {
|
|
119
|
-
const entry =
|
|
120
|
-
(run?.model && prices.entries[run.model]) ||
|
|
121
|
-
(run?.harness && prices.entries[run.harness]) ||
|
|
122
|
-
prices.entries.default ||
|
|
123
|
-
null;
|
|
124
|
-
return entry ? normalizePriceEntry(entry, 'prices') : null;
|
|
125
|
-
}
|
|
126
|
-
if (isFlatPriceObject(prices)) {
|
|
127
|
-
return normalizePriceEntry(prices, 'prices');
|
|
128
|
-
}
|
|
129
|
-
const entry =
|
|
130
|
-
(run?.model && prices[run.model]) ||
|
|
131
|
-
(run?.harness && prices[run.harness]) ||
|
|
132
|
-
prices.default ||
|
|
133
|
-
null;
|
|
134
|
-
return entry ? normalizePriceEntry(entry, 'prices') : null;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Compute billed cost in USD for a single run record.
|
|
139
|
-
*
|
|
140
|
-
* Returns `{ usd, source }` where `usd` is null when the cost is unknown — a missing
|
|
141
|
-
* cost must not read as a zero cost (review 0003 Q46: the claude run with usage but no
|
|
142
|
-
* prices reported `C $0.0000 | CPI $0.0000`). `source` is:
|
|
143
|
-
* 'priced' — computed from the token prices table
|
|
144
|
-
* 'reported' — the harness reported `costUsd` directly
|
|
145
|
-
* 'missing' — no prices table and no reported cost; `usd` is null
|
|
146
|
-
*/
|
|
147
|
-
export function computeRunCost(run, prices = null) {
|
|
148
|
-
const usage = run?.usage ?? {};
|
|
149
|
-
const input = Number(usage.input ?? 0);
|
|
150
|
-
const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
|
|
151
|
-
const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? 0);
|
|
152
|
-
const output = Number(usage.output ?? 0);
|
|
153
|
-
const totalTokens = input + cacheWrite + cacheRead + output;
|
|
154
|
-
|
|
155
|
-
const entry = resolvePriceEntry(prices, run);
|
|
156
|
-
if (entry) {
|
|
157
|
-
if (totalTokens === 0 && Number(run?.costUsd ?? 0) > 0) {
|
|
158
|
-
return { usd: Number(run.costUsd), source: 'reported' };
|
|
159
|
-
}
|
|
160
|
-
return {
|
|
161
|
-
usd:
|
|
162
|
-
(input * entry.in +
|
|
163
|
-
cacheWrite * entry.cache_write +
|
|
164
|
-
cacheRead * entry.cache_read +
|
|
165
|
-
output * entry.out) /
|
|
166
|
-
1_000_000,
|
|
167
|
-
source: 'priced',
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
const reported = run?.costUsd;
|
|
171
|
-
if (typeof reported === 'number' && Number.isFinite(reported)) {
|
|
172
|
-
return { usd: reported, source: 'reported' };
|
|
173
|
-
}
|
|
174
|
-
return { usd: null, source: 'missing' };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Whether a run attempt counts as resolved for R: hidden tests pass AND declared gates pass.
|
|
179
|
-
*/
|
|
180
|
-
export function isRunResolved(run) {
|
|
181
|
-
return Boolean(run?.resolved && (run?.gatesPassed ?? true));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Compute quality score Q in [0, 1] for a run record:
|
|
186
|
-
* 0.5 * hiddenTests + 0.2 * gates + 0.2 * probe + 0.1 * diffHygiene
|
|
187
|
-
*/
|
|
188
|
-
export function computeRunQuality(run) {
|
|
189
|
-
const testsScore = run?.resolved ? 1 : 0;
|
|
190
|
-
const gatesScore = (run?.gatesPassed ?? true) ? 1 : 0;
|
|
191
|
-
|
|
192
|
-
let probeScore = 1;
|
|
193
|
-
if (run?.probe !== null && run?.probe !== undefined) {
|
|
194
|
-
if (typeof run.probe === 'boolean') {
|
|
195
|
-
probeScore = run.probe ? 1 : 0;
|
|
196
|
-
} else if (typeof run.probe === 'number' && Number.isFinite(run.probe)) {
|
|
197
|
-
probeScore = Math.max(0, Math.min(1, run.probe));
|
|
198
|
-
} else if (typeof run.probe === 'object') {
|
|
199
|
-
probeScore = run.probe.passed ? 1 : 0;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const files = Number(run?.diff?.files ?? 0);
|
|
204
|
-
const outside = Number(run?.diff?.outsideBlastRadius ?? 0);
|
|
205
|
-
let hygieneScore = 1;
|
|
206
|
-
if (outside > 0) {
|
|
207
|
-
hygieneScore = files > 0 ? Math.max(0, Math.min(1, (files - outside) / files)) : 0;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return (5 * testsScore + 2 * gatesScore + 2 * probeScore + hygieneScore) / 10;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Evaluate per-run derived metrics used by arm aggregation and paired tests.
|
|
215
|
-
*/
|
|
216
|
-
export function evaluateRunRecord(run, prices = null) {
|
|
217
|
-
const usage = run?.usage ?? {};
|
|
218
|
-
const input = Number(usage.input ?? 0);
|
|
219
|
-
const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
|
|
220
|
-
const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? 0);
|
|
221
|
-
const output = Number(usage.output ?? 0);
|
|
222
|
-
const turns = Number(usage.turns ?? 0);
|
|
223
|
-
const toolCalls = Number(usage.toolCalls ?? usage.tool_calls ?? 0);
|
|
224
|
-
const R = isRunResolved(run) ? 1 : 0;
|
|
225
|
-
const Q = computeRunQuality(run);
|
|
226
|
-
const cost = computeRunCost(run, prices);
|
|
227
|
-
const cacheDenom = input + cacheRead;
|
|
228
|
-
const cacheHitRatio = cacheDenom > 0 ? cacheRead / cacheDenom : 0;
|
|
229
|
-
|
|
230
|
-
return {
|
|
231
|
-
task: String(run?.task ?? ''),
|
|
232
|
-
arm: String(run?.arm ?? 'default'),
|
|
233
|
-
harness: String(run?.harness ?? ''),
|
|
234
|
-
model: String(run?.model ?? ''),
|
|
235
|
-
experiment: String(run?.experiment ?? ''),
|
|
236
|
-
rep: Number(run?.rep ?? 1),
|
|
237
|
-
R,
|
|
238
|
-
Q,
|
|
239
|
-
C: cost.usd,
|
|
240
|
-
costSource: cost.source,
|
|
241
|
-
turns,
|
|
242
|
-
toolCalls,
|
|
243
|
-
input,
|
|
244
|
-
cacheWrite,
|
|
245
|
-
cacheRead,
|
|
246
|
-
output,
|
|
247
|
-
cacheHitRatio,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function mean(values) {
|
|
252
|
-
if (values.length === 0) return 0;
|
|
253
|
-
let sum = 0;
|
|
254
|
-
for (const v of values) sum += v;
|
|
255
|
-
return sum / values.length;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Aggregate a list of task-level (or run-level) metric rows into arm summary metrics.
|
|
260
|
-
*
|
|
261
|
-
* `C` is null when any row's cost is missing — a partial cost must not read as a
|
|
262
|
-
* complete one. `CPI` is total cost divided by resolved outcomes (plan 0004 §1:
|
|
263
|
-
* "undefined when none resolve, never zero or a favourable finite score").
|
|
264
|
-
*/
|
|
265
|
-
function summarizeRows(rows) {
|
|
266
|
-
if (rows.length === 0) {
|
|
267
|
-
return {
|
|
268
|
-
R: 0,
|
|
269
|
-
Q: 0,
|
|
270
|
-
C: null,
|
|
271
|
-
CPI: null,
|
|
272
|
-
turns: 0,
|
|
273
|
-
toolCalls: 0,
|
|
274
|
-
cacheHitRatio: 0,
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
const R = mean(rows.map((r) => r.R));
|
|
278
|
-
const Q = mean(rows.map((r) => r.Q));
|
|
279
|
-
const costComplete = rows.every((r) => r.C !== null && r.C !== undefined);
|
|
280
|
-
const C = costComplete ? mean(rows.map((r) => r.C)) : null;
|
|
281
|
-
const turns = mean(rows.map((r) => r.turns));
|
|
282
|
-
const toolCalls = mean(rows.map((r) => r.toolCalls));
|
|
283
|
-
let totalInput = 0;
|
|
284
|
-
let totalCacheRead = 0;
|
|
285
|
-
for (const r of rows) {
|
|
286
|
-
totalInput += r.input;
|
|
287
|
-
totalCacheRead += r.cacheRead;
|
|
288
|
-
}
|
|
289
|
-
const cacheHitRatio =
|
|
290
|
-
totalInput + totalCacheRead > 0 ? totalCacheRead / (totalInput + totalCacheRead) : 0;
|
|
291
|
-
|
|
292
|
-
let totalCost = 0;
|
|
293
|
-
let resolvedCount = 0;
|
|
294
|
-
for (const r of rows) {
|
|
295
|
-
const reps = Number(r.reps ?? 1);
|
|
296
|
-
totalCost += (r.C ?? 0) * reps;
|
|
297
|
-
resolvedCount += r.R * reps;
|
|
298
|
-
}
|
|
299
|
-
const CPI = costComplete && resolvedCount > 0 ? totalCost / resolvedCount : null;
|
|
300
|
-
|
|
301
|
-
return { R, Q, C, CPI, turns, toolCalls, cacheHitRatio };
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Group evaluated runs for one arm by task so multi-repetition tasks form one task unit
|
|
306
|
-
* for task-level means and bootstrap resampling.
|
|
307
|
-
*/
|
|
308
|
-
export function collapseArmByTask(evaluatedRuns) {
|
|
309
|
-
const byTask = new Map();
|
|
310
|
-
for (const run of evaluatedRuns) {
|
|
311
|
-
const list = byTask.get(run.task) ?? [];
|
|
312
|
-
list.push(run);
|
|
313
|
-
byTask.set(run.task, list);
|
|
314
|
-
}
|
|
315
|
-
const tasks = [...byTask.keys()].sort();
|
|
316
|
-
return tasks.map((task) => {
|
|
317
|
-
const group = byTask.get(task);
|
|
318
|
-
const R = mean(group.map((r) => r.R));
|
|
319
|
-
const Q = mean(group.map((r) => r.Q));
|
|
320
|
-
const costComplete = group.every((r) => r.C !== null && r.C !== undefined);
|
|
321
|
-
const C = costComplete ? mean(group.map((r) => r.C)) : null;
|
|
322
|
-
const turns = mean(group.map((r) => r.turns));
|
|
323
|
-
const toolCalls = mean(group.map((r) => r.toolCalls));
|
|
324
|
-
const input = mean(group.map((r) => r.input));
|
|
325
|
-
const cacheWrite = mean(group.map((r) => r.cacheWrite));
|
|
326
|
-
const cacheRead = mean(group.map((r) => r.cacheRead));
|
|
327
|
-
const output = mean(group.map((r) => r.output));
|
|
328
|
-
const cacheHitRatio = input + cacheRead > 0 ? cacheRead / (input + cacheRead) : 0;
|
|
329
|
-
return {
|
|
330
|
-
task,
|
|
331
|
-
reps: group.length,
|
|
332
|
-
R,
|
|
333
|
-
Q,
|
|
334
|
-
C,
|
|
335
|
-
turns,
|
|
336
|
-
toolCalls,
|
|
337
|
-
input,
|
|
338
|
-
cacheWrite,
|
|
339
|
-
cacheRead,
|
|
340
|
-
output,
|
|
341
|
-
cacheHitRatio,
|
|
342
|
-
};
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function percentilePair(sortedValues) {
|
|
347
|
-
if (sortedValues.length === 0) return [0, 0];
|
|
348
|
-
if (sortedValues.length === 1) return [sortedValues[0], sortedValues[0]];
|
|
349
|
-
const loIdx = Math.floor(0.025 * (sortedValues.length - 1));
|
|
350
|
-
const hiIdx = Math.ceil(0.975 * (sortedValues.length - 1));
|
|
351
|
-
return [sortedValues[loIdx], sortedValues[hiIdx]];
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Paired bootstrap 95% confidence intervals over task rows.
|
|
356
|
-
*/
|
|
357
|
-
export function bootstrapArmCi(
|
|
358
|
-
taskRows,
|
|
359
|
-
{ samples = DEFAULT_BOOTSTRAP_SAMPLES, seed = DEFAULT_BOOTSTRAP_SEED } = {},
|
|
360
|
-
) {
|
|
361
|
-
const n = taskRows.length;
|
|
362
|
-
// When any task's cost is missing the cost CIs are undefined, not degenerate:
|
|
363
|
-
// dropping the undefined resamples (the old behaviour) quietly narrowed the
|
|
364
|
-
// interval to the tasks that happened to price out (review 0003 Q46).
|
|
365
|
-
const costComplete = taskRows.every((r) => r.C !== null && r.C !== undefined);
|
|
366
|
-
if (n === 0) {
|
|
367
|
-
return {
|
|
368
|
-
R: [0, 0],
|
|
369
|
-
Q: [0, 0],
|
|
370
|
-
C: null,
|
|
371
|
-
CPI: null,
|
|
372
|
-
turns: [0, 0],
|
|
373
|
-
toolCalls: [0, 0],
|
|
374
|
-
cacheHitRatio: [0, 0],
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
if (n === 1) {
|
|
378
|
-
const s = summarizeRows(taskRows);
|
|
379
|
-
return {
|
|
380
|
-
R: [s.R, s.R],
|
|
381
|
-
Q: [s.Q, s.Q],
|
|
382
|
-
C: s.C === null ? null : [s.C, s.C],
|
|
383
|
-
CPI: s.CPI === null ? null : [s.CPI, s.CPI],
|
|
384
|
-
turns: [s.turns, s.turns],
|
|
385
|
-
toolCalls: [s.toolCalls, s.toolCalls],
|
|
386
|
-
cacheHitRatio: [s.cacheHitRatio, s.cacheHitRatio],
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const rng = createRng(seed);
|
|
391
|
-
const rArr = [];
|
|
392
|
-
const qArr = [];
|
|
393
|
-
const cArr = [];
|
|
394
|
-
const cpiArr = [];
|
|
395
|
-
const turnsArr = [];
|
|
396
|
-
const toolsArr = [];
|
|
397
|
-
const cacheArr = [];
|
|
398
|
-
|
|
399
|
-
for (let b = 0; b < samples; b += 1) {
|
|
400
|
-
const draw = new Array(n);
|
|
401
|
-
for (let i = 0; i < n; i += 1) {
|
|
402
|
-
draw[i] = taskRows[Math.floor(rng() * n)];
|
|
403
|
-
}
|
|
404
|
-
const s = summarizeRows(draw);
|
|
405
|
-
rArr.push(s.R);
|
|
406
|
-
qArr.push(s.Q);
|
|
407
|
-
if (costComplete) cArr.push(s.C);
|
|
408
|
-
if (s.CPI !== null) cpiArr.push(s.CPI);
|
|
409
|
-
turnsArr.push(s.turns);
|
|
410
|
-
toolsArr.push(s.toolCalls);
|
|
411
|
-
cacheArr.push(s.cacheHitRatio);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const numSort = (a, b) => a - b;
|
|
415
|
-
rArr.sort(numSort);
|
|
416
|
-
qArr.sort(numSort);
|
|
417
|
-
cArr.sort(numSort);
|
|
418
|
-
cpiArr.sort(numSort);
|
|
419
|
-
turnsArr.sort(numSort);
|
|
420
|
-
toolsArr.sort(numSort);
|
|
421
|
-
cacheArr.sort(numSort);
|
|
422
|
-
|
|
423
|
-
return {
|
|
424
|
-
R: percentilePair(rArr),
|
|
425
|
-
Q: percentilePair(qArr),
|
|
426
|
-
C: costComplete ? percentilePair(cArr) : null,
|
|
427
|
-
CPI: cpiArr.length > 0 ? percentilePair(cpiArr) : null,
|
|
428
|
-
turns: percentilePair(turnsArr),
|
|
429
|
-
toolCalls: percentilePair(toolsArr),
|
|
430
|
-
cacheHitRatio: percentilePair(cacheArr),
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* Complementary error function erfc(x) for normal/chi-square tail probabilities.
|
|
436
|
-
* Maximum relative error < 1.5e-7 (Abramowitz & Stegun 7.1.26).
|
|
437
|
-
*/
|
|
438
|
-
function erfc(x) {
|
|
439
|
-
const z = Math.abs(x);
|
|
440
|
-
const t = 1 / (1 + 0.3275911 * z);
|
|
441
|
-
const a1 = 0.254829592;
|
|
442
|
-
const a2 = -0.284496736;
|
|
443
|
-
const a3 = 1.421413741;
|
|
444
|
-
const a4 = -1.453152027;
|
|
445
|
-
const a5 = 1.061405429;
|
|
446
|
-
const poly = (((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t) * Math.exp(-z * z);
|
|
447
|
-
return x >= 0 ? poly : 2 - poly;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* Two-sided exact binomial p-value for discordant counts (b, c) under p = 0.5.
|
|
452
|
-
*/
|
|
453
|
-
function exactBinomialTwoSided(b, c) {
|
|
454
|
-
const m = b + c;
|
|
455
|
-
if (m === 0) return 1;
|
|
456
|
-
const k = Math.min(b, c);
|
|
457
|
-
// Sum_{j=0..k} C(m, j) * 0.5^m computed recurrence-style to avoid overflow.
|
|
458
|
-
let term = Math.pow(0.5, m);
|
|
459
|
-
let cdf = term;
|
|
460
|
-
for (let j = 1; j <= k; j += 1) {
|
|
461
|
-
term *= (m - j + 1) / j;
|
|
462
|
-
cdf += term;
|
|
463
|
-
}
|
|
464
|
-
return Math.min(1, 2 * cdf);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* McNemar's test on paired binary resolution outcomes (armA vs armB).
|
|
469
|
-
*
|
|
470
|
-
* Contingency counts:
|
|
471
|
-
* n11: both resolved
|
|
472
|
-
* n10 (b): armA resolved, armB did not
|
|
473
|
-
* n01 (c): armA did not, armB resolved
|
|
474
|
-
* n00: neither resolved
|
|
475
|
-
*
|
|
476
|
-
* Statistic: (b - c)^2 / (b + c) (0 when b + c === 0).
|
|
477
|
-
* pValue: exact two-sided binomial test on discordant pairs (b, c).
|
|
478
|
-
*/
|
|
479
|
-
export function mcnemarTest(pairs) {
|
|
480
|
-
let n11 = 0;
|
|
481
|
-
let n10 = 0;
|
|
482
|
-
let n01 = 0;
|
|
483
|
-
let n00 = 0;
|
|
484
|
-
|
|
485
|
-
for (const [a, b] of pairs) {
|
|
486
|
-
const aRes = typeof a === 'number' ? a >= 0.5 : Boolean(a);
|
|
487
|
-
const bRes = typeof b === 'number' ? b >= 0.5 : Boolean(b);
|
|
488
|
-
if (aRes && bRes) n11 += 1;
|
|
489
|
-
else if (aRes && !bRes) n10 += 1;
|
|
490
|
-
else if (!aRes && bRes) n01 += 1;
|
|
491
|
-
else n00 += 1;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const discordant = n10 + n01;
|
|
495
|
-
const statistic = discordant > 0 ? ((n10 - n01) * (n10 - n01)) / discordant : 0;
|
|
496
|
-
const pValue = exactBinomialTwoSided(n10, n01);
|
|
497
|
-
const pValueAsymptotic = discordant > 0 ? erfc(Math.sqrt(statistic / 2)) : 1;
|
|
498
|
-
|
|
499
|
-
return {
|
|
500
|
-
n11,
|
|
501
|
-
n10,
|
|
502
|
-
n01,
|
|
503
|
-
n00,
|
|
504
|
-
b: n10,
|
|
505
|
-
c: n01,
|
|
506
|
-
statistic,
|
|
507
|
-
pValue,
|
|
508
|
-
pValueAsymptotic,
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* Non-inferiority margin for resolution rate (plan 0004 §1): the candidate is
|
|
514
|
-
* non-inferior only when the one-sided 95% lower bound of ΔR stays above -2 pp.
|
|
515
|
-
*/
|
|
516
|
-
export const NON_INFERIORITY_MARGIN = -0.02;
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Below this many paired tasks the two-sided exact sign test cannot reject at α = 0.05
|
|
520
|
-
* (its smallest achievable p-value at n = 4 is 0.125), so the verdict is
|
|
521
|
-
* `insufficient-tasks` instead of a p-value the sample cannot support (review 0003 Q46).
|
|
522
|
-
*/
|
|
523
|
-
export const MIN_PAIRD_TASKS = 5;
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Exact two-sided sign test on paired task-mean outcomes (armA, armB).
|
|
527
|
-
* Zero differences carry no evidence and are excluded, mirroring the Wilcoxon
|
|
528
|
-
* treatment of ties. This replaces McNemar on task means: the sign of a task-mean
|
|
529
|
-
* difference is the paired discrete evidence, and it does not assume the 0/1
|
|
530
|
-
* contingency McNemar requires.
|
|
531
|
-
*/
|
|
532
|
-
export function signTest(pairs) {
|
|
533
|
-
let plus = 0;
|
|
534
|
-
let minus = 0;
|
|
535
|
-
let zero = 0;
|
|
536
|
-
for (const [a, b] of pairs) {
|
|
537
|
-
const d = Number(b) - Number(a);
|
|
538
|
-
if (d > 1e-12) plus += 1;
|
|
539
|
-
else if (d < -1e-12) minus += 1;
|
|
540
|
-
else zero += 1;
|
|
541
|
-
}
|
|
542
|
-
return {
|
|
543
|
-
n: pairs.length,
|
|
544
|
-
plus,
|
|
545
|
-
minus,
|
|
546
|
-
zero,
|
|
547
|
-
statistic: Math.min(plus, minus),
|
|
548
|
-
pValue: exactBinomialTwoSided(plus, minus),
|
|
549
|
-
};
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* Holm step-down p-value adjustment: conservative FWER control for the family of
|
|
554
|
-
* comparisons (ablation sections, rule-variant slots) tested in one run.
|
|
555
|
-
*
|
|
556
|
-
* @param {number[]} pValues raw p-values of the family, in any order
|
|
557
|
-
* @returns {number[]} adjusted p-values in the original order, monotone and in [0, 1]
|
|
558
|
-
*/
|
|
559
|
-
export function holmAdjust(pValues) {
|
|
560
|
-
if (pValues.length === 0) return [];
|
|
561
|
-
const m = pValues.length;
|
|
562
|
-
const indexed = pValues.map((p, i) => [i, Number(p)]).sort((a, b) => a[1] - b[1]);
|
|
563
|
-
const adjusted = new Array(m);
|
|
564
|
-
let runningMax = 0;
|
|
565
|
-
for (let j = 0; j < m; j += 1) {
|
|
566
|
-
runningMax = Math.max(runningMax, Math.min(1, indexed[j][1] * (m - j)));
|
|
567
|
-
adjusted[indexed[j][0]] = runningMax;
|
|
568
|
-
}
|
|
569
|
-
return adjusted;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* Wilcoxon signed-rank test on paired numerical observations (armA, armB),
|
|
574
|
-
* evaluating differences d_i = b_i - a_i.
|
|
575
|
-
*
|
|
576
|
-
* Uses average ranks for ties. For n_r <= 20 non-zero differences, computes the exact
|
|
577
|
-
* two-sided sign-permutation p-value over the assigned ranks; for n_r > 20, uses the
|
|
578
|
-
* tie-adjusted normal approximation.
|
|
579
|
-
*/
|
|
580
|
-
export function wilcoxonSignedRank(pairs) {
|
|
581
|
-
const diffs = [];
|
|
582
|
-
for (const [a, b] of pairs) {
|
|
583
|
-
const d = Number(b) - Number(a);
|
|
584
|
-
if (Math.abs(d) > 1e-12) {
|
|
585
|
-
diffs.push({ d, abs: Math.abs(d), sign: d > 0 ? 1 : -1 });
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
const n = pairs.length;
|
|
590
|
-
const nonZero = diffs.length;
|
|
591
|
-
if (nonZero === 0) {
|
|
592
|
-
return {
|
|
593
|
-
n,
|
|
594
|
-
nonZero: 0,
|
|
595
|
-
wPlus: 0,
|
|
596
|
-
wMinus: 0,
|
|
597
|
-
statistic: 0,
|
|
598
|
-
pValue: 1,
|
|
599
|
-
};
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
diffs.sort((x, y) => x.abs - y.abs);
|
|
603
|
-
|
|
604
|
-
const ranks = new Array(nonZero);
|
|
605
|
-
const tieGroups = [];
|
|
606
|
-
let i = 0;
|
|
607
|
-
while (i < nonZero) {
|
|
608
|
-
let j = i + 1;
|
|
609
|
-
while (j < nonZero && Math.abs(diffs[j].abs - diffs[i].abs) <= 1e-12) {
|
|
610
|
-
j += 1;
|
|
611
|
-
}
|
|
612
|
-
const avgRank = (i + 1 + j) / 2;
|
|
613
|
-
for (let k = i; k < j; k += 1) {
|
|
614
|
-
ranks[k] = avgRank;
|
|
615
|
-
}
|
|
616
|
-
const groupSize = j - i;
|
|
617
|
-
if (groupSize > 1) tieGroups.push(groupSize);
|
|
618
|
-
i = j;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
let wPlus = 0;
|
|
622
|
-
let wMinus = 0;
|
|
623
|
-
for (let k = 0; k < nonZero; k += 1) {
|
|
624
|
-
if (diffs[k].sign > 0) wPlus += ranks[k];
|
|
625
|
-
else wMinus += ranks[k];
|
|
626
|
-
}
|
|
627
|
-
const statistic = Math.min(wPlus, wMinus);
|
|
628
|
-
|
|
629
|
-
let pValue;
|
|
630
|
-
if (nonZero <= 20) {
|
|
631
|
-
// Exact two-sided permutation distribution over the 2^nonZero sign assignments.
|
|
632
|
-
const totalConfigurations = 1 << nonZero;
|
|
633
|
-
const target = statistic + 1e-9;
|
|
634
|
-
let tailCount = 0;
|
|
635
|
-
for (let mask = 0; mask < totalConfigurations; mask += 1) {
|
|
636
|
-
let sum = 0;
|
|
637
|
-
for (let bit = 0; bit < nonZero; bit += 1) {
|
|
638
|
-
if ((mask & (1 << bit)) !== 0) {
|
|
639
|
-
sum += ranks[bit];
|
|
640
|
-
if (sum > target) break;
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
if (sum <= target) tailCount += 1;
|
|
644
|
-
}
|
|
645
|
-
pValue = Math.min(1, (2 * tailCount) / totalConfigurations);
|
|
646
|
-
} else {
|
|
647
|
-
const meanW = (nonZero * (nonZero + 1)) / 4;
|
|
648
|
-
let tieCorrection = 0;
|
|
649
|
-
for (const t of tieGroups) {
|
|
650
|
-
tieCorrection += t * t * t - t;
|
|
651
|
-
}
|
|
652
|
-
const variance =
|
|
653
|
-
(nonZero * (nonZero + 1) * (2 * nonZero + 1) - 0.5 * tieCorrection) / 24;
|
|
654
|
-
const z = variance > 0 ? Math.abs(wPlus - meanW) / Math.sqrt(variance) : 0;
|
|
655
|
-
pValue = erfc(z / Math.SQRT2);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
return {
|
|
659
|
-
n,
|
|
660
|
-
nonZero,
|
|
661
|
-
wPlus,
|
|
662
|
-
wMinus,
|
|
663
|
-
statistic,
|
|
664
|
-
pValue,
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* Pair two arms at the task level and compute deltas, paired bootstrap 95% CIs,
|
|
670
|
-
* an exact sign test on task-mean R, a Wilcoxon signed-rank test on task-mean C,
|
|
671
|
-
* and a one-sided non-inferiority verdict (plan 0004 §1, review 0003 Q46).
|
|
672
|
-
*
|
|
673
|
-
* Reps of a task are not independent tasks: the old run-level pairing paired
|
|
674
|
-
* (task, rep) records when both arms shared the same keys, so one task with ten
|
|
675
|
-
* reps produced ten "paired runs" and an inflated p-value. Pairing is now at the
|
|
676
|
-
* task level only; `pairedRuns` survives as a descriptive count.
|
|
677
|
-
*
|
|
678
|
-
* `verdict`:
|
|
679
|
-
* 'insufficient-tasks' — fewer than MIN_PAIRD_TASKS paired tasks
|
|
680
|
-
* 'non-inferior' — one-sided 95% lower bound of ΔR above NON_INFERIORITY_MARGIN
|
|
681
|
-
* 'inferior' — two-sided 95% CI of ΔR entirely below 0
|
|
682
|
-
* 'inconclusive' — otherwise (CI too wide to decide)
|
|
683
|
-
*/
|
|
684
|
-
export function compareArms(
|
|
685
|
-
armAName,
|
|
686
|
-
armARuns,
|
|
687
|
-
armBName,
|
|
688
|
-
armBRuns,
|
|
689
|
-
{ samples = DEFAULT_BOOTSTRAP_SAMPLES, seed = DEFAULT_BOOTSTRAP_SEED } = {},
|
|
690
|
-
) {
|
|
691
|
-
const tasksA = new Map(collapseArmByTask(armARuns).map((row) => [row.task, row]));
|
|
692
|
-
const tasksB = new Map(collapseArmByTask(armBRuns).map((row) => [row.task, row]));
|
|
693
|
-
const sharedTasks = [...tasksA.keys()].filter((t) => tasksB.has(t)).sort();
|
|
694
|
-
|
|
695
|
-
const pairedTasks = sharedTasks.map((task) => ({
|
|
696
|
-
task,
|
|
697
|
-
a: tasksA.get(task),
|
|
698
|
-
b: tasksB.get(task),
|
|
699
|
-
}));
|
|
700
|
-
|
|
701
|
-
const rPairs = pairedTasks.map((p) => [p.a.R, p.b.R]);
|
|
702
|
-
const cPairs = pairedTasks
|
|
703
|
-
.filter((p) => p.a.C !== null && p.b.C !== null)
|
|
704
|
-
.map((p) => [p.a.C, p.b.C]);
|
|
705
|
-
|
|
706
|
-
const sumA = summarizeRows(pairedTasks.map((p) => p.a));
|
|
707
|
-
const sumB = summarizeRows(pairedTasks.map((p) => p.b));
|
|
708
|
-
|
|
709
|
-
const deltas = {
|
|
710
|
-
R: sumB.R - sumA.R,
|
|
711
|
-
Q: sumB.Q - sumA.Q,
|
|
712
|
-
C: sumB.C !== null && sumA.C !== null ? sumB.C - sumA.C : null,
|
|
713
|
-
CPI: sumB.CPI !== null && sumA.CPI !== null ? sumB.CPI - sumA.CPI : null,
|
|
714
|
-
cpiRatio:
|
|
715
|
-
sumB.CPI !== null && sumA.CPI !== null && sumA.CPI > 0 ? sumB.CPI / sumA.CPI : null,
|
|
716
|
-
turns: sumB.turns - sumA.turns,
|
|
717
|
-
toolCalls: sumB.toolCalls - sumA.toolCalls,
|
|
718
|
-
cacheHitRatio: sumB.cacheHitRatio - sumA.cacheHitRatio,
|
|
719
|
-
};
|
|
720
|
-
|
|
721
|
-
// Paired bootstrap 95% CIs on deltas (B - A).
|
|
722
|
-
const n = pairedTasks.length;
|
|
723
|
-
const costComplete =
|
|
724
|
-
pairedTasks.every((p) => p.a.C !== null && p.a.C !== undefined) &&
|
|
725
|
-
pairedTasks.every((p) => p.b.C !== null && p.b.C !== undefined);
|
|
726
|
-
let ci95;
|
|
727
|
-
if (n <= 1) {
|
|
728
|
-
ci95 = {
|
|
729
|
-
R: [deltas.R, deltas.R],
|
|
730
|
-
Q: [deltas.Q, deltas.Q],
|
|
731
|
-
C: deltas.C === null ? null : [deltas.C, deltas.C],
|
|
732
|
-
CPI: deltas.CPI === null ? null : [deltas.CPI, deltas.CPI],
|
|
733
|
-
turns: [deltas.turns, deltas.turns],
|
|
734
|
-
toolCalls: [deltas.toolCalls, deltas.toolCalls],
|
|
735
|
-
cacheHitRatio: [deltas.cacheHitRatio, deltas.cacheHitRatio],
|
|
736
|
-
};
|
|
737
|
-
} else {
|
|
738
|
-
const rng = createRng(seed);
|
|
739
|
-
const dR = [];
|
|
740
|
-
const dQ = [];
|
|
741
|
-
const dC = [];
|
|
742
|
-
const dCPI = [];
|
|
743
|
-
const dTurns = [];
|
|
744
|
-
const dTools = [];
|
|
745
|
-
const dCache = [];
|
|
746
|
-
|
|
747
|
-
for (let b = 0; b < samples; b += 1) {
|
|
748
|
-
const drawA = new Array(n);
|
|
749
|
-
const drawB = new Array(n);
|
|
750
|
-
for (let i = 0; i < n; i += 1) {
|
|
751
|
-
const pair = pairedTasks[Math.floor(rng() * n)];
|
|
752
|
-
drawA[i] = pair.a;
|
|
753
|
-
drawB[i] = pair.b;
|
|
754
|
-
}
|
|
755
|
-
const sA = summarizeRows(drawA);
|
|
756
|
-
const sB = summarizeRows(drawB);
|
|
757
|
-
dR.push(sB.R - sA.R);
|
|
758
|
-
dQ.push(sB.Q - sA.Q);
|
|
759
|
-
if (costComplete) dC.push(sB.C - sA.C);
|
|
760
|
-
if (sB.CPI !== null && sA.CPI !== null) dCPI.push(sB.CPI - sA.CPI);
|
|
761
|
-
dTurns.push(sB.turns - sA.turns);
|
|
762
|
-
dTools.push(sB.toolCalls - sA.toolCalls);
|
|
763
|
-
dCache.push(sB.cacheHitRatio - sA.cacheHitRatio);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
const numSort = (x, y) => x - y;
|
|
767
|
-
dR.sort(numSort);
|
|
768
|
-
dQ.sort(numSort);
|
|
769
|
-
dC.sort(numSort);
|
|
770
|
-
dCPI.sort(numSort);
|
|
771
|
-
dTurns.sort(numSort);
|
|
772
|
-
dTools.sort(numSort);
|
|
773
|
-
dCache.sort(numSort);
|
|
774
|
-
|
|
775
|
-
ci95 = {
|
|
776
|
-
R: percentilePair(dR),
|
|
777
|
-
Q: percentilePair(dQ),
|
|
778
|
-
C: costComplete ? percentilePair(dC) : null,
|
|
779
|
-
CPI: dCPI.length > 0 ? percentilePair(dCPI) : null,
|
|
780
|
-
turns: percentilePair(dTurns),
|
|
781
|
-
toolCalls: percentilePair(dTools),
|
|
782
|
-
cacheHitRatio: percentilePair(dCache),
|
|
783
|
-
};
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
const rTest = signTest(rPairs);
|
|
787
|
-
const pairedRuns = pairedTasks.reduce(
|
|
788
|
-
(sum, p) => sum + Math.min(Number(p.a.reps ?? 1), Number(p.b.reps ?? 1)),
|
|
789
|
-
0,
|
|
790
|
-
);
|
|
791
|
-
|
|
792
|
-
const bound = ci95.R[0];
|
|
793
|
-
const met = n < MIN_PAIRD_TASKS ? null : bound > NON_INFERIORITY_MARGIN;
|
|
794
|
-
const verdict =
|
|
795
|
-
n < MIN_PAIRD_TASKS
|
|
796
|
-
? 'insufficient-tasks'
|
|
797
|
-
: bound > NON_INFERIORITY_MARGIN
|
|
798
|
-
? 'non-inferior'
|
|
799
|
-
: ci95.R[1] < 0
|
|
800
|
-
? 'inferior'
|
|
801
|
-
: 'inconclusive';
|
|
802
|
-
|
|
803
|
-
return {
|
|
804
|
-
baseline: armAName,
|
|
805
|
-
candidate: armBName,
|
|
806
|
-
pairedTasks: n,
|
|
807
|
-
pairedRuns,
|
|
808
|
-
deltas,
|
|
809
|
-
ci95,
|
|
810
|
-
rTest,
|
|
811
|
-
wilcoxon: wilcoxonSignedRank(cPairs),
|
|
812
|
-
nonInferiority: {
|
|
813
|
-
margin: NON_INFERIORITY_MARGIN,
|
|
814
|
-
bound,
|
|
815
|
-
met,
|
|
816
|
-
},
|
|
817
|
-
verdict,
|
|
818
|
-
};
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
/**
|
|
822
|
-
* Load all stored run records under `.simt/local/bench/runs/`.
|
|
823
|
-
*/
|
|
824
|
-
export async function loadRunRecords(root) {
|
|
825
|
-
// The pre-move directory is a fallback for one release (review 0003 Q19).
|
|
826
|
-
const runsRoot = join(resolve(root), LOCAL_DIR, 'bench', 'runs');
|
|
827
|
-
let entries = await readdir(runsRoot, { withFileTypes: true }).catch(() => null);
|
|
828
|
-
let base = runsRoot;
|
|
829
|
-
if (entries === null) {
|
|
830
|
-
base = join(resolve(root), '.simt', 'bench', 'runs');
|
|
831
|
-
entries = await readdir(base, { withFileTypes: true }).catch(() => []);
|
|
832
|
-
}
|
|
833
|
-
const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
834
|
-
|
|
835
|
-
const records = [];
|
|
836
|
-
for (const entry of sortedEntries) {
|
|
837
|
-
if (entry.name.startsWith('.')) continue;
|
|
838
|
-
const fullPath = join(base, entry.name);
|
|
839
|
-
if (entry.isDirectory()) {
|
|
840
|
-
const files = (await readdir(fullPath).catch(() => []))
|
|
841
|
-
.filter((f) => f.endsWith('.json') && !f.startsWith('.'))
|
|
842
|
-
.sort();
|
|
843
|
-
for (const file of files) {
|
|
844
|
-
const record = JSON.parse(await readFile(join(fullPath, file), 'utf8'));
|
|
845
|
-
records.push(record);
|
|
846
|
-
}
|
|
847
|
-
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
848
|
-
const record = JSON.parse(await readFile(fullPath, 'utf8'));
|
|
849
|
-
records.push(record);
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
return records;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
function fmtPct(v) {
|
|
856
|
-
return `${(v * 100).toFixed(1)}%`;
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
function fmtSignedPct(v) {
|
|
860
|
-
const sign = v > 0 ? '+' : '';
|
|
861
|
-
return `${sign}${(v * 100).toFixed(1)}%`;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
function fmtUsd(v) {
|
|
865
|
-
if (v === null || v === undefined || !Number.isFinite(v)) return 'n/a';
|
|
866
|
-
return `$${v.toFixed(4)}`;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
function fmtSignedUsd(v) {
|
|
870
|
-
if (v === null || v === undefined || !Number.isFinite(v)) return 'n/a';
|
|
871
|
-
const sign = v > 0 ? '+' : '';
|
|
872
|
-
return `${sign}$${v.toFixed(4)}`;
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
function fmtNum(v, digits = 2) {
|
|
876
|
-
if (v === null || v === undefined || !Number.isFinite(v)) return 'n/a';
|
|
877
|
-
return v.toFixed(digits);
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
function fmtSignedNum(v, digits = 2) {
|
|
881
|
-
if (v === null || v === undefined || !Number.isFinite(v)) return 'n/a';
|
|
882
|
-
const sign = v > 0 ? '+' : '';
|
|
883
|
-
return `${sign}${v.toFixed(digits)}`;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
/**
|
|
887
|
-
* Render the markdown table for `simt bench report` (and optional `--compare a b` delta block).
|
|
888
|
-
*/
|
|
889
|
-
export function formatReportMarkdown(report) {
|
|
890
|
-
const lines = [
|
|
891
|
-
'| Arm | Harness | Model | Tasks | Runs | R | 95% CI (R) | Q | C ($) | CPI ($) | Turns | Tool calls | Cache hit |',
|
|
892
|
-
'| --- | --- | --- | ---: | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
|
893
|
-
];
|
|
894
|
-
|
|
895
|
-
for (const arm of report.arms) {
|
|
896
|
-
const rCi = `[${fmtPct(arm.ci95.R[0])}, ${fmtPct(arm.ci95.R[1])}]`;
|
|
897
|
-
lines.push(
|
|
898
|
-
`| ${arm.arm} | ${arm.harness} | ${arm.model} | ${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)} |`,
|
|
899
|
-
);
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
if (report.excluded > 0) {
|
|
903
|
-
lines.push('');
|
|
904
|
-
lines.push(
|
|
905
|
-
`${report.excluded} run record(s) excluded: the run errored or exceeded its deadline, so it recorded no measurement`,
|
|
906
|
-
);
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
if (report.comparison) {
|
|
910
|
-
const cmp = report.comparison;
|
|
911
|
-
const d = cmp.deltas;
|
|
912
|
-
lines.push('');
|
|
913
|
-
lines.push(`### Comparison: \`${cmp.candidate}\` vs \`${cmp.baseline}\` (\`${cmp.candidate} - ${cmp.baseline}\`)`);
|
|
914
|
-
lines.push('');
|
|
915
|
-
lines.push('| Metric | Baseline | Candidate | Delta | 95% CI (Delta) | Test | Statistic | p-value |');
|
|
916
|
-
lines.push('| --- | ---: | ---: | ---: | --- | --- | ---: | ---: |');
|
|
917
|
-
|
|
918
|
-
const baseArm = report.arms.find((a) => a.arm === cmp.baseline);
|
|
919
|
-
const candArm = report.arms.find((a) => a.arm === cmp.candidate);
|
|
920
|
-
|
|
921
|
-
lines.push(
|
|
922
|
-
`| 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])}] | Sign | ${fmtNum(cmp.rTest.statistic, 4)} | ${fmtNum(cmp.rTest.pValue, 4)} |`,
|
|
923
|
-
);
|
|
924
|
-
lines.push(
|
|
925
|
-
`| 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)}] | — | — | — |`,
|
|
926
|
-
);
|
|
927
|
-
lines.push(
|
|
928
|
-
`| C ($) | ${baseArm ? fmtUsd(baseArm.C) : 'n/a'} | ${candArm ? fmtUsd(candArm.C) : 'n/a'} | ${fmtSignedUsd(d.C)} | ${cmp.ci95.C ? `[${fmtSignedUsd(cmp.ci95.C[0])}, ${fmtSignedUsd(cmp.ci95.C[1])}]` : 'n/a'} | Wilcoxon | ${fmtNum(cmp.wilcoxon.statistic, 4)} | ${fmtNum(cmp.wilcoxon.pValue, 4)} |`,
|
|
929
|
-
);
|
|
930
|
-
lines.push(
|
|
931
|
-
`| 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'} | — | — | — |`,
|
|
932
|
-
);
|
|
933
|
-
lines.push(
|
|
934
|
-
`| 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)}] | — | — | — |`,
|
|
935
|
-
);
|
|
936
|
-
lines.push(
|
|
937
|
-
`| 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)}] | — | — | — |`,
|
|
938
|
-
);
|
|
939
|
-
lines.push(
|
|
940
|
-
`| 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])}] | — | — | — |`,
|
|
941
|
-
);
|
|
942
|
-
lines.push('');
|
|
943
|
-
lines.push(
|
|
944
|
-
`Non-inferiority (ΔR lower bound > ${fmtSignedPct(cmp.nonInferiority.margin)}): ${fmtSignedPct(cmp.nonInferiority.bound)} — ${cmp.nonInferiority.met === true ? 'met' : cmp.nonInferiority.met === false ? 'not met' : 'not assessable'}`,
|
|
945
|
-
);
|
|
946
|
-
lines.push(`Verdict: ${cmp.verdict} (${cmp.pairedTasks} paired tasks)`);
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
return lines.join('\n');
|
|
3
|
+
// A report used to be a summary shape plus prose, assembled by the same function, so a
|
|
4
|
+
// caller that wanted the numbers had to accept the table as well. The builder now answers
|
|
5
|
+
// one document — `{reportSchema, experiment, strata, arms, comparisons, exclusions,
|
|
6
|
+
// warnings}` — and `render.mjs` turns it into markdown (review 0003 Q78).
|
|
7
|
+
//
|
|
8
|
+
// Two exclusions are distinguished because they mean different things: a record whose run
|
|
9
|
+
// errored measured nothing, and a record that was filtered out was never a candidate. A
|
|
10
|
+
// corrupt file is a warning, not an exclusion: the file is not a measurement, and calling
|
|
11
|
+
// it one would report a run nobody made.
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { evaluateRunRecord, loadPrices } from '../measure/metrics.mjs';
|
|
15
|
+
import { readRunRecords } from '../measure/records.mjs';
|
|
16
|
+
import { formatReportMarkdown } from './render.mjs';
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_BOOTSTRAP_SAMPLES,
|
|
19
|
+
DEFAULT_BOOTSTRAP_SEED,
|
|
20
|
+
bootstrapArmCi,
|
|
21
|
+
collapseArmByTask,
|
|
22
|
+
compareArms,
|
|
23
|
+
summarizeRows,
|
|
24
|
+
} from '../measure/stats.mjs';
|
|
25
|
+
|
|
26
|
+
/** The report's own schema stamp: a consumer can refuse a document it does not know. */
|
|
27
|
+
const REPORT_SCHEMA = 'simt.bench.report/2';
|
|
28
|
+
|
|
29
|
+
/** The experiment a record was produced under, as one sortable string. */
|
|
30
|
+
function experimentKey(record) {
|
|
31
|
+
const experiment = record?.experiment;
|
|
32
|
+
if (typeof experiment === 'string') return experiment;
|
|
33
|
+
if (experiment && typeof experiment === 'object') {
|
|
34
|
+
const parts = [
|
|
35
|
+
experiment.simtVersion ?? '-',
|
|
36
|
+
experiment.rulesDigest ?? '-',
|
|
37
|
+
experiment.corpusDigest ?? '-',
|
|
38
|
+
experiment.adapterVersion ?? '-',
|
|
39
|
+
];
|
|
40
|
+
return parts.join('/');
|
|
41
|
+
}
|
|
42
|
+
return '';
|
|
950
43
|
}
|
|
951
44
|
|
|
952
45
|
/**
|
|
953
46
|
* Build the complete benchmark report for `root`.
|
|
954
47
|
*
|
|
955
|
-
* Arms are grouped by (harness, model, arm, experiment): pooling the `gold` and
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
48
|
+
* Arms are grouped by (harness, model, arm, experiment): pooling the `gold` and `noop`
|
|
49
|
+
* harness runs of the same arm name into one row hid what each harness actually did
|
|
50
|
+
* (review 0003 Q46). A `--compare` across two groups that differ in harness, model or
|
|
51
|
+
* experiment is refused unless `crossGroup` is set.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} root
|
|
54
|
+
* @param {{arms?: string[], harness?: string, model?: string, compare?: string[]|null,
|
|
55
|
+
* prices?: object|null, samples?: number, seed?: number, crossGroup?: boolean}} [options]
|
|
56
|
+
* @returns {Promise<object>}
|
|
959
57
|
*/
|
|
960
58
|
export async function buildBenchReport(
|
|
961
59
|
root,
|
|
@@ -972,22 +70,24 @@ export async function buildBenchReport(
|
|
|
972
70
|
) {
|
|
973
71
|
const resolvedRoot = resolve(root);
|
|
974
72
|
const prices = customPrices !== undefined ? customPrices : await loadPrices(resolvedRoot);
|
|
975
|
-
const rawRecords = await
|
|
73
|
+
const { records: rawRecords, warnings } = await readRunRecords(resolvedRoot);
|
|
976
74
|
|
|
977
75
|
const armFilterSet = new Set(filterArms);
|
|
978
|
-
const filtered = rawRecords.filter((
|
|
979
|
-
if (armFilterSet.size > 0 && !armFilterSet.has(
|
|
980
|
-
if (filterHarness &&
|
|
981
|
-
if (filterModel &&
|
|
76
|
+
const filtered = rawRecords.filter((record) => {
|
|
77
|
+
if (armFilterSet.size > 0 && !armFilterSet.has(record.arm)) return false;
|
|
78
|
+
if (filterHarness && record.harness !== filterHarness) return false;
|
|
79
|
+
if (filterModel && record.model !== filterModel) return false;
|
|
982
80
|
return true;
|
|
983
81
|
});
|
|
984
82
|
|
|
985
83
|
// A run that errored produced no measurement: averaging its zero tokens, zero diff and
|
|
986
84
|
// unresolved verdict into an arm reports a result nobody observed (review 0003 Q47).
|
|
987
|
-
const errored = filtered.filter((
|
|
85
|
+
const errored = filtered.filter((record) => record.status === 'error');
|
|
86
|
+
const notRun = filtered.filter((record) => record.status === 'planned');
|
|
988
87
|
const evaluated = filtered
|
|
989
|
-
.filter((
|
|
990
|
-
.map((
|
|
88
|
+
.filter((record) => record.status !== 'error' && record.status !== 'planned')
|
|
89
|
+
.map((record) => evaluateRunRecord(record, prices));
|
|
90
|
+
|
|
991
91
|
const byGroup = new Map();
|
|
992
92
|
for (const row of evaluated) {
|
|
993
93
|
const key = `${row.harness}|${row.model}|${row.arm}|${row.experiment}`;
|
|
@@ -1007,6 +107,7 @@ export async function buildBenchReport(
|
|
|
1007
107
|
arm: armName,
|
|
1008
108
|
harness,
|
|
1009
109
|
model,
|
|
110
|
+
experiment: key.split('|').slice(3).join('|'),
|
|
1010
111
|
tasks: taskRows.length,
|
|
1011
112
|
runs: runs.length,
|
|
1012
113
|
R: summary.R,
|
|
@@ -1020,6 +121,34 @@ export async function buildBenchReport(
|
|
|
1020
121
|
};
|
|
1021
122
|
});
|
|
1022
123
|
|
|
124
|
+
// A stratum is a property of the task, so the same arm can answer differently on small
|
|
125
|
+
// and large changes. Pooling them into one number is how a small-task gain reads as a
|
|
126
|
+
// general one.
|
|
127
|
+
const byStratum = new Map();
|
|
128
|
+
for (const row of evaluated) {
|
|
129
|
+
const key = `${row.stratum}|${row.harness}|${row.model}|${row.arm}`;
|
|
130
|
+
const list = byStratum.get(key) ?? [];
|
|
131
|
+
list.push(row);
|
|
132
|
+
byStratum.set(key, list);
|
|
133
|
+
}
|
|
134
|
+
const strata = [...byStratum.keys()].sort().map((key) => {
|
|
135
|
+
const [stratum, harness, model, arm] = key.split('|');
|
|
136
|
+
const rows = byStratum.get(key);
|
|
137
|
+
const summary = summarizeRows(collapseArmByTask(rows));
|
|
138
|
+
return {
|
|
139
|
+
stratum,
|
|
140
|
+
arm,
|
|
141
|
+
harness,
|
|
142
|
+
model,
|
|
143
|
+
runs: rows.length,
|
|
144
|
+
tasks: new Set(rows.map((row) => row.task)).size,
|
|
145
|
+
R: summary.R,
|
|
146
|
+
Q: summary.Q,
|
|
147
|
+
C: summary.C,
|
|
148
|
+
CPI: summary.CPI,
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
|
|
1023
152
|
let comparison = null;
|
|
1024
153
|
if (compare) {
|
|
1025
154
|
if (!Array.isArray(compare) || compare.length !== 2 || !compare[0] || !compare[1]) {
|
|
@@ -1027,15 +156,13 @@ export async function buildBenchReport(
|
|
|
1027
156
|
}
|
|
1028
157
|
const [armA, armB] = compare;
|
|
1029
158
|
const pick = (name) => {
|
|
1030
|
-
const matches = groupKeys.filter((
|
|
1031
|
-
if (matches.length === 0) {
|
|
1032
|
-
throw new Error(`arm "${name}" has no stored benchmark runs`);
|
|
1033
|
-
}
|
|
159
|
+
const matches = groupKeys.filter((key) => key.split('|')[2] === name);
|
|
160
|
+
if (matches.length === 0) throw new Error(`arm "${name}" has no stored benchmark runs`);
|
|
1034
161
|
if (matches.length > 1) {
|
|
1035
162
|
const listing = matches
|
|
1036
|
-
.map((
|
|
1037
|
-
const [
|
|
1038
|
-
return `${
|
|
163
|
+
.map((key) => {
|
|
164
|
+
const [harness, model, arm] = key.split('|');
|
|
165
|
+
return `${arm} (harness: ${harness}, model: ${model})`;
|
|
1039
166
|
})
|
|
1040
167
|
.join('; ');
|
|
1041
168
|
throw new Error(
|
|
@@ -1057,17 +184,35 @@ export async function buildBenchReport(
|
|
|
1057
184
|
comparison = compareArms(armA, a.runs, armB, b.runs, { samples, seed });
|
|
1058
185
|
}
|
|
1059
186
|
|
|
187
|
+
const experiment = {
|
|
188
|
+
records: evaluated.length,
|
|
189
|
+
experiments: [...new Set(evaluated.map((row) => row.experiment).filter((key) => key !== ''))].sort(),
|
|
190
|
+
seeds: [...new Set(rawRecords.map((record) => record?.experiment?.seed).filter((value) => value != null))],
|
|
191
|
+
};
|
|
192
|
+
|
|
1060
193
|
const report = {
|
|
194
|
+
reportSchema: REPORT_SCHEMA,
|
|
1061
195
|
root: resolvedRoot,
|
|
1062
196
|
pricesLoaded: prices !== null,
|
|
1063
197
|
runCount: evaluated.length,
|
|
1064
198
|
excluded: errored.length,
|
|
199
|
+
exclusions: errored.map((record) => ({
|
|
200
|
+
task: record.task ?? null,
|
|
201
|
+
arm: record.arm ?? null,
|
|
202
|
+
harness: record.harness ?? null,
|
|
203
|
+
model: record.model ?? null,
|
|
204
|
+
rep: record.rep ?? null,
|
|
205
|
+
status: record.status,
|
|
206
|
+
error: record.error ?? null,
|
|
207
|
+
})),
|
|
208
|
+
notRun: notRun.length,
|
|
209
|
+
warnings,
|
|
210
|
+
experiment,
|
|
211
|
+
strata,
|
|
1065
212
|
arms,
|
|
213
|
+
comparisons: comparison ? [comparison] : [],
|
|
1066
214
|
comparison,
|
|
1067
215
|
};
|
|
1068
216
|
|
|
1069
|
-
return {
|
|
1070
|
-
...report,
|
|
1071
|
-
markdown: formatReportMarkdown(report),
|
|
1072
|
-
};
|
|
217
|
+
return { ...report, markdown: formatReportMarkdown(report) };
|
|
1073
218
|
}
|