simt-dev 0.3.1 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +271 -62
  3. package/package.json +6 -2
  4. package/rules/core.md +1 -1
  5. package/src/advice/advice.mjs +229 -0
  6. package/src/agenthooks/policy.mjs +10 -2
  7. package/src/agenthooks/prompt.mjs +81 -0
  8. package/src/agenthooks/render.mjs +138 -17
  9. package/src/argv.mjs +28 -1
  10. package/src/bench/grade.mjs +3 -0
  11. package/src/bench/run.mjs +88 -54
  12. package/src/bench/tasks.mjs +38 -0
  13. package/src/bench/variants.mjs +456 -0
  14. package/src/brief/assemble.mjs +368 -0
  15. package/src/brief/kind.mjs +134 -0
  16. package/src/brief/packet.mjs +200 -0
  17. package/src/commands/agenthook.mjs +84 -5
  18. package/src/commands/bench.mjs +72 -15
  19. package/src/commands/brief.mjs +78 -0
  20. package/src/commands/compare.mjs +10 -2
  21. package/src/commands/decide.mjs +116 -0
  22. package/src/commands/distill.mjs +36 -0
  23. package/src/commands/help.mjs +62 -3
  24. package/src/commands/index.mjs +29 -1
  25. package/src/commands/lessons.mjs +127 -0
  26. package/src/commands/local.mjs +97 -0
  27. package/src/commands/map.mjs +42 -7
  28. package/src/commands/repoindex.mjs +4 -1
  29. package/src/commands/review.mjs +65 -0
  30. package/src/commands/rules.mjs +17 -2
  31. package/src/commands/skills.mjs +5 -50
  32. package/src/commands/skillscompile.mjs +255 -0
  33. package/src/commands/sync.mjs +15 -0
  34. package/src/commands/tests.mjs +2 -49
  35. package/src/commands/why.mjs +23 -25
  36. package/src/compare/learn.mjs +680 -0
  37. package/src/decisions/index.mjs +116 -65
  38. package/src/distill/distill.mjs +246 -0
  39. package/src/distill/engine.mjs +187 -0
  40. package/src/federation/client.mjs +188 -0
  41. package/src/federation/config.mjs +134 -0
  42. package/src/federation/query.mjs +48 -0
  43. package/src/graph/compile.mjs +475 -0
  44. package/src/index/gate.mjs +1 -13
  45. package/src/index/govern.mjs +23 -11
  46. package/src/index/map.mjs +251 -17
  47. package/src/index/persist.mjs +74 -7
  48. package/src/index/store.mjs +222 -8
  49. package/src/kernel/variants.mjs +353 -0
  50. package/src/ledger/index.mjs +36 -1
  51. package/src/local/client.mjs +482 -0
  52. package/src/local/config.mjs +269 -0
  53. package/src/mcp/tools.mjs +138 -1
  54. package/src/memory/decide.mjs +462 -0
  55. package/src/memory/lessons.mjs +858 -0
  56. package/src/projection/lock.mjs +39 -7
  57. package/src/projection/rules.mjs +32 -8
  58. package/src/resolve/body.mjs +46 -0
  59. package/src/review/critic.mjs +509 -0
  60. package/src/toolwords.mjs +6 -0
@@ -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
+ }