simt-dev 0.3.1 → 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.
- package/CHANGELOG.md +109 -0
- package/README.md +14 -4
- package/package.json +2 -2
- package/rules/core.md +1 -1
- package/src/agenthooks/policy.mjs +10 -2
- package/src/agenthooks/prompt.mjs +81 -0
- package/src/agenthooks/render.mjs +138 -17
- package/src/argv.mjs +16 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +85 -39
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +346 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +197 -0
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +40 -15
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/help.mjs +34 -3
- package/src/commands/index.mjs +17 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +42 -7
- package/src/commands/repoindex.mjs +4 -1
- package/src/commands/rules.mjs +17 -2
- package/src/commands/skills.mjs +3 -17
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +15 -0
- package/src/commands/tests.mjs +2 -49
- package/src/commands/why.mjs +18 -23
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- package/src/federation/client.mjs +188 -0
- package/src/federation/config.mjs +134 -0
- package/src/federation/query.mjs +48 -0
- package/src/graph/compile.mjs +475 -0
- package/src/index/gate.mjs +1 -13
- package/src/index/govern.mjs +23 -11
- package/src/index/map.mjs +251 -17
- package/src/index/persist.mjs +74 -7
- package/src/index/store.mjs +222 -8
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +36 -1
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +234 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/lock.mjs +39 -7
- package/src/projection/rules.mjs +32 -8
- package/src/toolwords.mjs +6 -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
|
|
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
|
|
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
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
+
}
|