simt-dev 0.2.2 → 0.3.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 (96) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +129 -75
  3. package/bin/simt-dev.mjs +21 -2
  4. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  5. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  6. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  7. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  8. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  9. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  10. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  11. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  12. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  13. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  14. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  15. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  16. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  17. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  18. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  19. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  20. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  21. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  22. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  23. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  24. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  25. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  26. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  27. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  28. package/package.json +2 -1
  29. package/rules/core.md +1 -1
  30. package/src/agenthooks/decide.mjs +123 -0
  31. package/src/agenthooks/policy.mjs +78 -0
  32. package/src/agenthooks/render.mjs +240 -0
  33. package/src/argv.mjs +54 -1
  34. package/src/bench/ablate.mjs +630 -0
  35. package/src/bench/adapters.json +149 -0
  36. package/src/bench/grade.mjs +229 -0
  37. package/src/bench/mine.mjs +811 -0
  38. package/src/bench/probes.mjs +1350 -0
  39. package/src/bench/report.mjs +899 -0
  40. package/src/bench/run.mjs +1002 -0
  41. package/src/ci/workflow.mjs +11 -1
  42. package/src/commands/agenthook.mjs +282 -0
  43. package/src/commands/bench.mjs +371 -0
  44. package/src/commands/evidence.mjs +96 -0
  45. package/src/commands/gate.mjs +165 -0
  46. package/src/commands/harness.mjs +156 -5
  47. package/src/commands/help.mjs +89 -1
  48. package/src/commands/hooks.mjs +45 -5
  49. package/src/commands/impact.mjs +205 -0
  50. package/src/commands/index.mjs +60 -1
  51. package/src/commands/map.mjs +91 -0
  52. package/src/commands/plan.mjs +99 -0
  53. package/src/commands/repoindex.mjs +52 -0
  54. package/src/commands/shared.mjs +398 -0
  55. package/src/commands/sync.mjs +72 -3
  56. package/src/commands/tests.mjs +192 -0
  57. package/src/commands/tokens.mjs +61 -1
  58. package/src/commands/why.mjs +177 -0
  59. package/src/decisions/index.mjs +23 -5
  60. package/src/graph/spec.mjs +1 -1
  61. package/src/hooks/dispatcher.mjs +15 -0
  62. package/src/hooks/shim.mjs +31 -0
  63. package/src/index/compact.mjs +228 -0
  64. package/src/index/extract.mjs +331 -0
  65. package/src/index/gate.mjs +101 -0
  66. package/src/index/govern.mjs +222 -0
  67. package/src/index/impact.mjs +186 -0
  68. package/src/index/map.mjs +393 -0
  69. package/src/index/persist.mjs +172 -0
  70. package/src/index/store.mjs +301 -0
  71. package/src/index/tests.mjs +294 -0
  72. package/src/kernel/budget.mjs +100 -0
  73. package/src/kernel/card.mjs +254 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/ledger/index.mjs +112 -0
  77. package/src/mcp/resources.mjs +157 -0
  78. package/src/mcp/server.mjs +37 -6
  79. package/src/mcp/tools.mjs +84 -0
  80. package/src/measure/context.mjs +184 -11
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/plan/index.mjs +650 -0
  84. package/src/plan/template.mjs +331 -0
  85. package/src/projection/disclosure.mjs +92 -0
  86. package/src/projection/loaders.mjs +689 -0
  87. package/src/projection/lock.mjs +93 -0
  88. package/src/projection/manifest.mjs +78 -3
  89. package/src/projection/markers.mjs +56 -17
  90. package/src/projection/project.mjs +24 -17
  91. package/src/projection/rules.mjs +452 -43
  92. package/src/projection/shared.mjs +428 -0
  93. package/src/projection/surfaces.mjs +22 -8
  94. package/src/projection/targets.mjs +2 -1
  95. package/src/toolwords.mjs +71 -0
  96. package/templates/plan/tracker.json +36 -0
