simt-dev 0.3.0 → 0.4.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 (91) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +138 -74
  3. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
  4. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
  5. package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
  6. package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
  7. package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
  8. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
  9. package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
  10. package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
  11. package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
  12. package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
  13. package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
  14. package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
  15. package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
  16. package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
  17. package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
  18. package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
  19. package/package.json +2 -2
  20. package/rules/core.md +2 -2
  21. package/src/agenthooks/policy.mjs +10 -2
  22. package/src/agenthooks/prompt.mjs +81 -0
  23. package/src/agenthooks/render.mjs +138 -17
  24. package/src/argv.mjs +34 -1
  25. package/src/bench/grade.mjs +3 -0
  26. package/src/bench/run.mjs +85 -39
  27. package/src/bench/variants.mjs +456 -0
  28. package/src/brief/assemble.mjs +346 -0
  29. package/src/brief/kind.mjs +134 -0
  30. package/src/brief/packet.mjs +197 -0
  31. package/src/ci/workflow.mjs +11 -1
  32. package/src/commands/agenthook.mjs +84 -5
  33. package/src/commands/bench.mjs +53 -16
  34. package/src/commands/brief.mjs +78 -0
  35. package/src/commands/compare.mjs +10 -2
  36. package/src/commands/decide.mjs +116 -0
  37. package/src/commands/evidence.mjs +96 -0
  38. package/src/commands/gate.mjs +165 -0
  39. package/src/commands/help.mjs +65 -3
  40. package/src/commands/hooks.mjs +45 -5
  41. package/src/commands/impact.mjs +205 -0
  42. package/src/commands/index.mjs +52 -1
  43. package/src/commands/lessons.mjs +127 -0
  44. package/src/commands/local.mjs +97 -0
  45. package/src/commands/map.mjs +126 -0
  46. package/src/commands/repoindex.mjs +55 -0
  47. package/src/commands/rules.mjs +17 -2
  48. package/src/commands/shared.mjs +398 -0
  49. package/src/commands/skills.mjs +3 -17
  50. package/src/commands/skillscompile.mjs +255 -0
  51. package/src/commands/sync.mjs +87 -3
  52. package/src/commands/tests.mjs +145 -0
  53. package/src/commands/tokens.mjs +30 -1
  54. package/src/commands/why.mjs +172 -0
  55. package/src/compare/learn.mjs +680 -0
  56. package/src/decisions/index.mjs +116 -65
  57. package/src/federation/client.mjs +188 -0
  58. package/src/federation/config.mjs +134 -0
  59. package/src/federation/query.mjs +48 -0
  60. package/src/graph/compile.mjs +475 -0
  61. package/src/graph/spec.mjs +1 -1
  62. package/src/hooks/dispatcher.mjs +15 -0
  63. package/src/hooks/shim.mjs +31 -0
  64. package/src/index/compact.mjs +228 -0
  65. package/src/index/extract.mjs +331 -0
  66. package/src/index/gate.mjs +89 -0
  67. package/src/index/govern.mjs +234 -0
  68. package/src/index/impact.mjs +186 -0
  69. package/src/index/map.mjs +627 -0
  70. package/src/index/persist.mjs +239 -0
  71. package/src/index/store.mjs +515 -0
  72. package/src/index/tests.mjs +294 -0
  73. package/src/kernel/budget.mjs +100 -0
  74. package/src/kernel/checks.mjs +378 -0
  75. package/src/kernel/variants.mjs +353 -0
  76. package/src/ledger/index.mjs +147 -0
  77. package/src/local/client.mjs +482 -0
  78. package/src/local/config.mjs +234 -0
  79. package/src/mcp/tools.mjs +138 -1
  80. package/src/measure/context.mjs +7 -5
  81. package/src/memory/decide.mjs +462 -0
  82. package/src/memory/lessons.mjs +858 -0
  83. package/src/projection/disclosure.mjs +92 -0
  84. package/src/projection/lock.mjs +125 -0
  85. package/src/projection/manifest.mjs +52 -0
  86. package/src/projection/markers.mjs +56 -17
  87. package/src/projection/project.mjs +24 -17
  88. package/src/projection/rules.mjs +407 -49
  89. package/src/projection/shared.mjs +428 -0
  90. package/src/projection/targets.mjs +2 -1
  91. package/src/toolwords.mjs +8 -0
package/src/bench/run.mjs CHANGED
@@ -6,6 +6,9 @@ import { dirname, join, resolve, sep } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
 
8
8
  import { loadRegistry } from '../graph/load.mjs';
