simt-dev 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +158 -872
- package/README.md +19 -133
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/package.json +10 -2
- package/rules/core.md +7 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/shim.mjs +1 -1
- package/src/api.mjs +29 -0
- package/src/argv.mjs +29 -3
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +199 -35
- package/src/bench/probes.mjs +99 -62
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +17 -4
- package/src/bench/run.mjs +242 -142
- package/src/brief/assemble.mjs +38 -3
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +3 -4
- package/src/commands/agenthook.mjs +4 -5
- package/src/commands/bench.mjs +190 -17
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +42 -42
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +4 -4
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +6 -9
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +16 -12
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +125 -17
- package/src/commands/hooks.mjs +100 -30
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +4 -7
- package/src/commands/index.mjs +273 -188
- package/src/commands/init.mjs +2 -1
- package/src/commands/lease.mjs +6 -7
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +14 -9
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +2 -2
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +4 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +33 -16
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +28 -26
- package/src/commands/skillscompile.mjs +4 -5
- package/src/commands/skillsinstall.mjs +2 -2
- package/src/commands/slice.mjs +21 -10
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +13 -11
- package/src/commands/tests.mjs +6 -9
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +7 -3
- package/src/commands/why.mjs +9 -8
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/graph/load.mjs +4 -1
- package/src/hooks/capability.mjs +3 -3
- package/src/hooks/dispatcher.mjs +198 -43
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +2 -7
- package/src/hooks/shim.mjs +5 -2
- package/src/identity.mjs +29 -1
- package/src/journal.mjs +78 -9
- package/src/ledger/index.mjs +30 -1
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/server.mjs +70 -16
- package/src/mcp/tools.mjs +45 -7
- package/src/measure/session.mjs +3 -2
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +20 -34
- package/src/projection/rules.mjs +4 -3
- package/src/repo-root.mjs +19 -0
- package/src/resolve/fingerprint.mjs +19 -15
- package/src/review/critic.mjs +2 -1
- package/src/toolwords.mjs +3 -0
- package/src/workspace.mjs +55 -10
- package/docs/decisions/README.md +0 -600
package/src/bench/probes.mjs
CHANGED
|
@@ -17,9 +17,8 @@
|
|
|
17
17
|
// Each probe has a deterministic grader that reads:
|
|
18
18
|
// 1. the final git state in the isolated workspace (and local bare remote where applicable),
|
|
19
19
|
// 2. the commands recorded in the transcript, and
|
|
20
|
-
// 3. the evidence ledger (`.simt
|
|
20
|
+
// 3. the evidence ledger (`.simt/local/ledger.jsonl`, R17).
|
|
21
21
|
|
|
22
|
-
import { spawn } from 'node:child_process';
|
|
23
22
|
import { existsSync } from 'node:fs';
|
|
24
23
|
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
25
24
|
import { tmpdir } from 'node:os';
|
|
@@ -27,7 +26,21 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
27
26
|
|
|
28
27
|
import { LOCAL_DIR } from '../state.mjs';
|
|
29
28
|
import { parse as parseRegion } from '../projection/markers.mjs';
|
|
30
|
-
import {
|
|
29
|
+
import { spawnBounded } from './process.mjs';
|
|
30
|
+
import { adapterIsolation, executeAdapter, loadAdapters, materialiseArm, parseArmSpec } from './run.mjs';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Harnesses that script a workspace instead of driving an agent.
|
|
34
|
+
*
|
|
35
|
+
* They answer a different question — "does the grader accept compliant work and reject
|
|
36
|
+
* violating work" — and they answer it perfectly every time by construction, so their
|
|
37
|
+
* compliance rate is constant and says nothing about an arm. A record from one of these
|
|
38
|
+
* is a grader calibration, never a measurement (review 0003 Q49).
|
|
39
|
+
*/
|
|
40
|
+
export const CALIBRATION_HARNESSES = new Set(['gold', 'noop', 'violator']);
|
|
41
|
+
|
|
42
|
+
/** How long one agent-backed probe may run before its workspace is reclaimed. */
|
|
43
|
+
const PROBE_AGENT_TIMEOUT_MS = 120_000;
|
|
31
44
|
|
|
32
45
|
function sanitizedEnv(extra = {}) {
|
|
33
46
|
const env = { ...process.env, SIMT_DEV_WORKSPACE: 'off', ...extra };
|
|
@@ -37,56 +50,37 @@ function sanitizedEnv(extra = {}) {
|
|
|
37
50
|
return env;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const err = [];
|
|
49
|
-
child.stdout.on('data', (c) => out.push(c));
|
|
50
|
-
child.stderr.on('data', (c) => err.push(c));
|
|
51
|
-
child.on('error', (error) =>
|
|
52
|
-
resolveOutcome({ code: 1, stdout: '', stderr: String(error.message ?? error) }),
|
|
53
|
-
);
|
|
54
|
-
child.on('close', (code) =>
|
|
55
|
-
resolveOutcome({
|
|
56
|
-
code: code ?? 1,
|
|
57
|
-
stdout: Buffer.concat(out).toString('utf8'),
|
|
58
|
-
stderr: Buffer.concat(err).toString('utf8'),
|
|
59
|
-
}),
|
|
60
|
-
);
|
|
53
|
+
/** The deadline for one workspace-side git or node command inside a probe. */
|
|
54
|
+
const PROBE_COMMAND_TIMEOUT_MS = 120_000;
|
|
55
|
+
|
|
56
|
+
async function runGit(args, { cwd, env = {} } = {}) {
|
|
57
|
+
const outcome = await spawnBounded('git', args, {
|
|
58
|
+
cwd,
|
|
59
|
+
env: sanitizedEnv(env),
|
|
60
|
+
timeoutMs: PROBE_COMMAND_TIMEOUT_MS,
|
|
61
61
|
});
|
|
62
|
+
return {
|
|
63
|
+
code: outcome.code,
|
|
64
|
+
stdout: outcome.stdout.toString('utf8'),
|
|
65
|
+
stderr: outcome.stderr.toString('utf8'),
|
|
66
|
+
};
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
function runNode(args, { cwd, env = {} } = {}) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
70
|
-
});
|
|
71
|
-
const out = [];
|
|
72
|
-
const err = [];
|
|
73
|
-
child.stdout.on('data', (c) => out.push(c));
|
|
74
|
-
child.stderr.on('data', (c) => err.push(c));
|
|
75
|
-
child.on('error', (error) =>
|
|
76
|
-
resolveOutcome({ code: 1, stdout: '', stderr: String(error.message ?? error) }),
|
|
77
|
-
);
|
|
78
|
-
child.on('close', (code) =>
|
|
79
|
-
resolveOutcome({
|
|
80
|
-
code: code ?? 1,
|
|
81
|
-
stdout: Buffer.concat(out).toString('utf8'),
|
|
82
|
-
stderr: Buffer.concat(err).toString('utf8'),
|
|
83
|
-
}),
|
|
84
|
-
);
|
|
69
|
+
async function runNode(args, { cwd, env = {} } = {}) {
|
|
70
|
+
const outcome = await spawnBounded(process.execPath, args, {
|
|
71
|
+
cwd,
|
|
72
|
+
env: sanitizedEnv(env),
|
|
73
|
+
timeoutMs: PROBE_COMMAND_TIMEOUT_MS,
|
|
85
74
|
});
|
|
75
|
+
return {
|
|
76
|
+
code: outcome.code,
|
|
77
|
+
stdout: outcome.stdout.toString('utf8'),
|
|
78
|
+
stderr: outcome.stderr.toString('utf8'),
|
|
79
|
+
};
|
|
86
80
|
}
|
|
87
81
|
|
|
88
82
|
/**
|
|
89
|
-
* Read `.simt
|
|
83
|
+
* Read `.simt/local/ledger.jsonl` from `workDir` if present.
|
|
90
84
|
*
|
|
91
85
|
* @param {string} workDir
|
|
92
86
|
* @returns {Promise<Array<object>>}
|
|
@@ -112,7 +106,7 @@ export async function readLedgerEntries(workDir) {
|
|
|
112
106
|
}
|
|
113
107
|
|
|
114
108
|
/**
|
|
115
|
-
* Append a ledger entry to `.simt
|
|
109
|
+
* Append a ledger entry to `.simt/local/ledger.jsonl` in `workDir`.
|
|
116
110
|
*/
|
|
117
111
|
export async function appendLedgerEntry(workDir, entry) {
|
|
118
112
|
const ledgerPath = join(workDir, LOCAL_DIR, 'ledger.jsonl');
|
|
@@ -609,7 +603,7 @@ export const PROBES = [
|
|
|
609
603
|
return {
|
|
610
604
|
probeId: this.id,
|
|
611
605
|
passed: false,
|
|
612
|
-
reason: 'no test command was executed in transcript or recorded in .simt
|
|
606
|
+
reason: 'no test command was executed in transcript or recorded in .simt/local/ledger.jsonl',
|
|
613
607
|
};
|
|
614
608
|
}
|
|
615
609
|
const verify = await runNode(['--test', 'test/calc.test.mjs'], { cwd: workDir });
|
|
@@ -1092,13 +1086,16 @@ export async function prepareProbeWorkspace(probe, { armSpec = null, parentDir }
|
|
|
1092
1086
|
|
|
1093
1087
|
await probe.setup(workDir, { slotDir });
|
|
1094
1088
|
|
|
1089
|
+
await runGit(['init', '-q'], { cwd: workDir });
|
|
1090
|
+
await runGit(['config', 'user.email', 'bench@example.invalid'], { cwd: workDir });
|
|
1091
|
+
await runGit(['config', 'user.name', 'bench-probe'], { cwd: workDir });
|
|
1092
|
+
|
|
1095
1093
|
if (armSpec && probe.id !== 'p05-generated-region-edit') {
|
|
1096
1094
|
await materialiseArm(workDir, armSpec, { id: probe.id, prompt: probe.prompt });
|
|
1095
|
+
// Same rule as a mined run: the packet's index is tool state, not part of the arm.
|
|
1096
|
+
await rm(join(workDir, LOCAL_DIR), { recursive: true, force: true }).catch(() => {});
|
|
1097
1097
|
}
|
|
1098
1098
|
|
|
1099
|
-
await runGit(['init', '-q'], { cwd: workDir });
|
|
1100
|
-
await runGit(['config', 'user.email', 'bench@example.invalid'], { cwd: workDir });
|
|
1101
|
-
await runGit(['config', 'user.name', 'bench-probe'], { cwd: workDir });
|
|
1102
1099
|
await runGit(['add', '-A'], { cwd: workDir });
|
|
1103
1100
|
await runGit(['commit', '-qm', `base: ${probe.id}`, '--no-verify'], { cwd: workDir });
|
|
1104
1101
|
const headRes = await runGit(['rev-parse', 'HEAD'], { cwd: workDir });
|
|
@@ -1143,7 +1140,7 @@ export async function gradeProbe(probeOrId, ctx) {
|
|
|
1143
1140
|
}
|
|
1144
1141
|
|
|
1145
1142
|
/**
|
|
1146
|
-
* Load stored probe result records from `.simt
|
|
1143
|
+
* Load stored probe result records from `.simt/local/bench/probes/<runDir>/<probeId>.json`.
|
|
1147
1144
|
*
|
|
1148
1145
|
* @param {string} root
|
|
1149
1146
|
* @returns {Promise<Array<object>>}
|
|
@@ -1180,13 +1177,16 @@ export async function loadProbeRecords(root) { const probesRoot = join(resolv
|
|
|
1180
1177
|
*/
|
|
1181
1178
|
export function formatProbesMarkdown(report) {
|
|
1182
1179
|
const lines = [
|
|
1183
|
-
'| Arm | Probes | Passed | Compliance |',
|
|
1184
|
-
'| --- | ---: | ---: | ---: |',
|
|
1180
|
+
'| Arm | Probes | Passed | Compliance | Basis |',
|
|
1181
|
+
'| --- | ---: | ---: | ---: | --- |',
|
|
1185
1182
|
];
|
|
1186
1183
|
|
|
1184
|
+
// The basis is a column rather than a footnote because a calibration rate and a
|
|
1185
|
+
// measured rate are the same four characters apart (review 0003 Q49).
|
|
1187
1186
|
for (const arm of report.arms) {
|
|
1187
|
+
const basis = arm.measured ? 'agent' : 'grader calibration';
|
|
1188
1188
|
lines.push(
|
|
1189
|
-
`| ${arm.arm} | ${arm.total} | ${arm.passed} | ${(arm.complianceRate * 100).toFixed(1)}% |`,
|
|
1189
|
+
`| ${arm.arm} | ${arm.total} | ${arm.passed} | ${(arm.complianceRate * 100).toFixed(1)}% | ${basis} |`,
|
|
1190
1190
|
);
|
|
1191
1191
|
}
|
|
1192
1192
|
|
|
@@ -1220,9 +1220,23 @@ export async function runBenchProbes(
|
|
|
1220
1220
|
probes: probeFilter = [],
|
|
1221
1221
|
harness = 'gold',
|
|
1222
1222
|
reps = 1,
|
|
1223
|
+
model,
|
|
1224
|
+
timeoutMs = PROBE_AGENT_TIMEOUT_MS,
|
|
1225
|
+
adapters: adapterTable,
|
|
1223
1226
|
} = {},
|
|
1224
1227
|
) {
|
|
1225
1228
|
const resolvedRoot = resolve(root);
|
|
1229
|
+
const calibration = CALIBRATION_HARNESSES.has(harness);
|
|
1230
|
+
const adapters = calibration ? null : (adapterTable ?? (await loadAdapters()));
|
|
1231
|
+
const adapter = calibration ? null : adapters[harness];
|
|
1232
|
+
if (!calibration && !adapter) {
|
|
1233
|
+
throw new Error(
|
|
1234
|
+
`unknown probe harness "${harness}"; known harnesses: ${[...CALIBRATION_HARNESSES].sort().join(', ')}, ${Object.keys(adapters).sort().join(', ')}`,
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
const measured = !calibration;
|
|
1238
|
+
const isolation = calibration ? 'none' : adapterIsolation(adapter);
|
|
1239
|
+
const resolvedModel = model ?? (calibration ? harness : 'default');
|
|
1226
1240
|
const selectedProbes =
|
|
1227
1241
|
Array.isArray(probeFilter) && probeFilter.length > 0
|
|
1228
1242
|
? PROBES.filter((p) => probeFilter.includes(p.id))
|
|
@@ -1252,14 +1266,28 @@ export async function runBenchProbes(
|
|
|
1252
1266
|
const ws = await prepareProbeWorkspace(probe, { armSpec });
|
|
1253
1267
|
try {
|
|
1254
1268
|
let outcome;
|
|
1255
|
-
if (
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
outcome =
|
|
1269
|
+
if (calibration) {
|
|
1270
|
+
// `gold` runs the compliant script, `violator` and `noop` the violating one:
|
|
1271
|
+
// the pair proves the grader both accepts and rejects, nothing more.
|
|
1272
|
+
outcome =
|
|
1273
|
+
harness === 'gold'
|
|
1274
|
+
? await probe.comply(ws.workDir, ws)
|
|
1275
|
+
: await probe.violate(ws.workDir, ws);
|
|
1259
1276
|
} else {
|
|
1260
|
-
|
|
1261
|
-
|
|
1277
|
+
// A real harness gets a real workspace and the probe's own prompt. Grading
|
|
1278
|
+
// reads the workspace and the transcript, so the agent's own commands are
|
|
1279
|
+
// what decide compliance.
|
|
1280
|
+
const executed = await executeAdapter(
|
|
1281
|
+
adapter,
|
|
1282
|
+
ws.workDir,
|
|
1283
|
+
{ id: probe.id, prompt: probe.prompt },
|
|
1284
|
+
{ model: resolvedModel, env: {}, timeoutMs, slotDir: ws.slotDir },
|
|
1262
1285
|
);
|
|
1286
|
+
const transcript = executed.transcript ?? '';
|
|
1287
|
+
outcome = {
|
|
1288
|
+
commands: extractTranscriptCommands({ stdout: transcript }),
|
|
1289
|
+
stdout: transcript,
|
|
1290
|
+
};
|
|
1263
1291
|
}
|
|
1264
1292
|
|
|
1265
1293
|
const grade = await gradeProbe(probe, {
|
|
@@ -1278,7 +1306,10 @@ export async function runBenchProbes(
|
|
|
1278
1306
|
adr: probe.adr,
|
|
1279
1307
|
arm: armSpec.name,
|
|
1280
1308
|
harness,
|
|
1309
|
+
model: resolvedModel,
|
|
1281
1310
|
rep,
|
|
1311
|
+
measured,
|
|
1312
|
+
isolation,
|
|
1282
1313
|
passed: grade.passed,
|
|
1283
1314
|
reason: grade.reason,
|
|
1284
1315
|
};
|
|
@@ -1300,6 +1331,9 @@ export async function runBenchProbes(
|
|
|
1300
1331
|
const byArm = new Map();
|
|
1301
1332
|
for (const rec of allRecords) {
|
|
1302
1333
|
if (Array.isArray(arms) && arms.length > 0 && !arms.includes(rec.arm)) continue;
|
|
1334
|
+
// A stored record belongs to the harness that produced it. Pooling a violator run
|
|
1335
|
+
// into a gold summary reported compliance for a measurement never taken.
|
|
1336
|
+
if (rec.harness && rec.harness !== harness) continue;
|
|
1303
1337
|
const list = byArm.get(rec.arm) ?? [];
|
|
1304
1338
|
list.push(rec);
|
|
1305
1339
|
byArm.set(rec.arm, list);
|
|
@@ -1313,13 +1347,16 @@ export async function runBenchProbes(
|
|
|
1313
1347
|
const complianceRate = total > 0 ? passed / total : 0;
|
|
1314
1348
|
const results = {};
|
|
1315
1349
|
for (const r of list) {
|
|
1316
|
-
|
|
1350
|
+
// Keyed by rep as well as probe: two reps of one probe are two results, and the
|
|
1351
|
+
// old key kept only whichever rep was read last (review 0003 Q59).
|
|
1352
|
+
results[`${r.probe}#r${r.rep ?? 1}`] = { passed: r.passed, reason: r.reason };
|
|
1317
1353
|
}
|
|
1318
1354
|
return {
|
|
1319
1355
|
arm,
|
|
1320
1356
|
total,
|
|
1321
1357
|
passed,
|
|
1322
1358
|
complianceRate,
|
|
1359
|
+
measured: list.every((r) => r.measured === true),
|
|
1323
1360
|
results,
|
|
1324
1361
|
};
|
|
1325
1362
|
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// One bounded child process, for everything the bench runs (review 0003 Q47).
|
|
2
|
+
//
|
|
3
|
+
// `child.kill('SIGKILL')` kills the process the bench started and leaves its
|
|
4
|
+
// grandchildren running: an adapter that ran `(sleep 15 &); sleep 60` kept the pipe open
|
|
5
|
+
// and the bench waited 62.6 s against a 2 s timeout, then graded the result. A detached
|
|
6
|
+
// child is its own process group, so the deadline kills the group, destroys the streams
|
|
7
|
+
// and settles. A run cannot outlive its own deadline, whatever it spawned.
|
|
8
|
+
//
|
|
9
|
+
// Output is bounded so a runaway agent cannot decide the bench's memory: past the cap the
|
|
10
|
+
// oldest chunk is dropped and `truncated` says so, rather than the spawn failing.
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
/** The most output one run may accumulate before the oldest bytes are dropped. */
|
|
15
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 8 << 20;
|
|
16
|
+
|
|
17
|
+
/** How long to wait for streams to close after the process group is killed. */
|
|
18
|
+
const HARD_STOP_GRACE_MS = 500;
|
|
19
|
+
|
|
20
|
+
function boundedPush(chunks, state, chunk) {
|
|
21
|
+
state.bytes += chunk.length;
|
|
22
|
+
chunks.push(chunk);
|
|
23
|
+
while (state.bytes > state.max && chunks.length > 1) {
|
|
24
|
+
state.bytes -= chunks.shift().length;
|
|
25
|
+
state.truncated = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run `cmd` with `args`, bounded by a deadline and an output cap.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} cmd
|
|
33
|
+
* @param {string[]} args
|
|
34
|
+
* @param {{cwd?: string, env?: NodeJS.ProcessEnv, input?: Buffer|string|null, timeoutMs?: number, maxOutputBytes?: number}} [options]
|
|
35
|
+
* @returns {Promise<{code: number, timedOut: boolean, wallMs: number, stdout: Buffer, stderr: Buffer, truncated: boolean, spawnError: string|null}>}
|
|
36
|
+
* `code` is 124 when the deadline fired, and `wallMs` is what the run actually took.
|
|
37
|
+
*/
|
|
38
|
+
export function spawnBounded(
|
|
39
|
+
cmd,
|
|
40
|
+
args,
|
|
41
|
+
{ cwd, env, input = null, timeoutMs = 0, maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES } = {},
|
|
42
|
+
) {
|
|
43
|
+
return new Promise((settle) => {
|
|
44
|
+
const startedAt = Date.now();
|
|
45
|
+
let child;
|
|
46
|
+
try {
|
|
47
|
+
child = spawn(cmd, args, {
|
|
48
|
+
cwd,
|
|
49
|
+
env,
|
|
50
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
51
|
+
detached: true,
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
settle({
|
|
55
|
+
code: 1,
|
|
56
|
+
timedOut: false,
|
|
57
|
+
wallMs: 0,
|
|
58
|
+
stdout: Buffer.alloc(0),
|
|
59
|
+
stderr: Buffer.from(String(error?.message ?? error)),
|
|
60
|
+
truncated: false,
|
|
61
|
+
spawnError: String(error?.message ?? error),
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const out = [];
|
|
67
|
+
const err = [];
|
|
68
|
+
const outState = { bytes: 0, max: maxOutputBytes, truncated: false };
|
|
69
|
+
const errState = { bytes: 0, max: maxOutputBytes, truncated: false };
|
|
70
|
+
let timer = null;
|
|
71
|
+
let hardStop = null;
|
|
72
|
+
let timedOut = false;
|
|
73
|
+
let done = false;
|
|
74
|
+
|
|
75
|
+
const killGroup = () => {
|
|
76
|
+
try {
|
|
77
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
78
|
+
} catch {
|
|
79
|
+
try {
|
|
80
|
+
child.kill('SIGKILL');
|
|
81
|
+
} catch {
|
|
82
|
+
// Already gone; the close handler settles the promise.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const finish = (code, spawnError = null) => {
|
|
88
|
+
if (done) return;
|
|
89
|
+
done = true;
|
|
90
|
+
if (timer) clearTimeout(timer);
|
|
91
|
+
if (hardStop) clearTimeout(hardStop);
|
|
92
|
+
child.stdout?.destroy();
|
|
93
|
+
child.stderr?.destroy();
|
|
94
|
+
settle({
|
|
95
|
+
code,
|
|
96
|
+
timedOut,
|
|
97
|
+
wallMs: Date.now() - startedAt,
|
|
98
|
+
stdout: Buffer.concat(out),
|
|
99
|
+
stderr: Buffer.concat(err),
|
|
100
|
+
truncated: outState.truncated || errState.truncated,
|
|
101
|
+
spawnError,
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (timeoutMs > 0) {
|
|
106
|
+
timer = setTimeout(() => {
|
|
107
|
+
timedOut = true;
|
|
108
|
+
killGroup();
|
|
109
|
+
// A process that escaped the group can hold the pipes open after the kill, and a
|
|
110
|
+
// deadline that waits on a stranger's cleanup is not a deadline.
|
|
111
|
+
hardStop = setTimeout(() => finish(124), HARD_STOP_GRACE_MS);
|
|
112
|
+
hardStop.unref?.();
|
|
113
|
+
}, timeoutMs);
|
|
114
|
+
timer.unref?.();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
child.stdout?.on('data', (chunk) => boundedPush(out, outState, chunk));
|
|
118
|
+
child.stderr?.on('data', (chunk) => boundedPush(err, errState, chunk));
|
|
119
|
+
child.on('error', (error) => {
|
|
120
|
+
const message = String(error?.message ?? error);
|
|
121
|
+
boundedPush(err, errState, Buffer.from(message));
|
|
122
|
+
finish(1, message);
|
|
123
|
+
});
|
|
124
|
+
child.on('close', (code) => finish(timedOut ? 124 : (code ?? 1)));
|
|
125
|
+
|
|
126
|
+
child.stdin?.end(input ?? undefined);
|
|
127
|
+
});
|
|
128
|
+
}
|
package/src/bench/report.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Cost model, paired statistical tests and report generator (`simt bench report`, R03).
|
|
2
2
|
//
|
|
3
|
-
// Reads stored run records from `.simt
|
|
4
|
-
// from `.simt
|
|
3
|
+
// Reads stored run records from `.simt/local/bench/runs/` and optional token pricing
|
|
4
|
+
// from `.simt/local/bench/prices.json` (per million tokens: `{in, cache_write, cache_read, out}`).
|
|
5
5
|
// Never performs network lookups. All statistical estimators (paired bootstrap 95% CIs,
|
|
6
6
|
// exact sign test on task-mean R, Wilcoxon signed-rank test on task-mean C) are
|
|
7
7
|
// deterministic. Reps of a task are not independent tasks: pairing happens at the
|
|
@@ -69,7 +69,7 @@ function isFlatPriceObject(obj) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* Load `.simt
|
|
72
|
+
* Load `.simt/local/bench/prices.json` if present.
|
|
73
73
|
* Supports either a flat `{in, cache_write, cache_read, out}` object (prices per 1M tokens)
|
|
74
74
|
* or a map keyed by model/harness name with an optional `"default"` entry.
|
|
75
75
|
*/
|
|
@@ -899,6 +899,13 @@ export function formatReportMarkdown(report) {
|
|
|
899
899
|
);
|
|
900
900
|
}
|
|
901
901
|
|
|
902
|
+
if (report.excluded > 0) {
|
|
903
|
+
lines.push('');
|
|
904
|
+
lines.push(
|
|
905
|
+
`${report.excluded} run record(s) excluded: the run errored or exceeded its deadline, so it recorded no measurement`,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
|
|
902
909
|
if (report.comparison) {
|
|
903
910
|
const cmp = report.comparison;
|
|
904
911
|
const d = cmp.deltas;
|
|
@@ -975,7 +982,12 @@ export async function buildBenchReport(
|
|
|
975
982
|
return true;
|
|
976
983
|
});
|
|
977
984
|
|
|
978
|
-
|
|
985
|
+
// A run that errored produced no measurement: averaging its zero tokens, zero diff and
|
|
986
|
+
// unresolved verdict into an arm reports a result nobody observed (review 0003 Q47).
|
|
987
|
+
const errored = filtered.filter((rec) => rec.status === 'error');
|
|
988
|
+
const evaluated = filtered
|
|
989
|
+
.filter((rec) => rec.status !== 'error')
|
|
990
|
+
.map((rec) => evaluateRunRecord(rec, prices));
|
|
979
991
|
const byGroup = new Map();
|
|
980
992
|
for (const row of evaluated) {
|
|
981
993
|
const key = `${row.harness}|${row.model}|${row.arm}|${row.experiment}`;
|
|
@@ -1049,6 +1061,7 @@ export async function buildBenchReport(
|
|
|
1049
1061
|
root: resolvedRoot,
|
|
1050
1062
|
pricesLoaded: prices !== null,
|
|
1051
1063
|
runCount: evaluated.length,
|
|
1064
|
+
excluded: errored.length,
|
|
1052
1065
|
arms,
|
|
1053
1066
|
comparison,
|
|
1054
1067
|
};
|