simt-dev 0.6.3 → 0.7.1

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 (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  13. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  18. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
@@ -1,14 +1,16 @@
1
1
  // Cost model, paired statistical tests and report generator (`simt bench report`, R03).
2
2
  //
3
- // Reads stored run records from `.simt-dev/bench/runs/` and optional token pricing
4
- // from `.simt-dev/bench/prices.json` (per million tokens: `{in, cache_write, cache_read, out}`).
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
5
  // Never performs network lookups. All statistical estimators (paired bootstrap 95% CIs,
6
- // McNemar's test on R, Wilcoxon signed-rank test on C) are deterministic.
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).
7
9
 
8
10
  import { readdir, readFile } from 'node:fs/promises';
9
11
  import { join, resolve } from 'node:path';
10
12
 
11
- import { STATE_DIR } from '../name.mjs';
13
+ import { LOCAL_DIR } from '../state.mjs';
12
14
 
13
15
  export const DEFAULT_BOOTSTRAP_SAMPLES = 1000;
14
16
  export const DEFAULT_BOOTSTRAP_SEED = 0x51772026;
@@ -67,18 +69,26 @@ function isFlatPriceObject(obj) {
67
69
  }
68
70
 
69
71
  /**
70
- * Load `.simt-dev/bench/prices.json` if present.
72
+ * Load `.simt/local/bench/prices.json` if present.
71
73
  * Supports either a flat `{in, cache_write, cache_read, out}` object (prices per 1M tokens)
72
74
  * or a map keyed by model/harness name with an optional `"default"` entry.
73
75
  */
