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.
Files changed (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -1,961 +1,59 @@
1
- // Cost model, paired statistical tests and report generator (`simt bench report`, R03).
1
+ // The benchmark report, as data.
2
2
  //
3
- // Reads stored run records from `.simt/local/bench/runs/` and optional token pricing
4
- // from `.simt/local/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
- // exact sign test on task-mean R, Wilcoxon signed-rank test on task-mean C) are
7
- // deterministic. Reps of a task are not independent tasks: pairing happens at the
8
- // task level only (plan 0004 §1, review 0003 Q46).
9
-
10
- import { readdir, readFile } from 'node:fs/promises';
11
- import { join, resolve } from 'node:path';
12
-
13
- import { LOCAL_DIR } from '../state.mjs';
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/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
- * `noop` harness runs of the same arm name into one row hid what each harness
957
- * actually did (review 0003 Q46). A `--compare` across two groups that differ in
958
- * harness, model or experiment is refused unless `crossGroup` is set.
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 loadRunRecords(resolvedRoot);
73
+ const { records: rawRecords, warnings } = await readRunRecords(resolvedRoot);
976
74
 
977
75
  const armFilterSet = new Set(filterArms);
978
- const filtered = rawRecords.filter((rec) => {
979
- if (armFilterSet.size > 0 && !armFilterSet.has(rec.arm)) return false;
980
- if (filterHarness && rec.harness !== filterHarness) return false;
981
- if (filterModel && rec.model !== filterModel) return false;
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((rec) => rec.status === 'error');
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((rec) => rec.status !== 'error')
990
- .map((rec) => evaluateRunRecord(rec, prices));
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((k) => k.split('|')[2] === name);
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((k) => {
1037
- const [h, m, a] = k.split('|');
1038
- return `${a} (harness: ${h}, model: ${m})`;
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
  }