9
+ import { extractOpenedSkills, matchSkillsForPaths } from '../graph/compile.mjs';
10
+ import { matchGlob } from '../index/govern.mjs';
11
+ import { formatLessonParagraph, listPromotedLessons } from '../memory/lessons.mjs';
9
12
  import { CONFIG_DIR, MANIFEST_FILE } from '../name.mjs';
10
13
  import { readManifest } from '../projection/manifest.mjs';
11
14
  import { plan } from '../projection/project.mjs';
@@ -15,6 +18,7 @@ import { rankSkills, tokenize } from '../resolve/resolve.mjs';
15
18
  import { gradeWorkspace } from './grade.mjs';
16
19
  import { resolveVaultDir } from './mine.mjs';
17
20
  import { ablateSectionInWorkspace, parseAblationArmName } from './ablate.mjs';
21
+ import { applyVariantInWorkspace, parseVariantArmName } from './variants.mjs';
18
22
 
19
23
  const STATE_DIR = `.${CONFIG_DIR}`;
20
24
 
@@ -288,6 +292,15 @@ export async function parseArmSpec(rawSpec, cwd = process.cwd()) {
288
292
  env: {},
289
293
  };
290
294
  }
295
+ const variantSpec = parseVariantArmName(spec);
296
+ if (variantSpec) {
297
+ return {
298
+ ...variantSpec,
299
+ write: {},
300
+ remove: [],
301
+ env: {},
302
+ };
303
+ }
291
304
  if (!BUILTIN_ARMS.has(spec)) {
292
305
  throw new Error(
293
306
  `unknown arm "${spec}"; built-in arms are: ${[...BUILTIN_ARMS].join(', ')} (or pass --arm name=path/to/arm.json)`,
@@ -400,7 +413,7 @@ async function stripBareArm(workDir) {
400
413
  }
401
414
  }
402
415
 
403
- for (const rel of ['.githooks', '.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json']) {
416
+ for (const rel of ['.githooks', '.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json', 'docs/lessons']) {
404
417
  await rm(join(workDir, rel), { recursive: true, force: true }).catch(() => {});
405
418
  }
406
419
 
@@ -457,20 +470,32 @@ async function applyProjectionMode(workDir, mode) {
457
470
 
458
471
  async function writeTaskBrief(workDir, task) {
459
472
  const prompt = String(task?.prompt ?? '');
460
- const [registry, fp] = await Promise.all([
473
+ const taskTokens = tokenize(prompt);
474
+ const taskTokenSet = new Set(taskTokens);
475
+ const [registry, fp, lessons] = await Promise.all([
461
476
  loadRegistry().catch(() => ({ skills: [] })),
462
477
  fingerprint(workDir).catch(() => ({ stacks: [], facts: new Set() })),
478
+ listPromotedLessons(workDir).catch(() => []),
463
479
  ]);
464
480
  const skillsList = Array.isArray(registry.skills)
465
481
  ? registry.skills
466
482
  : Object.values(registry.skills ?? {});
467
483
  const { selected = [] } = rankSkills(skillsList, {
468
- taskTokens: tokenize(prompt),
484
+ taskTokens,
469
485
  paths: [],
470
486
  repoStack: fp.stack ?? [],
471
487
  });
472
488
  const ranked = selected.slice(0, 5);
473
489
 
490
+ const taskPaths = Array.isArray(task?.paths) ? task.paths : [];
491
+ const matchingLessons = lessons.filter((lesson) => {
492
+ const lessonGlobs = Array.isArray(lesson.paths) ? lesson.paths : [];
493
+ if (taskPaths.some((p) => lessonGlobs.some((glob) => matchGlob(glob, p)))) return true;
494
+ if (lessonGlobs.some((glob) => prompt.includes(glob.replace(/\/\*\*$/, '')))) return true;
495
+ const kw = Array.isArray(lesson.keywords) ? lesson.keywords : [];
496
+ return kw.some((k) => taskTokenSet.has(String(k).toLowerCase()));
497
+ });
498
+
474
499
  const lines = [
475
500
  `# Task Brief: ${task?.id ?? 'task'}`,
476
501
  '',
@@ -480,6 +505,13 @@ async function writeTaskBrief(workDir, task) {
480
505
  ...(ranked.length > 0
481
506
  ? ranked.map((r) => `- ${r.skill.name} (score ${r.score}): ${r.skill.description ?? ''}`)
482
507
  : ['- (none matched)']),
508
+ ...(matchingLessons.length > 0
509
+ ? [
510
+ '',
511
+ '## Lessons',
512
+ ...matchingLessons.map((l) => `- ${formatLessonParagraph(l)}`),
513
+ ]
514
+ : []),
483
515
  '',
484
516
  ];
485
517
  const briefPath = join(workDir, STATE_DIR, 'brief.md');
@@ -497,9 +529,12 @@ async function writeTaskBrief(workDir, task) {
497
529
  */
498
530
  export async function materialiseArm(workDir, armSpec, task = {}) {
499
531
  const ablate = armSpec.ablate ?? parseAblationArmName(armSpec.name)?.ablate ?? null;
532
+ const variant = armSpec.variant ?? parseVariantArmName(armSpec.name)?.variant ?? null;
500
533
  const base = armSpec.base ?? (BUILTIN_ARMS.has(armSpec.name) ? armSpec.name : null);
501
534
  if (ablate) {
502
535
  await ablateSectionInWorkspace(workDir, ablate);
536
+ } else if (variant) {
537
+ await applyVariantInWorkspace(workDir, variant);
503
538
  } else if (base === 'bare') {
504
539
  await stripBareArm(workDir);
505
540
  } else if (base === 'current') {
@@ -559,6 +594,31 @@ export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}
559
594
  throw new Error(`tar unpack failed for ${baseSha}: ${tarRes.stderr.toString('utf8').trim()}`);
560
595
  }
561
596
 
597
+ const rootLessonsDir = join(root, 'docs', 'lessons');
598
+ const rootLessonFiles = (await readdir(rootLessonsDir).catch(() => [])).filter((f) => f.endsWith('.md'));
599
+ if (rootLessonFiles.length > 0) {
600
+ const destLessonsDir = join(workDir, 'docs', 'lessons');
601
+ await mkdir(destLessonsDir, { recursive: true });
602
+ for (const file of rootLessonFiles) {
603
+ const target = join(destLessonsDir, file);
604
+ if (!existsSync(target)) {
605
+ const text = await readFile(join(rootLessonsDir, file), 'utf8');
606
+ await writeFile(target, text, 'utf8');
607
+ }
608
+ }
609
+ }
610
+
611
+ const rootProfilesDir = join(root, STATE_DIR, 'profiles');
612
+ const rootProfileFiles = (await readdir(rootProfilesDir).catch(() => [])).filter((f) => f.endsWith('.md'));
613
+ if (rootProfileFiles.length > 0) {
614
+ const destProfilesDir = join(workDir, STATE_DIR, 'profiles');
615
+ await mkdir(destProfilesDir, { recursive: true });
616
+ for (const file of rootProfileFiles) {
617
+ const text = await readFile(join(rootProfilesDir, file), 'utf8');
618
+ await writeFile(join(destProfilesDir, file), text, 'utf8');
619
+ }
620
+ }
621
+
562
622
  const { env } = await materialiseArm(workDir, armSpec, task);
563
623
 
564
624
  await runCmd('git', ['init', '-q'], { cwd: workDir });
@@ -598,49 +658,24 @@ function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog }) {
598
658
  }
599
659
 
600
660
  async function executeAdapter(adapter, workDir, task, { vaultRoot, model, maxTurns, env, timeoutMs, slotDir }) {
601
- if (adapter.id === 'noop') {
602
- return parseAdapterUsage(adapter, '');
603
- }
604
-
661
+ if (adapter.id === 'noop') return { ...parseAdapterUsage(adapter, ''), transcript: '' };
605
662
  if (adapter.id === 'gold') {
606
- const goldPatchPath = join(vaultRoot, task.id, 'gold.patch');
607
- const goldPatch = await readFile(goldPatchPath);
663
+ const goldPatch = await readFile(join(vaultRoot, task.id, 'gold.patch'));
608
664
  if (goldPatch.length > 0) {
609
- const applyRes = await runCmd('git', ['apply', '-'], {
610
- cwd: workDir,
611
- input: goldPatch,
612
- timeoutMs,
613
- });
665
+ const applyRes = await runCmd('git', ['apply', '-'], { cwd: workDir, input: goldPatch, timeoutMs });
614
666
  if (applyRes.code !== 0) {
615
- await runCmd('git', ['apply', '--3way', '-'], {
616
- cwd: workDir,
617
- input: goldPatch,
618
- timeoutMs,
619
- });
667
+ await runCmd('git', ['apply', '--3way', '-'], { cwd: workDir, input: goldPatch, timeoutMs });
620
668
  }
621
669
  }
622
- return parseAdapterUsage(adapter, '');
670
+ return { ...parseAdapterUsage(adapter, ''), transcript: '' };
623
671
  }
624
672
 
625
673
  const analyticsLog = join(slotDir, 'analytics.jsonl');
626
- const args = buildAdapterArgs(adapter, {
627
- prompt: String(task.prompt ?? ''),
628
- turns: maxTurns,
629
- model,
630
- analyticsLog,
631
- });
632
-
633
- const outcome = await runCmd(adapter.bin, args, {
634
- cwd: workDir,
635
- env,
636
- timeoutMs,
637
- });
638
- const analyticsText =
639
- adapter.format === 'analytics-log'
640
- ? await readFile(analyticsLog, 'utf8').catch(() => '')
641
- : '';
642
-
643
- return parseAdapterUsage(adapter, outcome.stdout.toString('utf8'), analyticsText);
674
+ const args = buildAdapterArgs(adapter, { prompt: String(task.prompt ?? ''), turns: maxTurns, model, analyticsLog });
675
+ const outcome = await runCmd(adapter.bin, args, { cwd: workDir, env, timeoutMs });
676
+ const stdoutText = outcome.stdout.toString('utf8');
677
+ const analyticsText = adapter.format === 'analytics-log' ? await readFile(analyticsLog, 'utf8').catch(() => '') : '';
678
+ return { ...parseAdapterUsage(adapter, stdoutText, analyticsText), transcript: `${stdoutText}\n${analyticsText}` };
644
679
  }
645
680
 
646
681
  async function loadTasks(root, filterIds = []) {
@@ -776,6 +811,9 @@ export async function runBench(
776
811
  }
777
812
  }
778
813
 
814
+ const regRes = await loadRegistry().catch(() => ({ registry: { skills: [] } }));
815
+ const registrySkills = regRes?.registry?.skills ?? [];
816
+ const knownSkillNames = new Set(registrySkills.map((s) => s.name));
779
817
  const parentDir = await mkdtemp(join(tmpdir(), 'bench-batch-'));
780
818
  try {
781
819
  const records = await mapPool(plannedRuns, concurrency, async (item) => {
@@ -787,7 +825,7 @@ export async function runBench(
787
825
  { parentDir },
788
826
  );
789
827
  try {
790
- const { usage, costUsd } = await executeAdapter(adapter, workDir, item.task, {
828
+ const { usage, costUsd, transcript } = await executeAdapter(adapter, workDir, item.task, {
791
829
  vaultRoot,
792
830
  model: resolvedModel,
793
831
  maxTurns,
@@ -801,6 +839,13 @@ export async function runBench(
801
839
  initialHead,
802
840
  timeoutMs,
803
841
  });
842
+ const expectedSkills = matchSkillsForPaths(registrySkills, grade.goldFiles ?? []);
843
+ const openedSkills =
844
+ adapter.id === 'gold'
845
+ ? expectedSkills
846
+ : adapter.id === 'noop'
847
+ ? []
848
+ : extractOpenedSkills(transcript, knownSkillNames);
804
849
 
805
850
  const record = {
806
851
  schemaVersion: 1,
@@ -814,6 +859,7 @@ export async function runBench(
814
859
  usage,
815
860
  costUsd,
816
861
  diff: grade.diff,
862
+ skills: { expected: expectedSkills, opened: openedSkills },
817
863
  probe: null,
818
864
  };
819
865
 
@@ -0,0 +1,456 @@
1
+ // Variant selection for rule text (`simt bench variants`, plan 0003 R35).
2
+ //
3
+ // Discovers `simt-variants` slots across applicable rules, runs candidate
4
+ // variants on the 10-task canary suite (escalating when significant), records
5
+ // the winning variant with `{winner, variant, model, harness, dR, dC, n, p}`
6
+ // in `simt-dev.lock.json`, and re-opens the experiment whenever the target
7
+ // model or harness changes.
8
+
9
+ import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises';
10
+ import { dirname, join, resolve } from 'node:path';
11
+
12
+ import { extractVariantsFromRule, readLockedVariants } from '../kernel/variants.mjs';
13
+ import { CONFIG_DIR, MANIFEST_FILE } from '../name.mjs';
14
+ import { writeLockVariants } from '../projection/lock.mjs';
15
+ import { readManifest } from '../projection/manifest.mjs';
16
+ import { loadMdcRules } from '../projection/mdc.mjs';
17
+ import { plan } from '../projection/project.mjs';
18
+ import { isEligible, loadRules, readDeclared, select } from '../projection/rules.mjs';
19
+ import { fingerprint } from '../resolve/fingerprint.mjs';
20
+ import { CANARY_ALPHA, DEFAULT_CANARY_SIZE, DEL_R_TOLERANCE, shouldEscalateCanary } from './ablate.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
+ /**
33
+ * Discover every `simt-variants` slot across applicable rules for `root`.
34
+ */
35
+ export async function listVariantSlots(root, { includeAll = false } = {}) {
36
+ const resolvedRoot = resolve(root);
37
+ const [{ stack, facts }, allRules, mdcRules, declared] = await Promise.all([
38
+ fingerprint(resolvedRoot),
39
+ loadRules(resolvedRoot),
40
+ loadMdcRules(),
41
+ readDeclared(resolvedRoot),
42
+ ]);
43
+
44
+ const signals = new Set([...(stack ?? []), ...(facts ?? [])]);
45
+ const { selected } = select(allRules, stack ?? [], facts ?? []);
46
+ const rulePool = includeAll ? allRules : selected;
47
+ const mdcPool = includeAll
48
+ ? mdcRules.filter((r) => r.id && r.error === null)
49
+ : mdcRules.filter((r) => r.id && r.error === null && isEligible(r, signals, declared.optIn));
50
+
51
+ const slots = [];
52
+ for (const rule of [...rulePool, ...mdcPool]) {
53
+ if (!rule || rule.error !== null) continue;
54
+ slots.push(...extractVariantsFromRule(rule));
55
+ }
56
+ return slots.sort((a, b) => a.slotId.localeCompare(b.slotId));
57
+ }
58
+
59
+ /**
60
+ * Parse a variant benchmark arm name (`variant-<slotId>-<variantKey>`) or return null.
61
+ */
62
+ export function parseVariantArmName(armName) {
63
+ const s = String(armName ?? '').trim();
64
+ if (!s.startsWith('variant-')) return null;
65
+ const rest = s.slice('variant-'.length);
66
+ const dash = rest.lastIndexOf('-');
67
+ if (dash <= 0 || dash === rest.length - 1) return null;
68
+ const slotId = rest.slice(0, dash);
69
+ const variantKey = rest.slice(dash + 1);
70
+ return {
71
+ name: s,
72
+ kind: 'variant',
73
+ base: 'current',
74
+ variant: { slotId, variantKey },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Format the arm name for a `(slotId, variantKey)` pair.
80
+ */
81
+ export function variantArmName(slotId, variantKey) {
82
+ return `variant-${slotId}-${variantKey}`;
83
+ }
84
+
85
+ /**
86
+ * Determine whether a locked variant entry is still valid or re-opened by a model/harness change.
87
+ */
88
+ export function isVariantExperimentOpen(
89
+ lockedEntry,
90
+ { harness, model, variants, force = false } = {},
91
+ ) {
92
+ if (force) return { open: true, reopened: Boolean(lockedEntry), reason: 'forced' };
93
+ if (!lockedEntry || typeof lockedEntry !== 'object') {
94
+ return { open: true, reopened: false, reason: 'unlocked' };
95
+ }
96
+ const winner = lockedEntry.winner ?? lockedEntry.variant;
97
+ if (typeof winner !== 'string' || winner === '') {
98
+ return { open: true, reopened: true, reason: 'missing-winner' };
99
+ }
100
+ if (variants && typeof variants === 'object' && !Object.hasOwn(variants, winner)) {
101
+ return { open: true, reopened: true, reason: 'unknown-variant' };
102
+ }
103
+ if (harness && lockedEntry.harness && lockedEntry.harness !== harness) {
104
+ return { open: true, reopened: true, reason: 'harness-changed' };
105
+ }
106
+ if (model && lockedEntry.model && lockedEntry.model !== model) {
107
+ return { open: true, reopened: true, reason: 'model-changed' };
108
+ }
109
+ return { open: false, reopened: false, reason: 'locked' };
110
+ }
111
+
112
+ /**
113
+ * Materialise a variant arm inside an isolated benchmark workspace `workDir`.
114
+ */
115
+ export async function applyVariantInWorkspace(workDir, variant) {
116
+ if (!variant || typeof variant !== 'object') return;
117
+ const { slotId, variantKey } = variant;
118
+ if (!slotId || !variantKey) return;
119
+
120
+ const manifestRes = await readManifest(workDir);
121
+ const manifestPath = manifestRes.path ?? join(workDir, MANIFEST_FILE);
122
+ const hadManifest = manifestRes.exists;
123
+ const originalManifestBytes = hadManifest
124
+ ? await readFile(manifestPath, 'utf8').catch(() => null)
125
+ : null;
126
+
127
+ const baseManifest = manifestRes.data ?? {
128
+ schemaVersion: 1,
129
+ projection: { sha: '0000000000000000000000000000000000000000', sources: [] },
130
+ gates: { preCommit: [], prePush: [] },
131
+ };
132
+ const nextManifest = { ...baseManifest };
133
+ delete nextManifest.mode;
134
+ await writeFile(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
135
+
136
+ const planned = await plan(workDir, {
137
+ variants: { [slotId]: { winner: variantKey } },
138
+ });
139
+ for (const t of planned.targets ?? []) {
140
+ if (t.action !== 'unchanged' && typeof t.text === 'string') {
141
+ await mkdir(dirname(t.path), { recursive: true });
142
+ await writeFile(t.path, t.text, 'utf8');
143
+ }
144
+ }
145
+
146
+ if (!hadManifest) {
147
+ await unlink(manifestPath).catch(() => {});
148
+ } else if (originalManifestBytes !== null) {
149
+ await writeFile(manifestPath, originalManifestBytes, 'utf8');
150
+ }
151
+ }
152
+
153
+ function summarizeEvaluated(evaluated) {
154
+ if (evaluated.length === 0) {
155
+ return { n: 0, R: 0, C: 0 };
156
+ }
157
+ const byTask = new Map();
158
+ for (const row of evaluated) {
159
+ const list = byTask.get(row.task) ?? [];
160
+ list.push(row);
161
+ byTask.set(row.task, list);
162
+ }
163
+ let sumR = 0;
164
+ let sumC = 0;
165
+ for (const list of byTask.values()) {
166
+ sumR += list.reduce((a, x) => a + x.R, 0) / list.length;
167
+ sumC += list.reduce((a, x) => a + x.costUsd, 0) / list.length;
168
+ }
169
+ const n = byTask.size;
170
+ return { n, R: sumR / n, C: sumC / n };
171
+ }
172
+
173
+ /**
174
+ * Run or evaluate variant selection on the canary suite and record winners in `simt-dev.lock.json`.
175
+ */
176
+ export async function selectRuleVariants(
177
+ root,
178
+ {
179
+ apply = false,
180
+ force = false,
181
+ harness = 'gold',
182
+ model,
183
+ reps = 1,
184
+ canarySize = DEFAULT_CANARY_SIZE,
185
+ includeAll = false,
186
+ slots: customSlots,
187
+ prices: customPrices,
188
+ samples = DEFAULT_BOOTSTRAP_SAMPLES,
189
+ seed = DEFAULT_BOOTSTRAP_SEED,
190
+ vaultRoot,
191
+ runner,
192
+ } = {},
193
+ ) {
194
+ const resolvedRoot = resolve(root);
195
+ const resolvedModel = model ?? harness;
196
+ const [slots, existingLocks, prices] = await Promise.all([
197
+ customSlots ? Promise.resolve(customSlots) : listVariantSlots(resolvedRoot, { includeAll }),
198
+ readLockedVariants(resolvedRoot),
199
+ customPrices !== undefined ? Promise.resolve(customPrices) : loadPrices(resolvedRoot),
200
+ ]);
201
+
202
+ const tasksDir = join(resolvedRoot, STATE_DIR, 'bench', 'tasks');
203
+ let taskIds = [];
204
+ try {
205
+ const files = (await readdir(tasksDir)).filter((f) => f.endsWith('.json')).sort();
206
+ for (const file of files) {
207
+ const data = JSON.parse(await readFile(join(tasksDir, file), 'utf8'));
208
+ if (data?.id) taskIds.push(data.id);
209
+ }
210
+ taskIds.sort();
211
+ } catch {
212
+ taskIds = [];
213
+ }
214
+
215
+ const canaryTaskIds = taskIds.slice(0, Math.max(1, canarySize));
216
+ const remainingTaskIds = taskIds.slice(canaryTaskIds.length);
217
+
218
+ const slotStates = slots.map((slot) => {
219
+ const lockedEntry = existingLocks[slot.slotId] ?? null;
220
+ const check = isVariantExperimentOpen(lockedEntry, {
221
+ harness,
222
+ model: resolvedModel,
223
+ variants: slot.variants,
224
+ force,
225
+ });
226
+ return {
227
+ slot,
228
+ lockedEntry,
229
+ ...check,
230
+ };
231
+ });
232
+
233
+ const openStates = slotStates.filter((s) => s.open);
234
+ const escalatedArms = new Set();
235
+
236
+ if (apply && openStates.length > 0) {
237
+ if (taskIds.length === 0) {
238
+ throw new Error(`no mined benchmark tasks found in ${tasksDir}; run bench mine --yes first`);
239
+ }
240
+ const execRunBench = runner ?? (await import('./run.mjs')).runBench;
241
+ const canaryArms = [];
242
+ for (const { slot } of openStates) {
243
+ for (const key of Object.keys(slot.variants)) {
244
+ canaryArms.push(variantArmName(slot.slotId, key));
245
+ }
246
+ }
247
+
248
+ await execRunBench(resolvedRoot, {
249
+ apply: true,
250
+ arms: canaryArms,
251
+ harness,
252
+ model: resolvedModel,
253
+ reps,
254
+ tasks: canaryTaskIds,
255
+ ...(vaultRoot ? { vaultRoot } : {}),
256
+ });
257
+
258
+ if (remainingTaskIds.length > 0) {
259
+ const canaryEvaluated = (await loadRunRecords(resolvedRoot))
260
+ .filter(
261
+ (rec) =>
262
+ canaryTaskIds.includes(rec.task) &&
263
+ (!harness || rec.harness === harness) &&
264
+ (!model || rec.model === resolvedModel),
265
+ )
266
+ .map((rec) => evaluateRunRecord(rec, prices));
267
+ const armsToEscalate = [];
268
+ const includeBaseline = new Set();
269
+ for (const { slot } of openStates) {
270
+ const baseArm = variantArmName(slot.slotId, slot.baseline);
271
+ const baseRecs = canaryEvaluated.filter((r) => r.arm === baseArm);
272
+ for (const key of Object.keys(slot.variants)) {
273
+ if (key === slot.baseline) continue;
274
+ const candArm = variantArmName(slot.slotId, key);
275
+ const candRecs = canaryEvaluated.filter((r) => r.arm === candArm);
276
+ if (baseRecs.length > 0 && candRecs.length > 0) {
277
+ const comp = compareArms(baseArm, baseRecs, candArm, candRecs, { samples, seed });
278
+ if (shouldEscalateCanary(comp, { alpha: CANARY_ALPHA })) {
279
+ includeBaseline.add(baseArm);
280
+ armsToEscalate.push(candArm);
281
+ escalatedArms.add(candArm);
282
+ }
283
+ }
284
+ }
285
+ }
286
+ const fullEscalationArms = [...includeBaseline, ...armsToEscalate];
287
+ if (fullEscalationArms.length > 0) {
288
+ await execRunBench(resolvedRoot, {
289
+ apply: true,
290
+ arms: fullEscalationArms,
291
+ harness,
292
+ model: resolvedModel,
293
+ reps,
294
+ tasks: remainingTaskIds,
295
+ ...(vaultRoot ? { vaultRoot } : {}),
296
+ });
297
+ }
298
+ }
299
+ }
300
+
301
+ const allEvaluated = (await loadRunRecords(resolvedRoot))
302
+ .filter(
303
+ (rec) =>
304
+ (!harness || rec.harness === harness) &&
305
+ (!model || !rec.model || rec.model === resolvedModel),
306
+ )
307
+ .map((rec) => evaluateRunRecord(rec, prices));
308
+
309
+ const results = [];
310
+ const nextVariantsLock = { ...existingLocks };
311
+
312
+ for (const state of slotStates) {
313
+ const { slot, lockedEntry, open, reopened, reason } = state;
314
+ if (!open && lockedEntry) {
315
+ const winnerKey = lockedEntry.winner ?? lockedEntry.variant ?? slot.baseline;
316
+ results.push({
317
+ slotId: slot.slotId,
318
+ ruleId: slot.ruleId,
319
+ baseline: slot.baseline,
320
+ variants: slot.variants,
321
+ status: 'locked',
322
+ reopened: false,
323
+ reason,
324
+ measured: true,
325
+ winner: winnerKey,
326
+ model: lockedEntry.model ?? resolvedModel,
327
+ harness: lockedEntry.harness ?? harness,
328
+ dR: Number(lockedEntry.dR ?? 0),
329
+ dC: Number(lockedEntry.dC ?? 0),
330
+ n: Number(lockedEntry.n ?? 0),
331
+ p: Number(lockedEntry.p ?? 1),
332
+ });
333
+ continue;
334
+ }
335
+
336
+ const baseArm = variantArmName(slot.slotId, slot.baseline);
337
+ const baseRecs = allEvaluated.filter((r) => r.arm === baseArm);
338
+ const baseSummary = summarizeEvaluated(baseRecs);
339
+
340
+ const candidates = [];
341
+ for (const [key, text] of Object.entries(slot.variants)) {
342
+ const arm = variantArmName(slot.slotId, key);
343
+ const recs = allEvaluated.filter((r) => r.arm === arm);
344
+ const summary = summarizeEvaluated(recs);
345
+ const comp =
346
+ key === slot.baseline || baseRecs.length === 0 || recs.length === 0
347
+ ? null
348
+ : compareArms(baseArm, baseRecs, arm, recs, { samples, seed });
349
+ candidates.push({
350
+ key,
351
+ arm,
352
+ bytes: Buffer.byteLength(text, 'utf8'),
353
+ records: recs,
354
+ summary,
355
+ comparison: comp,
356
+ escalated: escalatedArms.has(arm),
357
+ });
358
+ }
359
+
360
+ const measured = baseSummary.n > 0 && candidates.every((c) => c.summary.n > 0);
361
+ if (!measured) {
362
+ results.push({
363
+ slotId: slot.slotId,
364
+ ruleId: slot.ruleId,
365
+ baseline: slot.baseline,
366
+ variants: slot.variants,
367
+ status: reopened ? 'reopened' : 'unmeasured',
368
+ reopened,
369
+ reason,
370
+ measured: false,
371
+ winner: slot.baseline,
372
+ model: resolvedModel,
373
+ harness,
374
+ dR: 0,
375
+ dC: 0,
376
+ n: 0,
377
+ p: 1,
378
+ });
379
+ continue;
380
+ }
381
+
382
+ // Rank variants: higher R (beyond tolerance), then lower C, then fewer bytes, then key.
383
+ const ranked = [...candidates].sort((a, b) => {
384
+ if (Math.abs(b.summary.R - a.summary.R) > DEL_R_TOLERANCE) {
385
+ return b.summary.R - a.summary.R;
386
+ }
387
+ if (Math.abs(a.summary.C - b.summary.C) > 1e-9) {
388
+ return a.summary.C - b.summary.C;
389
+ }
390
+ if (a.bytes !== b.bytes) return a.bytes - b.bytes;
391
+ return a.key.localeCompare(b.key);
392
+ });
393
+
394
+ const best = ranked[0];
395
+ let dR = 0;
396
+ let dC = 0;
397
+ let n = baseSummary.n;
398
+ let p = 1;
399
+
400
+ if (best.key !== slot.baseline && best.comparison) {
401
+ dR = Number(best.comparison.deltas.R.toFixed(4));
402
+ dC = Number(best.comparison.deltas.C.toFixed(4));
403
+ n = best.comparison.pairedTasks;
404
+ p = Number(
405
+ Math.min(best.comparison.mcnemar.pValue, best.comparison.wilcoxon.pValue).toFixed(4),
406
+ );
407
+ } else {
408
+ const runnerUp = ranked.find((c) => c.key !== slot.baseline && c.comparison);
409
+ if (runnerUp?.comparison) {
410
+ n = runnerUp.comparison.pairedTasks;
411
+ }
412
+ }
413
+
414
+ const lockEntry = {
415
+ winner: best.key,
416
+ variant: best.key,
417
+ model: resolvedModel,
418
+ harness,
419
+ dR,
420
+ dC,
421
+ n,
422
+ p,
423
+ };
424
+ nextVariantsLock[slot.slotId] = lockEntry;
425
+
426
+ results.push({
427
+ slotId: slot.slotId,
428
+ ruleId: slot.ruleId,
429
+ baseline: slot.baseline,
430
+ variants: slot.variants,
431
+ status: reopened ? 'reopened' : 'evaluated',
432
+ reopened,
433
+ reason,
434
+ measured: true,
435
+ ...lockEntry,
436
+ });
437
+ }
438
+
439
+ let lock = null;
440
+ if (apply && Object.keys(nextVariantsLock).length > 0) {
441
+ lock = await writeLockVariants(resolvedRoot, nextVariantsLock);
442
+ }
443
+
444
+ return {
445
+ root: resolvedRoot,
446
+ applied: Boolean(apply),
447
+ harness,
448
+ model: resolvedModel,
449
+ canarySize,
450
+ canaryTasks: canaryTaskIds.length,
451
+ totalTasks: taskIds.length,
452
+ slots: results,
453
+ variants: nextVariantsLock,
454
+ lock,
455
+ };
456
+ }