74
76
  export async function loadPrices(root) {
75
- const pricesPath = join(resolve(root), STATE_DIR, 'bench', 'prices.json');
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');
76
80
  let rawText;
77
81
  try {
78
82
  rawText = await readFile(pricesPath, 'utf8');
79
83
  } catch (error) {
80
- if (error && error.code === 'ENOENT') return null;
81
- throw 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;
82
92
  }
83
93
 
84
94
  let parsed;
@@ -126,10 +136,13 @@ function resolvePriceEntry(prices, run) {
126
136
 
127
137
  /**
128
138
  * Compute billed cost in USD for a single run record.
129
- * When a price entry is provided (per 1M tokens), computes:
130
- * (input * in + cacheWrite * cache_write + cacheRead * cache_read + output * out) / 1e6
131
- * Falls back to `run.costUsd` when no price table is supplied (or when all token counts are 0
132
- * and the harness reported a direct `costUsd`).
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
133
146
  */
134
147
  export function computeRunCost(run, prices = null) {
135
148
  const usage = run?.usage ?? {};
@@ -142,17 +155,23 @@ export function computeRunCost(run, prices = null) {
142
155
  const entry = resolvePriceEntry(prices, run);
143
156
  if (entry) {
144
157
  if (totalTokens === 0 && Number(run?.costUsd ?? 0) > 0) {
145
- return Number(run.costUsd);
158
+ return { usd: Number(run.costUsd), source: 'reported' };
146
159
  }
147
- return (
148
- (input * entry.in +
149
- cacheWrite * entry.cache_write +
150
- cacheRead * entry.cache_read +
151
- output * entry.out) /
152
- 1_000_000
153
- );
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' };
154
173
  }
155
- return Number(run?.costUsd ?? 0);
174
+ return { usd: null, source: 'missing' };
156
175
  }
157
176
 
158
177
  /**
@@ -204,7 +223,7 @@ export function evaluateRunRecord(run, prices = null) {
204
223
  const toolCalls = Number(usage.toolCalls ?? usage.tool_calls ?? 0);
205
224
  const R = isRunResolved(run) ? 1 : 0;
206
225
  const Q = computeRunQuality(run);
207
- const C = computeRunCost(run, prices);
226
+ const cost = computeRunCost(run, prices);
208
227
  const cacheDenom = input + cacheRead;
209
228
  const cacheHitRatio = cacheDenom > 0 ? cacheRead / cacheDenom : 0;
210
229
 
@@ -213,10 +232,12 @@ export function evaluateRunRecord(run, prices = null) {
213
232
  arm: String(run?.arm ?? 'default'),
214
233
  harness: String(run?.harness ?? ''),
215
234
  model: String(run?.model ?? ''),
235
+ experiment: String(run?.experiment ?? ''),
216
236
  rep: Number(run?.rep ?? 1),
217
237
  R,
218
238
  Q,
219
- C,
239
+ C: cost.usd,
240
+ costSource: cost.source,
220
241
  turns,
221
242
  toolCalls,
222
243
  input,
@@ -236,13 +257,17 @@ function mean(values) {
236
257
 
237
258
  /**
238
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").
239
264
  */
240
265
  function summarizeRows(rows) {
241
266
  if (rows.length === 0) {
242
267
  return {
243
268
  R: 0,
244
269
  Q: 0,
245
- C: 0,
270
+ C: null,
246
271
  CPI: null,
247
272
  turns: 0,
248
273
  toolCalls: 0,
@@ -251,7 +276,8 @@ function summarizeRows(rows) {
251
276
  }
252
277
  const R = mean(rows.map((r) => r.R));
253
278
  const Q = mean(rows.map((r) => r.Q));
254
- const C = mean(rows.map((r) => r.C));
279
+ const costComplete = rows.every((r) => r.C !== null && r.C !== undefined);
280
+ const C = costComplete ? mean(rows.map((r) => r.C)) : null;
255
281
  const turns = mean(rows.map((r) => r.turns));
256
282
  const toolCalls = mean(rows.map((r) => r.toolCalls));
257
283
  let totalInput = 0;
@@ -262,8 +288,15 @@ function summarizeRows(rows) {
262
288
  }
263
289
  const cacheHitRatio =
264
290
  totalInput + totalCacheRead > 0 ? totalCacheRead / (totalInput + totalCacheRead) : 0;
265
- const rq = R * Q;
266
- const CPI = rq > 0 ? C / rq : null;
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;
267
300
 
268
301
  return { R, Q, C, CPI, turns, toolCalls, cacheHitRatio };
269
302
  }
@@ -272,7 +305,7 @@ function summarizeRows(rows) {
272
305
  * Group evaluated runs for one arm by task so multi-repetition tasks form one task unit
273
306
  * for task-level means and bootstrap resampling.
274
307
  */
275
- function collapseArmByTask(evaluatedRuns) {
308
+ export function collapseArmByTask(evaluatedRuns) {
276
309
  const byTask = new Map();
277
310
  for (const run of evaluatedRuns) {
278
311
  const list = byTask.get(run.task) ?? [];
@@ -284,7 +317,8 @@ function collapseArmByTask(evaluatedRuns) {
284
317
  const group = byTask.get(task);
285
318
  const R = mean(group.map((r) => r.R));
286
319
  const Q = mean(group.map((r) => r.Q));
287
- const C = mean(group.map((r) => r.C));
320
+ const costComplete = group.every((r) => r.C !== null && r.C !== undefined);
321
+ const C = costComplete ? mean(group.map((r) => r.C)) : null;
288
322
  const turns = mean(group.map((r) => r.turns));
289
323
  const toolCalls = mean(group.map((r) => r.toolCalls));
290
324
  const input = mean(group.map((r) => r.input));
@@ -325,11 +359,15 @@ export function bootstrapArmCi(
325
359
  { samples = DEFAULT_BOOTSTRAP_SAMPLES, seed = DEFAULT_BOOTSTRAP_SEED } = {},
326
360
  ) {
327
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);
328
366
  if (n === 0) {
329
367
  return {
330
368
  R: [0, 0],
331
369
  Q: [0, 0],
332
- C: [0, 0],
370
+ C: null,
333
371
  CPI: null,
334
372
  turns: [0, 0],
335
373
  toolCalls: [0, 0],
@@ -341,7 +379,7 @@ export function bootstrapArmCi(
341
379
  return {
342
380
  R: [s.R, s.R],
343
381
  Q: [s.Q, s.Q],
344
- C: [s.C, s.C],
382
+ C: s.C === null ? null : [s.C, s.C],
345
383
  CPI: s.CPI === null ? null : [s.CPI, s.CPI],
346
384
  turns: [s.turns, s.turns],
347
385
  toolCalls: [s.toolCalls, s.toolCalls],
@@ -366,7 +404,7 @@ export function bootstrapArmCi(
366
404
  const s = summarizeRows(draw);
367
405
  rArr.push(s.R);
368
406
  qArr.push(s.Q);
369
- cArr.push(s.C);
407
+ if (costComplete) cArr.push(s.C);
370
408
  if (s.CPI !== null) cpiArr.push(s.CPI);
371
409
  turnsArr.push(s.turns);
372
410
  toolsArr.push(s.toolCalls);
@@ -385,7 +423,7 @@ export function bootstrapArmCi(
385
423
  return {
386
424
  R: percentilePair(rArr),
387
425
  Q: percentilePair(qArr),
388
- C: percentilePair(cArr),
426
+ C: costComplete ? percentilePair(cArr) : null,
389
427
  CPI: cpiArr.length > 0 ? percentilePair(cpiArr) : null,
390
428
  turns: percentilePair(turnsArr),
391
429
  toolCalls: percentilePair(toolsArr),
@@ -471,6 +509,66 @@ export function mcnemarTest(pairs) {
471
509
  };
472
510
  }
473
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
+
474
572
  /**
475
573
  * Wilcoxon signed-rank test on paired numerical observations (armA, armB),
476
574
  * evaluating differences d_i = b_i - a_i.
@@ -568,8 +666,20 @@ export function wilcoxonSignedRank(pairs) {
568
666
  }
569
667
 
570
668
  /**
571
- * Pair two arms' evaluated runs and compute deltas, paired bootstrap 95% CIs,
572
- * McNemar test on R, and Wilcoxon signed-rank test on C.
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)
573
683
  */
574
684
  export function compareArms(
575
685
  armAName,
@@ -588,24 +698,10 @@ export function compareArms(
588
698
  b: tasksB.get(task),
589
699
  }));
590
700
 
591
- // Pair individual (task, rep) records when both arms share the same (task, rep) keys;
592
- // otherwise fall back to task-level pairs.
593
- const runMapA = new Map(armARuns.map((r) => [`${r.task}#${r.rep}`, r]));
594
- const runMapB = new Map(armBRuns.map((r) => [`${r.task}#${r.rep}`, r]));
595
- const sharedRunKeys = [...runMapA.keys()].filter((k) => runMapB.has(k)).sort();
596
-
597
- const useRunPairs =
598
- sharedRunKeys.length > 0 &&
599
- sharedRunKeys.length === armARuns.length &&
600
- sharedRunKeys.length === armBRuns.length;
601
-
602
- const rPairs = useRunPairs
603
- ? sharedRunKeys.map((k) => [runMapA.get(k).R, runMapB.get(k).R])
604
- : pairedTasks.map((p) => [p.a.R, p.b.R]);
605
-
606
- const cPairs = useRunPairs
607
- ? sharedRunKeys.map((k) => [runMapA.get(k).C, runMapB.get(k).C])
608
- : pairedTasks.map((p) => [p.a.C, p.b.C]);
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]);
609
705
 
610
706
  const sumA = summarizeRows(pairedTasks.map((p) => p.a));
611
707
  const sumB = summarizeRows(pairedTasks.map((p) => p.b));
@@ -613,7 +709,7 @@ export function compareArms(
613
709
  const deltas = {
614
710
  R: sumB.R - sumA.R,
615
711
  Q: sumB.Q - sumA.Q,
616
- C: sumB.C - sumA.C,
712
+ C: sumB.C !== null && sumA.C !== null ? sumB.C - sumA.C : null,
617
713
  CPI: sumB.CPI !== null && sumA.CPI !== null ? sumB.CPI - sumA.CPI : null,
618
714
  cpiRatio:
619
715
  sumB.CPI !== null && sumA.CPI !== null && sumA.CPI > 0 ? sumB.CPI / sumA.CPI : null,
@@ -624,12 +720,15 @@ export function compareArms(
624
720
 
625
721
  // Paired bootstrap 95% CIs on deltas (B - A).
626
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);
627
726
  let ci95;
628
727
  if (n <= 1) {
629
728
  ci95 = {
630
729
  R: [deltas.R, deltas.R],
631
730
  Q: [deltas.Q, deltas.Q],
632
- C: [deltas.C, deltas.C],
731
+ C: deltas.C === null ? null : [deltas.C, deltas.C],
633
732
  CPI: deltas.CPI === null ? null : [deltas.CPI, deltas.CPI],
634
733
  turns: [deltas.turns, deltas.turns],
635
734
  toolCalls: [deltas.toolCalls, deltas.toolCalls],
@@ -657,7 +756,7 @@ export function compareArms(
657
756
  const sB = summarizeRows(drawB);
658
757
  dR.push(sB.R - sA.R);
659
758
  dQ.push(sB.Q - sA.Q);
660
- dC.push(sB.C - sA.C);
759
+ if (costComplete) dC.push(sB.C - sA.C);
661
760
  if (sB.CPI !== null && sA.CPI !== null) dCPI.push(sB.CPI - sA.CPI);
662
761
  dTurns.push(sB.turns - sA.turns);
663
762
  dTools.push(sB.toolCalls - sA.toolCalls);
@@ -676,7 +775,7 @@ export function compareArms(
676
775
  ci95 = {
677
776
  R: percentilePair(dR),
678
777
  Q: percentilePair(dQ),
679
- C: percentilePair(dC),
778
+ C: costComplete ? percentilePair(dC) : null,
680
779
  CPI: dCPI.length > 0 ? percentilePair(dCPI) : null,
681
780
  turns: percentilePair(dTurns),
682
781
  toolCalls: percentilePair(dTools),
@@ -684,30 +783,59 @@ export function compareArms(
684
783
  };
685
784
  }
686
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
+
687
803
  return {
688
804
  baseline: armAName,
689
805
  candidate: armBName,
690
- pairedTasks: pairedTasks.length,
691
- pairedRuns: rPairs.length,
806
+ pairedTasks: n,
807
+ pairedRuns,
692
808
  deltas,
693
809
  ci95,
694
- mcnemar: mcnemarTest(rPairs),
810
+ rTest,
695
811
  wilcoxon: wilcoxonSignedRank(cPairs),
812
+ nonInferiority: {
813
+ margin: NON_INFERIORITY_MARGIN,
814
+ bound,
815
+ met,
816
+ },
817
+ verdict,
696
818
  };
697
819
  }
698
820
 
699
821
  /**
700
- * Load all stored run records under `.simt-dev/bench/runs/`.
822
+ * Load all stored run records under `.simt/local/bench/runs/`.
701
823
  */
702
824
  export async function loadRunRecords(root) {
703
- const runsRoot = join(resolve(root), STATE_DIR, 'bench', 'runs');
704
- const entries = await readdir(runsRoot, { withFileTypes: true }).catch(() => []);
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
+ }
705
833
  const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name));
706
834
 
707
835
  const records = [];
708
836
  for (const entry of sortedEntries) {
709
837
  if (entry.name.startsWith('.')) continue;
710
- const fullPath = join(runsRoot, entry.name);
838
+ const fullPath = join(base, entry.name);
711
839
  if (entry.isDirectory()) {
712
840
  const files = (await readdir(fullPath).catch(() => []))
713
841
  .filter((f) => f.endsWith('.json') && !f.startsWith('.'))
@@ -760,14 +888,21 @@ function fmtSignedNum(v, digits = 2) {
760
888
  */
761
889
  export function formatReportMarkdown(report) {
762
890
  const lines = [
763
- '| Arm | Tasks | Runs | R | 95% CI (R) | Q | C ($) | CPI ($) | Turns | Tool calls | Cache hit |',
764
- '| --- | ---: | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: |',
891
+ '| Arm | Harness | Model | Tasks | Runs | R | 95% CI (R) | Q | C ($) | CPI ($) | Turns | Tool calls | Cache hit |',
892
+ '| --- | --- | --- | ---: | ---: | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: |',
765
893
  ];
766
894
 
767
895
  for (const arm of report.arms) {
768
896
  const rCi = `[${fmtPct(arm.ci95.R[0])}, ${fmtPct(arm.ci95.R[1])}]`;
769
897
  lines.push(
770
- `| ${arm.arm} | ${arm.tasks} | ${arm.runs} | ${fmtPct(arm.R)} | ${rCi} | ${fmtNum(arm.Q, 3)} | ${fmtUsd(arm.C)} | ${fmtUsd(arm.CPI)} | ${fmtNum(arm.turns, 2)} | ${fmtNum(arm.toolCalls, 2)} | ${fmtPct(arm.cacheHitRatio)} |`,
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`,
771
906
  );
772
907
  }
773
908
 
@@ -784,13 +919,13 @@ export function formatReportMarkdown(report) {
784
919
  const candArm = report.arms.find((a) => a.arm === cmp.candidate);
785
920
 
786
921
  lines.push(
787
- `| R | ${baseArm ? fmtPct(baseArm.R) : 'n/a'} | ${candArm ? fmtPct(candArm.R) : 'n/a'} | ${fmtSignedPct(d.R)} | [${fmtSignedPct(cmp.ci95.R[0])}, ${fmtSignedPct(cmp.ci95.R[1])}] | McNemar | ${fmtNum(cmp.mcnemar.statistic, 4)} | ${fmtNum(cmp.mcnemar.pValue, 4)} |`,
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)} |`,
788
923
  );
789
924
  lines.push(
790
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)}] | — | — | — |`,
791
926
  );
792
927
  lines.push(
793
- `| C ($) | ${baseArm ? fmtUsd(baseArm.C) : 'n/a'} | ${candArm ? fmtUsd(candArm.C) : 'n/a'} | ${fmtSignedUsd(d.C)} | [${fmtSignedUsd(cmp.ci95.C[0])}, ${fmtSignedUsd(cmp.ci95.C[1])}] | Wilcoxon | ${fmtNum(cmp.wilcoxon.statistic, 4)} | ${fmtNum(cmp.wilcoxon.pValue, 4)} |`,
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)} |`,
794
929
  );
795
930
  lines.push(
796
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'} | — | — | — |`,
@@ -804,6 +939,11 @@ export function formatReportMarkdown(report) {
804
939
  lines.push(
805
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])}] | — | — | — |`,
806
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)`);
807
947
  }
808
948
 
809
949
  return lines.join('\n');
@@ -811,6 +951,11 @@ export function formatReportMarkdown(report) {
811
951
 
812
952
  /**
813
953
  * Build the complete benchmark report for `root`.
954
+ *
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.
814
959
  */
815
960
  export async function buildBenchReport(
816
961
  root,
@@ -822,6 +967,7 @@ export async function buildBenchReport(
822
967
  prices: customPrices,
823
968
  samples = DEFAULT_BOOTSTRAP_SAMPLES,
824
969
  seed = DEFAULT_BOOTSTRAP_SEED,
970
+ crossGroup = false,
825
971
  } = {},
826
972
  ) {
827
973
  const resolvedRoot = resolve(root);
@@ -836,22 +982,31 @@ export async function buildBenchReport(
836
982
  return true;
837
983
  });
838
984
 
839
- const evaluated = filtered.map((rec) => evaluateRunRecord(rec, prices));
840
- const byArm = new Map();
985
+ // A run that errored produced no measurement: averaging its zero tokens, zero diff and
986
+ // unresolved verdict into an arm reports a result nobody observed (review 0003 Q47).
987
+ const errored = filtered.filter((rec) => rec.status === 'error');
988
+ const evaluated = filtered
989
+ .filter((rec) => rec.status !== 'error')
990
+ .map((rec) => evaluateRunRecord(rec, prices));
991
+ const byGroup = new Map();
841
992
  for (const row of evaluated) {
842
- const list = byArm.get(row.arm) ?? [];
993
+ const key = `${row.harness}|${row.model}|${row.arm}|${row.experiment}`;
994
+ const list = byGroup.get(key) ?? [];
843
995
  list.push(row);
844
- byArm.set(row.arm, list);
996
+ byGroup.set(key, list);
845
997
  }
846
998
 
847
- const armNames = [...byArm.keys()].sort();
848
- const arms = armNames.map((armName) => {
849
- const runs = byArm.get(armName);
999
+ const groupKeys = [...byGroup.keys()].sort();
1000
+ const arms = groupKeys.map((key) => {
1001
+ const [harness, model, armName] = key.split('|');
1002
+ const runs = byGroup.get(key);
850
1003
  const taskRows = collapseArmByTask(runs);
851
1004
  const summary = summarizeRows(taskRows);
852
1005
  const ci95 = bootstrapArmCi(taskRows, { samples, seed });
853
1006
  return {
854
1007
  arm: armName,
1008
+ harness,
1009
+ model,
855
1010
  tasks: taskRows.length,
856
1011
  runs: runs.length,
857
1012
  R: summary.R,
@@ -871,21 +1026,42 @@ export async function buildBenchReport(
871
1026
  throw new Error('--compare requires two arm names: --compare <arm-a> <arm-b>');
872
1027
  }
873
1028
  const [armA, armB] = compare;
874
- const runsA = byArm.get(armA);
875
- const runsB = byArm.get(armB);
876
- if (!runsA || runsA.length === 0) {
877
- throw new Error(`arm "${armA}" has no stored benchmark runs`);
878
- }
879
- if (!runsB || runsB.length === 0) {
880
- throw new Error(`arm "${armB}" has no stored benchmark runs`);
1029
+ 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
+ }
1034
+ if (matches.length > 1) {
1035
+ const listing = matches
1036
+ .map((k) => {
1037
+ const [h, m, a] = k.split('|');
1038
+ return `${a} (harness: ${h}, model: ${m})`;
1039
+ })
1040
+ .join('; ');
1041
+ throw new Error(
1042
+ `arm "${name}" is ambiguous — groups: ${listing}; filter with --harness/--model or disambiguate the arm name`,
1043
+ );
1044
+ }
1045
+ return { runs: byGroup.get(matches[0]), groupKey: matches[0] };
1046
+ };
1047
+ const a = pick(armA);
1048
+ const b = pick(armB);
1049
+ const ga = a.groupKey.split('|');
1050
+ const gb = b.groupKey.split('|');
1051
+ const crosses = ga[0] !== gb[0] || ga[1] !== gb[1] || ga[3] !== gb[3];
1052
+ if (crosses && !crossGroup) {
1053
+ throw new Error(
1054
+ `comparing "${armA}" and "${armB}" crosses groups (harness/model differ); pass crossGroup to compare across groups anyway`,
1055
+ );
881
1056
  }
882
- comparison = compareArms(armA, runsA, armB, runsB, { samples, seed });
1057
+ comparison = compareArms(armA, a.runs, armB, b.runs, { samples, seed });
883
1058
  }
884
1059
 
885
1060
  const report = {
886
1061
  root: resolvedRoot,
887
1062
  pricesLoaded: prices !== null,
888
1063
  runCount: evaluated.length,
1064
+ excluded: errored.length,
889
1065
  arms,
890
1066
  comparison,
891
1067
  };