@@ -0,0 +1,630 @@
1
+ // Leave-one-out rule section ablation (`simt bench ablate`, R04).
2
+ //
3
+ // Enumerates every section of `core`, every selected stack profile, and every
4
+ // eligible process rule. Leaves one section out at a time (`no-core-<slug>`,
5
+ // `no-profile-<id>`, `no-process-<id>`), runs on a 10-task canary subset first,
6
+ // escalates significant deltas to the full suite, ranks every section by
7
+ // value per KB (`dR / kb`), and flags sections with `dR ≈ 0` and `dC > 0` as
8
+ // deletion candidates.
9
+
10
+ import { mkdir, readFile, rm, unlink, writeFile } from 'node:fs/promises';
11
+ import { dirname, join, resolve } from 'node:path';
12
+
13
+ import { CONFIG_DIR, MANIFEST_FILE } from '../name.mjs';
14
+ import { readManifest } from '../projection/manifest.mjs';
15
+ import { inject, parse as parseRegion } from '../projection/markers.mjs';
16
+ import { loadMdcRules, renderMdc, ruleName } from '../projection/mdc.mjs';
17
+ import { plan } from '../projection/project.mjs';
18
+ import { isEligible, loadRules, readDeclared, ruleValues, select } from '../projection/rules.mjs';
19
+ import { resolveSurfaces, rulePath, SURFACES } from '../projection/surfaces.mjs';
20
+ import { fingerprint } from '../resolve/fingerprint.mjs';
21
+ import {
22
+ DEFAULT_BOOTSTRAP_SAMPLES,
23
+ DEFAULT_BOOTSTRAP_SEED,
24
+ compareArms,
25
+ evaluateRunRecord,
26
+ loadPrices,
27
+ loadRunRecords,
28
+ } from './report.mjs';
29
+
30
+ const STATE_DIR = `.${CONFIG_DIR}`;
31
+
32
+ export const DEFAULT_CANARY_SIZE = 10;
33
+ export const DEL_R_TOLERANCE = 0.01;
34
+ export const CANARY_ALPHA = 0.05;
35
+
36
+ export function slugifyHeading(title) {
37
+ return String(title)
38
+ .trim()
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, '-')
41
+ .replace(/^-+|-+$/g, '');
42
+ }
43
+
44
+ function demoteHeadings(text) {
45
+ let fence = false;
46
+ return text
47
+ .split('\n')
48
+ .map((line) => {
49
+ if (/^(```|~~~)/.test(line)) {
50
+ fence = !fence;
51
+ return line;
52
+ }
53
+ if (fence) return line;
54
+ return line.replace(/^(#{1,5})(?=\s)/, '$1#');
55
+ })
56
+ .join('\n');
57
+ }
58
+
59
+ /**
60
+ * Split `core` rule body into its `## <Heading>` sections.
61
+ */
62
+ export function parseCoreSections(coreBody) {
63
+ const lines = String(coreBody ?? '').split('\n');
64
+ const sections = [];
65
+ let current = null;
66
+ let fence = false;
67
+
68
+ for (const line of lines) {
69
+ if (/^(```|~~~)/.test(line)) {
70
+ fence = !fence;
71
+ }
72
+ const m = !fence ? /^##\s+(.+?)\s*$/.exec(line) : null;
73
+ if (m) {
74
+ if (current) sections.push(current);
75
+ current = { title: m[1].trim(), lines: [line] };
76
+ } else if (current) {
77
+ current.lines.push(line);
78
+ }
79
+ }
80
+ if (current) sections.push(current);
81
+
82
+ return sections.map((sec) => {
83
+ const raw = sec.lines.join('\n').trim();
84
+ const rendered = demoteHeadings(raw).trim();
85
+ const slug = slugifyHeading(sec.title);
86
+ const bytes = Buffer.byteLength(rendered, 'utf8');
87
+ return {
88
+ id: `core:${slug}`,
89
+ arm: `no-core-${slug}`,
90
+ kind: 'core',
91
+ targetId: slug,
92
+ title: sec.title,
93
+ bytes,
94
+ kb: bytes / 1024,
95
+ };
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Discover every ablatable section for `root`:
101
+ * 1. Each `##` section of `core` (`core:<slug>`)
102
+ * 2. Each stack profile (`profile:<id>`)
103
+ * 3. Each process rule (`process:<id>`)
104
+ */
105
+ export async function listAblationSections(root, { includeAll = false } = {}) {
106
+ const resolvedRoot = resolve(root);
107
+ const [{ stack, facts, manifest }, allRules, mdcRules, declared] = await Promise.all([
108
+ fingerprint(resolvedRoot),
109
+ loadRules(resolvedRoot),
110
+ loadMdcRules(),
111
+ readDeclared(resolvedRoot),
112
+ ]);
113
+
114
+ const signals = new Set([...(stack ?? []), ...(facts ?? [])]);
115
+ const { selected } = select(allRules, stack ?? [], facts ?? []);
116
+ const resolvedSurfaces = resolveSurfaces(manifest, {});
117
+
118
+ const sections = [];
119
+
120
+ // 1. Core sections
121
+ const coreRule = allRules.find((r) => r.id === 'core');
122
+ if (coreRule) {
123
+ sections.push(...parseCoreSections(coreRule.body));
124
+ }
125
+
126
+ // 2. Stack profiles
127
+ const profileSource = includeAll
128
+ ? allRules.filter((r) => r.id && r.id !== 'core')
129
+ : selected.filter((r) => r.id && r.id !== 'core');
130
+
131
+ const sortedProfiles = [...profileSource].sort((a, b) => String(a.id).localeCompare(String(b.id)));
132
+ for (const prof of sortedProfiles) {
133
+ const rendered = `## Stack: ${prof.id}\n\n${demoteHeadings(String(prof.body ?? '').trim()).trim()}`;
134
+ const bytes = Buffer.byteLength(rendered, 'utf8');
135
+ sections.push({
136
+ id: `profile:${prof.id}`,
137
+ arm: `no-profile-${prof.id}`,
138
+ kind: 'profile',
139
+ targetId: prof.id,
140
+ title: prof.title ?? prof.id,
141
+ bytes,
142
+ kb: bytes / 1024,
143
+ });
144
+ }
145
+
146
+ // 3. Process rules
147
+ const processSource = includeAll
148
+ ? mdcRules.filter((r) => r.id && r.error === null)
149
+ : mdcRules.filter((r) => r.id && r.error === null && isEligible(r, signals, declared.optIn));
150
+
151
+ const sortedProcess = [...processSource].sort((a, b) => String(a.id).localeCompare(String(b.id)));
152
+ for (const rule of sortedProcess) {
153
+ const values = ruleValues(rule, declared.values[rule.id] ?? {}, resolvedSurfaces.surfaces);
154
+ const { body } = renderMdc(rule, values);
155
+ const rendered = `## Process: ${rule.id}\n\n${demoteHeadings(String(body ?? '').trim()).trim()}`;
156
+ const bytes = Buffer.byteLength(rendered, 'utf8');
157
+ sections.push({
158
+ id: `process:${rule.id}`,
159
+ arm: `no-process-${rule.id}`,
160
+ kind: 'process',
161
+ targetId: rule.id,
162
+ title: rule.title ?? rule.id,
163
+ bytes,
164
+ kb: bytes / 1024,
165
+ });
166
+ }
167
+
168
+ return sections;
169
+ }
170
+
171
+ /**
172
+ * Parse an ablation arm name (`no-core-<slug>`, `no-profile-<id>`, `no-process-<id>`)
173
+ * or return null when `armName` is not an ablation arm.
174
+ */
175
+ export function parseAblationArmName(armName) {
176
+ const s = String(armName ?? '').trim();
177
+ if (s.startsWith('no-core-') && s.length > 'no-core-'.length) {
178
+ const slug = s.slice('no-core-'.length);
179
+ return {
180
+ name: s,
181
+ kind: 'ablate',
182
+ base: 'current',
183
+ ablate: { kind: 'core', targetId: slug, sectionId: `core:${slug}` },
184
+ };
185
+ }
186
+ if (s.startsWith('no-profile-') && s.length > 'no-profile-'.length) {
187
+ const id = s.slice('no-profile-'.length);
188
+ return {
189
+ name: s,
190
+ kind: 'ablate',
191
+ base: 'current',
192
+ ablate: { kind: 'profile', targetId: id, sectionId: `profile:${id}` },
193
+ };
194
+ }
195
+ if (s.startsWith('no-process-') && s.length > 'no-process-'.length) {
196
+ const id = s.slice('no-process-'.length);
197
+ return {
198
+ name: s,
199
+ kind: 'ablate',
200
+ base: 'current',
201
+ ablate: { kind: 'process', targetId: id, sectionId: `process:${id}` },
202
+ };
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Remove a `### <Heading>` block inside `## Core` from a region body string.
209
+ */
210
+ export function removeCoreSectionFromBody(body, targetSlug) {
211
+ const lines = String(body ?? '').split('\n');
212
+ const out = [];
213
+ let inCore = false;
214
+ let skipping = false;
215
+ let fence = false;
216
+
217
+ for (const line of lines) {
218
+ if (/^(```|~~~)/.test(line)) {
219
+ fence = !fence;
220
+ }
221
+ if (!fence && /^##\s+/.test(line)) {
222
+ inCore = /^##\s+Core\s*$/.test(line);
223
+ skipping = false;
224
+ } else if (!fence && inCore && /^###\s+(.+?)\s*$/.test(line)) {
225
+ const heading = /^###\s+(.+?)\s*$/.exec(line)[1];
226
+ skipping = slugifyHeading(heading) === targetSlug;
227
+ }
228
+ if (!skipping) out.push(line);
229
+ }
230
+
231
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
232
+ }
233
+
234
+ /**
235
+ * Remove a top-level `## Stack: <id>` or `## Process: <id>` block from a region body string.
236
+ */
237
+ export function removeTopSectionFromBody(body, headingPrefix, targetId) {
238
+ const lines = String(body ?? '').split('\n');
239
+ const out = [];
240
+ let skipping = false;
241
+ let fence = false;
242
+ const targetHeader = `## ${headingPrefix}: ${targetId}`;
243
+
244
+ for (const line of lines) {
245
+ if (/^(```|~~~)/.test(line)) {
246
+ fence = !fence;
247
+ }
248
+ if (!fence && /^##\s+/.test(line)) {
249
+ skipping = line.trim() === targetHeader;
250
+ }
251
+ if (!skipping) out.push(line);
252
+ }
253
+
254
+ return out
255
+ .filter((line) => !line.startsWith(`- Stack: ${targetId} —`))
256
+ .join('\n')
257
+ .replace(/\n{3,}/g, '\n\n')
258
+ .trim();
259
+ }
260
+
261
+ /**
262
+ * Ensure `workDir` has a full `current` projection materialised, then strip the
263
+ * specified ablation section (`core`, `profile`, or `process`).
264
+ */
265
+ export async function ablateSectionInWorkspace(workDir, ablate) {
266
+ if (!ablate || typeof ablate !== 'object') return;
267
+
268
+ // Ensure a full projection exists in `workDir` so leaving one section out is
269
+ // well-defined even if the base commit predates adoption.
270
+ const manifestRes = await readManifest(workDir);
271
+ const manifestPath = manifestRes.path ?? join(workDir, MANIFEST_FILE);
272
+ const hadManifest = manifestRes.exists;
273
+ const originalManifestBytes = hadManifest
274
+ ? await readFile(manifestPath, 'utf8').catch(() => null)
275
+ : null;
276
+
277
+ const baseManifest = manifestRes.data ?? {
278
+ schemaVersion: 1,
279
+ projection: { sha: '0000000000000000000000000000000000000000', sources: [] },
280
+ gates: { preCommit: [], prePush: [] },
281
+ };
282
+ const nextManifest = { ...baseManifest };
283
+ delete nextManifest.mode;
284
+ await writeFile(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
285
+
286
+ const planned = await plan(workDir);
287
+ for (const t of planned.targets ?? []) {
288
+ if (t.action !== 'unchanged' && typeof t.text === 'string') {
289
+ await mkdir(dirname(t.path), { recursive: true });
290
+ await writeFile(t.path, t.text, 'utf8');
291
+ }
292
+ }
293
+
294
+ if (!hadManifest) {
295
+ await unlink(manifestPath).catch(() => {});
296
+ } else if (originalManifestBytes !== null) {
297
+ await writeFile(manifestPath, originalManifestBytes, 'utf8');
298
+ }
299
+
300
+ const agentsPath = join(workDir, 'AGENTS.md');
301
+ const agentsText = await readFile(agentsPath, 'utf8').catch(() => null);
302
+ if (agentsText !== null) {
303
+ const parsed = parseRegion(agentsText, 'html');
304
+ if (parsed.status === 'ok') {
305
+ let nextBody = parsed.body;
306
+ if (ablate.kind === 'core') {
307
+ nextBody = removeCoreSectionFromBody(nextBody, ablate.targetId);
308
+ } else if (ablate.kind === 'profile') {
309
+ nextBody = removeTopSectionFromBody(nextBody, 'Stack', ablate.targetId);
310
+ } else if (ablate.kind === 'process') {
311
+ nextBody = removeTopSectionFromBody(nextBody, 'Process', ablate.targetId);
312
+ }
313
+ const injected = inject(agentsText, nextBody, {
314
+ sha: parsed.sha ?? '0000000000000000000000000000000000000000',
315
+ sources: parsed.sources ?? [],
316
+ });
317
+ await writeFile(agentsPath, injected.text, 'utf8');
318
+ }
319
+ }
320
+
321
+ if (ablate.kind === 'profile' || ablate.kind === 'process') {
322
+ const names = new Set([ablate.targetId]);
323
+ if (ablate.kind === 'process') {
324
+ const mdc = await loadMdcRules();
325
+ const matched = mdc.find((r) => r.id === ablate.targetId);
326
+ if (matched) {
327
+ const rn = ruleName(matched);
328
+ if (rn) names.add(rn);
329
+ }
330
+ }
331
+ for (const s of SURFACES) {
332
+ if (!s.rule) continue;
333
+ for (const name of names) {
334
+ const rel = rulePath(s.id, name);
335
+ if (rel) {
336
+ await rm(join(workDir, rel), { force: true }).catch(() => {});
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Decide whether a canary comparison warrants escalating the section arm to the full task suite.
345
+ */
346
+ export function shouldEscalateCanary(comparison, { alpha = CANARY_ALPHA } = {}) {
347
+ if (!comparison || !comparison.pairedTasks) return false;
348
+ if (comparison.mcnemar.pValue < alpha) return true;
349
+ if (comparison.wilcoxon.pValue < alpha) return true;
350
+ if (comparison.ci95.R[0] > 0 || comparison.ci95.R[1] < 0) return true;
351
+ if (Math.abs(comparison.deltas.R) >= 0.2) return true;
352
+ return false;
353
+ }
354
+
355
+ /**
356
+ * Whether a section is a deletion candidate (`ΔR ≈ 0` or `< 0`, and `ΔC > 0`).
357
+ */
358
+ export function isDeletionCandidate({ measured, dR, dC }, { rTolerance = DEL_R_TOLERANCE } = {}) {
359
+ if (!measured) return false;
360
+ return dR <= rTolerance && dC > 0;
361
+ }
362
+
363
+ function fmtSignedPct(v) {
364
+ const sign = v > 0 ? '+' : '';
365
+ return `${sign}${(v * 100).toFixed(1)}%`;
366
+ }
367
+
368
+ function fmtSignedUsd(v) {
369
+ const sign = v > 0 ? '+' : '';
370
+ return `${sign}$${v.toFixed(4)}`;
371
+ }
372
+
373
+ /**
374
+ * Render the ablation report as a Markdown table ranked by value per KB.
375
+ */
376
+ export function formatAblationMarkdown(report) {
377
+ const lines = [
378
+ '| Rank | Section | Kind | KB | Stage | Tasks | ΔR | 95% CI (ΔR) | ΔC ($) | 95% CI (ΔC) | Value / KB (ΔR/KB) | Status |',
379
+ '| ---: | --- | --- | ---: | --- | ---: | ---: | --- | ---: | --- | ---: | --- |',
380
+ ];
381
+
382
+ for (const row of report.ranking) {
383
+ const stage = !row.measured ? 'planned' : row.escalated ? 'full' : 'canary';
384
+ const rCi = `[${fmtSignedPct(row.ci95.dR[0])}, ${fmtSignedPct(row.ci95.dR[1])}]`;
385
+ const cCi = `[${fmtSignedUsd(row.ci95.dC[0])}, ${fmtSignedUsd(row.ci95.dC[1])}]`;
386
+ const status = !row.measured
387
+ ? 'UNMEASURED'
388
+ : row.deletionCandidate
389
+ ? 'DELETE CANDIDATE'
390
+ : row.dR > DEL_R_TOLERANCE
391
+ ? 'KEEP'
392
+ : 'NEUTRAL';
393
+
394
+ lines.push(
395
+ `| ${row.rank} | ${row.id} | ${row.kind} | ${row.kb.toFixed(2)} | ${stage} | ${row.tasks} | ${fmtSignedPct(row.dR)} | ${rCi} | ${fmtSignedUsd(row.dC)} | ${cCi} | ${fmtSignedPct(row.valuePerKb)} | ${status} |`,
396
+ );
397
+ }
398
+
399
+ return lines.join('\n');
400
+ }
401
+
402
+ /**
403
+ * Run or summarise a leave-one-out ablation across every core section, profile and process rule.
404
+ */
405
+ export async function ablateBench(
406
+ root,
407
+ {
408
+ apply = false,
409
+ harness = 'gold',
410
+ model,
411
+ reps = 1,
412
+ canarySize = DEFAULT_CANARY_SIZE,
413
+ includeAll = false,
414
+ sections: customSections,
415
+ prices: customPrices,
416
+ samples = DEFAULT_BOOTSTRAP_SAMPLES,
417
+ seed = DEFAULT_BOOTSTRAP_SEED,
418
+ vaultRoot,
419
+ runner,
420
+ } = {},
421
+ ) {
422
+ const resolvedRoot = resolve(root);
423
+ const sections = customSections ?? (await listAblationSections(resolvedRoot, { includeAll }));
424
+ const prices = customPrices !== undefined ? customPrices : await loadPrices(resolvedRoot);
425
+
426
+ // Discover mined tasks if present.
427
+ const tasksDir = join(resolvedRoot, STATE_DIR, 'bench', 'tasks');
428
+ let taskIds = [];
429
+ try {
430
+ const { readdir: rd } = await import('node:fs/promises');
431
+ const files = (await rd(tasksDir)).filter((f) => f.endsWith('.json')).sort();
432
+ for (const file of files) {
433
+ const data = JSON.parse(await readFile(join(tasksDir, file), 'utf8'));
434
+ if (data?.id) taskIds.push(data.id);
435
+ }
436
+ taskIds.sort();
437
+ } catch {
438
+ taskIds = [];
439
+ }
440
+
441
+ const canaryTaskIds = taskIds.slice(0, Math.max(1, canarySize));
442
+ const remainingTaskIds = taskIds.slice(canaryTaskIds.length);
443
+ const escalatedArms = new Set();
444
+
445
+ if (apply) {
446
+ if (taskIds.length === 0) {
447
+ throw new Error(`no mined benchmark tasks found in ${tasksDir}; run bench mine --yes first`);
448
+ }
449
+ const execRunBench = runner ?? (await import('./run.mjs')).runBench;
450
+
451
+ // 1. Run canary subset for `current` and every leave-one-out section arm.
452
+ const allArmNames = ['current', ...sections.map((s) => s.arm)];
453
+ await execRunBench(resolvedRoot, {
454
+ apply: true,
455
+ arms: allArmNames,
456
+ harness,
457
+ model,
458
+ reps,
459
+ tasks: canaryTaskIds,
460
+ ...(vaultRoot ? { vaultRoot } : {}),
461
+ });
462
+
463
+ // 2. Check canary deltas and escalate only significant arms to the remaining tasks.
464
+ if (remainingTaskIds.length > 0) {
465
+ const canaryRecords = (await loadRunRecords(resolvedRoot)).filter(
466
+ (rec) =>
467
+ canaryTaskIds.includes(rec.task) &&
468
+ (!harness || rec.harness === harness) &&
469
+ (!model || rec.model === model),
470
+ );
471
+ const canaryEvaluated = canaryRecords.map((rec) => evaluateRunRecord(rec, prices));
472
+ const currentCanary = canaryEvaluated.filter((r) => r.arm === 'current');
473
+
474
+ for (const sec of sections) {
475
+ const armCanary = canaryEvaluated.filter((r) => r.arm === sec.arm);
476
+ if (currentCanary.length > 0 && armCanary.length > 0) {
477
+ const cmp = compareArms(sec.arm, armCanary, 'current', currentCanary, { samples, seed });
478
+ if (shouldEscalateCanary(cmp)) {
479
+ escalatedArms.add(sec.arm);
480
+ }
481
+ }
482
+ }
483
+
484
+ if (escalatedArms.size > 0) {
485
+ await execRunBench(resolvedRoot, {
486
+ apply: true,
487
+ arms: ['current', ...escalatedArms],
488
+ harness,
489
+ model,
490
+ reps,
491
+ tasks: remainingTaskIds,
492
+ ...(vaultRoot ? { vaultRoot } : {}),
493
+ });
494
+ }
495
+ }
496
+ }
497
+
498
+ const rawRecords = await loadRunRecords(resolvedRoot);
499
+ const filtered = rawRecords.filter((rec) => {
500
+ if (harness && apply && rec.harness !== harness) return false;
501
+ if (model && rec.model !== model) return false;
502
+ return true;
503
+ });
504
+
505
+ const evaluated = filtered.map((rec) => evaluateRunRecord(rec, prices));
506
+ const byArm = new Map();
507
+ for (const row of evaluated) {
508
+ const list = byArm.get(row.arm) ?? [];
509
+ list.push(row);
510
+ byArm.set(row.arm, list);
511
+ }
512
+
513
+ const currentRuns = byArm.get('current') ?? [];
514
+
515
+ const sectionEntries = sections.map((sec) => {
516
+ const ablatedRuns = byArm.get(sec.arm) ?? [];
517
+ if (currentRuns.length === 0 || ablatedRuns.length === 0) {
518
+ return {
519
+ ...sec,
520
+ measured: false,
521
+ escalated: false,
522
+ tasks: 0,
523
+ runs: 0,
524
+ dR: 0,
525
+ dQ: 0,
526
+ dC: 0,
527
+ valuePerKb: 0,
528
+ ci95: {
529
+ dR: [0, 0],
530
+ dQ: [0, 0],
531
+ dC: [0, 0],
532
+ valuePerKb: [0, 0],
533
+ },
534
+ mcnemar: { statistic: 0, pValue: 1, b: 0, c: 0 },
535
+ wilcoxon: { statistic: 0, pValue: 1, wPlus: 0, wMinus: 0 },
536
+ deletionCandidate: false,
537
+ };
538
+ }
539
+
540
+ // Pair `sec.arm` (without section) as baseline and `current` (with section) as candidate
541
+ // so deltas represent the marginal contribution of keeping `sec`.
542
+ const cmp = compareArms(sec.arm, ablatedRuns, 'current', currentRuns, { samples, seed });
543
+ const dR = cmp.deltas.R;
544
+ const dQ = cmp.deltas.Q;
545
+ const dC = cmp.deltas.C;
546
+ const valuePerKb = sec.kb > 0 ? dR / sec.kb : 0;
547
+ const vCi =
548
+ sec.kb > 0 ? [cmp.ci95.R[0] / sec.kb, cmp.ci95.R[1] / sec.kb] : [0, 0];
549
+ const escalated =
550
+ escalatedArms.has(sec.arm) ||
551
+ cmp.pairedTasks > canarySize ||
552
+ shouldEscalateCanary(cmp);
553
+ const deletionCandidate = isDeletionCandidate({ measured: true, dR, dC });
554
+
555
+ return {
556
+ ...sec,
557
+ measured: true,
558
+ escalated,
559
+ tasks: cmp.pairedTasks,
560
+ runs: cmp.pairedRuns,
561
+ dR,
562
+ dQ,
563
+ dC,
564
+ valuePerKb,
565
+ ci95: {
566
+ dR: cmp.ci95.R,
567
+ dQ: cmp.ci95.Q,
568
+ dC: cmp.ci95.C,
569
+ valuePerKb: vCi,
570
+ },
571
+ mcnemar: {
572
+ statistic: cmp.mcnemar.statistic,
573
+ pValue: cmp.mcnemar.pValue,
574
+ b: cmp.mcnemar.b,
575
+ c: cmp.mcnemar.c,
576
+ },
577
+ wilcoxon: {
578
+ statistic: cmp.wilcoxon.statistic,
579
+ pValue: cmp.wilcoxon.pValue,
580
+ wPlus: cmp.wilcoxon.wPlus,
581
+ wMinus: cmp.wilcoxon.wMinus,
582
+ },
583
+ deletionCandidate,
584
+ };
585
+ });
586
+
587
+ const ranking = [...sectionEntries]
588
+ .sort((a, b) => {
589
+ if (Math.abs(b.valuePerKb - a.valuePerKb) > 1e-12) {
590
+ return b.valuePerKb - a.valuePerKb;
591
+ }
592
+ if (Math.abs(a.dC - b.dC) > 1e-12) {
593
+ return a.dC - b.dC;
594
+ }
595
+ return a.id.localeCompare(b.id);
596
+ })
597
+ .map((entry, idx) => ({
598
+ rank: idx + 1,
599
+ ...entry,
600
+ }));
601
+
602
+ const deletionCandidates = ranking
603
+ .filter((entry) => entry.deletionCandidate)
604
+ .map((entry) => entry.id);
605
+
606
+ const result = {
607
+ root: resolvedRoot,
608
+ applied: apply,
609
+ pricesLoaded: prices !== null,
610
+ canarySize,
611
+ canaryTasks: canaryTaskIds.length,
612
+ totalTasks: taskIds.length,
613
+ sections: sectionEntries,
614
+ ranking,
615
+ deletionCandidates,
616
+ };
617
+
618
+ const markdown = formatAblationMarkdown(result);
619
+
620
+ if (apply) {
621
+ const outPath = join(resolvedRoot, STATE_DIR, 'bench', 'ablation.json');
622
+ await mkdir(dirname(outPath), { recursive: true });
623
+ await writeFile(outPath, `${JSON.stringify({ schemaVersion: 1, ...result }, null, 2)}\n`, 'utf8');
624
+ }
625
+
626
+ return {
627
+ ...result,
628
+ markdown,
629
+ };
630
+ }