simt-dev 0.7.1 → 0.8.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 +36 -1
- package/README.md +169 -80
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
- package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +3 -1
- package/rules/mdc/intake-questions.mdc +8 -0
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +7 -5
- package/src/argv.mjs +19 -0
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +155 -113
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +12 -26
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +17 -535
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +15 -1037
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +116 -971
- package/src/bench/run.mjs +164 -881
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +11 -13
- package/src/ci/dependabot.mjs +25 -55
- package/src/commands/agent.mjs +1 -9
- package/src/commands/agenthook.mjs +66 -35
- package/src/commands/bench.mjs +2 -2
- package/src/commands/ci.mjs +11 -13
- package/src/commands/docs.mjs +88 -0
- package/src/commands/gate.mjs +10 -13
- package/src/commands/harness.mjs +3 -3
- package/src/commands/help.mjs +44 -3
- package/src/commands/hooks.mjs +20 -20
- package/src/commands/ignore.mjs +11 -7
- package/src/commands/impact.mjs +4 -8
- package/src/commands/index.mjs +49 -9
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +122 -104
- package/src/commands/lease.mjs +67 -18
- package/src/commands/map.mjs +10 -5
- package/src/commands/mcp.mjs +2 -17
- package/src/commands/repoindex.mjs +8 -8
- package/src/commands/rules.mjs +8 -4
- package/src/commands/shared.mjs +12 -16
- package/src/commands/skills.mjs +5 -6
- package/src/commands/skillscompile.mjs +3 -3
- package/src/commands/skillsinstall.mjs +5 -4
- package/src/commands/slice.mjs +199 -64
- package/src/commands/spec.mjs +78 -62
- package/src/commands/status.mjs +24 -12
- package/src/commands/sync.mjs +14 -14
- package/src/commands/tests.mjs +12 -12
- package/src/commands/tokens.mjs +19 -24
- package/src/commands/upgrade.mjs +8 -4
- package/src/commands/why.mjs +6 -13
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/decisions/index.mjs +3 -2
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +2 -1
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +5 -3
- package/src/hooks/dispatcher.mjs +26 -32
- package/src/hooks/gates.mjs +12 -2
- package/src/hooks/resolve.mjs +2 -7
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +2 -11
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +16 -11
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +124 -17
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +49 -9
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -1005
- package/src/{bench → measure}/corpus.mjs +1 -1
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/output/index.mjs +122 -31
- package/src/packs/manage.mjs +15 -9
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/manifest.mjs +10 -435
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +16 -547
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +13 -1
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +0 -7
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +8 -12
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +1 -1
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +3 -1
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/src/bench/tasks.mjs +0 -36
|
@@ -18,7 +18,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
18
18
|
|
|
19
19
|
import { STATE_DIR } from '../name.mjs';
|
|
20
20
|
import { packageVersion } from '../package-version.mjs';
|
|
21
|
-
import { resolveVaultDir } from '
|
|
21
|
+
import { resolveVaultDir } from '../testing/vault.mjs';
|
|
22
22
|
|
|
23
23
|
/** The document kind stamp, so a reader can refuse a file that is not a corpus. */
|
|
24
24
|
export const CORPUS_KIND = 'simt-bench-corpus';
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Derived per-run metrics: what a record costs, and how good it is.
|
|
2
|
+
//
|
|
3
|
+
// Split from the statistics beside it (review 0003 Q78): a metric is a function of one
|
|
4
|
+
// record, a statistical test is a function of a population of them, and a reader looking
|
|
5
|
+
// for the cost model should not have to open the bootstrap.
|
|
6
|
+
//
|
|
7
|
+
// The cost model distinguishes three answers because a missing cost must never read as a
|
|
8
|
+
// zero cost: `priced` from the token table, `reported` by the harness, `missing` when
|
|
9
|
+
// neither exists (review 0003 Q46).
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { LOCAL_DIR } from '../state.mjs';
|
|
14
|
+
|
|
15
|
+
function normalizePriceEntry(raw, label) {
|
|
16
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
17
|
+
throw new Error(`${label} must be a JSON object with {in, cache_write, cache_read, out}`);
|
|
18
|
+
}
|
|
19
|
+
const pIn = Number(raw.in ?? raw.input ?? 0);
|
|
20
|
+
const pCw = Number(raw.cache_write ?? raw.cacheWrite ?? 0);
|
|
21
|
+
const pCr = Number(raw.cache_read ?? raw.cacheRead ?? 0);
|
|
22
|
+
const pOut = Number(raw.out ?? raw.output ?? 0);
|
|
23
|
+
for (const [key, value] of [
|
|
24
|
+
['in', pIn],
|
|
25
|
+
['cache_write', pCw],
|
|
26
|
+
['cache_read', pCr],
|
|
27
|
+
['out', pOut],
|
|
28
|
+
]) {
|
|
29
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
30
|
+
throw new Error(`${label}.${key} must be a non-negative number`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { in: pIn, cache_write: pCw, cache_read: pCr, out: pOut };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isFlatPriceObject(obj) {
|
|
37
|
+
return (
|
|
38
|
+
'in' in obj ||
|
|
39
|
+
'input' in obj ||
|
|
40
|
+
'cache_write' in obj ||
|
|
41
|
+
'cacheWrite' in obj ||
|
|
42
|
+
'cache_read' in obj ||
|
|
43
|
+
'cacheRead' in obj ||
|
|
44
|
+
'out' in obj ||
|
|
45
|
+
'output' in obj
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Load `.simt/local/bench/prices.json` if present.
|
|
51
|
+
*
|
|
52
|
+
* Supports either a flat `{in, cache_write, cache_read, out}` object (prices per 1M
|
|
53
|
+
* tokens) or a map keyed by model/harness name with an optional `"default"` entry.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} root
|
|
56
|
+
* @returns {Promise<null|{kind: 'flat'|'map', entry?: object, entries?: object}>}
|
|
57
|
+
*/
|
|
58
|
+
export async function loadPrices(root) {
|
|
59
|
+
// The pre-move path is a fallback for one release (review 0003 Q19).
|
|
60
|
+
const pricesPath = join(resolve(root), LOCAL_DIR, 'bench', 'prices.json');
|
|
61
|
+
const legacyPath = join(resolve(root), '.simt', 'bench', 'prices.json');
|
|
62
|
+
let rawText;
|
|
63
|
+
try {
|
|
64
|
+
rawText = await readFile(pricesPath, 'utf8');
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error && error.code === 'ENOENT') {
|
|
67
|
+
try {
|
|
68
|
+
rawText = await readFile(legacyPath, 'utf8');
|
|
69
|
+
} catch (legacyError) {
|
|
70
|
+
if (legacyError && legacyError.code === 'ENOENT') return null;
|
|
71
|
+
throw legacyError;
|
|
72
|
+
}
|
|
73
|
+
} else throw error;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(rawText);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(`invalid JSON in ${pricesPath}: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
83
|
+
throw new Error(`${pricesPath} must be a JSON object`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isFlatPriceObject(parsed)) {
|
|
87
|
+
return { kind: 'flat', entry: normalizePriceEntry(parsed, pricesPath) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const entries = {};
|
|
91
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
92
|
+
entries[key] = normalizePriceEntry(value, `${pricesPath}[${JSON.stringify(key)}]`);
|
|
93
|
+
}
|
|
94
|
+
return { kind: 'map', entries };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolvePriceEntry(prices, run) {
|
|
98
|
+
if (!prices) return null;
|
|
99
|
+
if (prices.kind === 'flat') return normalizePriceEntry(prices.entry, 'prices');
|
|
100
|
+
if (prices.kind === 'map') {
|
|
101
|
+
const entry =
|
|
102
|
+
(run?.model && prices.entries[run.model]) ||
|
|
103
|
+
(run?.harness && prices.entries[run.harness]) ||
|
|
104
|
+
prices.entries.default ||
|
|
105
|
+
null;
|
|
106
|
+
return entry ? normalizePriceEntry(entry, 'prices') : null;
|
|
107
|
+
}
|
|
108
|
+
if (isFlatPriceObject(prices)) {
|
|
109
|
+
return normalizePriceEntry(prices, 'prices');
|
|
110
|
+
}
|
|
111
|
+
const entry =
|
|
112
|
+
(run?.model && prices[run.model]) || (run?.harness && prices[run.harness]) || prices.default || null;
|
|
113
|
+
return entry ? normalizePriceEntry(entry, 'prices') : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Compute billed cost in USD for a single run record.
|
|
118
|
+
*
|
|
119
|
+
* Returns `{usd, source}` where `usd` is null when the cost is unknown: an arm whose
|
|
120
|
+
* prices are absent must not report a free run.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} run
|
|
123
|
+
* @param {object|null} [prices]
|
|
124
|
+
* @returns {{usd: number|null, source: 'priced'|'reported'|'missing'}}
|
|
125
|
+
*/
|
|
126
|
+
export function computeRunCost(run, prices = null) {
|
|
127
|
+
const usage = run?.usage ?? {};
|
|
128
|
+
const input = Number(usage.input ?? 0);
|
|
129
|
+
const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
|
|
130
|
+
const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? 0);
|
|
131
|
+
const output = Number(usage.output ?? 0);
|
|
132
|
+
const totalTokens = input + cacheWrite + cacheRead + output;
|
|
133
|
+
|
|
134
|
+
const entry = resolvePriceEntry(prices, run);
|
|
135
|
+
if (entry) {
|
|
136
|
+
if (totalTokens === 0 && Number(run?.costUsd ?? 0) > 0) {
|
|
137
|
+
return { usd: Number(run.costUsd), source: 'reported' };
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
usd:
|
|
141
|
+
(input * entry.in + cacheWrite * entry.cache_write + cacheRead * entry.cache_read + output * entry.out) /
|
|
142
|
+
1_000_000,
|
|
143
|
+
source: 'priced',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const reported = run?.costUsd;
|
|
147
|
+
if (typeof reported === 'number' && Number.isFinite(reported)) {
|
|
148
|
+
return { usd: reported, source: 'reported' };
|
|
149
|
+
}
|
|
150
|
+
return { usd: null, source: 'missing' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Whether a run attempt counts as resolved: hidden tests pass AND declared gates pass. */
|
|
154
|
+
export function isRunResolved(run) {
|
|
155
|
+
return Boolean(run?.resolved && (run?.gatesPassed ?? true));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Quality score Q in [0, 1]: 0.5 hidden tests + 0.2 gates + 0.2 probe + 0.1 diff hygiene.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} run
|
|
162
|
+
* @returns {number}
|
|
163
|
+
*/
|
|
164
|
+
export function computeRunQuality(run) {
|
|
165
|
+
const testsScore = run?.resolved ? 1 : 0;
|
|
166
|
+
const gatesScore = (run?.gatesPassed ?? true) ? 1 : 0;
|
|
167
|
+
|
|
168
|
+
let probeScore = 1;
|
|
169
|
+
if (run?.probe !== null && run?.probe !== undefined) {
|
|
170
|
+
if (typeof run.probe === 'boolean') probeScore = run.probe ? 1 : 0;
|
|
171
|
+
else if (typeof run.probe === 'number' && Number.isFinite(run.probe)) {
|
|
172
|
+
probeScore = Math.max(0, Math.min(1, run.probe));
|
|
173
|
+
} else if (typeof run.probe === 'object') probeScore = run.probe.passed ? 1 : 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const files = Number(run?.diff?.files ?? 0);
|
|
177
|
+
const outside = Number(run?.diff?.outsideBlastRadius ?? 0);
|
|
178
|
+
let hygieneScore = 1;
|
|
179
|
+
if (outside > 0) {
|
|
180
|
+
hygieneScore = files > 0 ? Math.max(0, Math.min(1, (files - outside) / files)) : 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (5 * testsScore + 2 * gatesScore + 2 * probeScore + hygieneScore) / 10;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Evaluate one record into the row an arm aggregation and a paired test consume.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} run
|
|
190
|
+
* @param {object|null} [prices]
|
|
191
|
+
* @returns {object}
|
|
192
|
+
*/
|
|
193
|
+
export function evaluateRunRecord(run, prices = null) {
|
|
194
|
+
const usage = run?.usage ?? {};
|
|
195
|
+
const input = Number(usage.input ?? 0);
|
|
196
|
+
const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
|
|
197
|
+
const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? 0);
|
|
198
|
+
const output = Number(usage.output ?? 0);
|
|
199
|
+
const turns = Number(usage.turns ?? 0);
|
|
200
|
+
const toolCalls = Number(usage.toolCalls ?? usage.tool_calls ?? 0);
|
|
201
|
+
const R = isRunResolved(run) ? 1 : 0;
|
|
202
|
+
const Q = computeRunQuality(run);
|
|
203
|
+
const cost = computeRunCost(run, prices);
|
|
204
|
+
const cacheDenom = input + cacheRead;
|
|
205
|
+
const cacheHitRatio = cacheDenom > 0 ? cacheRead / cacheDenom : 0;
|
|
206
|
+
// A v2 experiment is the conditions, a v1 experiment was a label. Both reduce to one
|
|
207
|
+
// string, so grouping by experiment keeps working across a stored corpus.
|
|
208
|
+
const experiment = run?.experiment;
|
|
209
|
+
const experimentKey =
|
|
210
|
+
typeof experiment === 'string'
|
|
211
|
+
? experiment
|
|
212
|
+
: experiment && typeof experiment === 'object'
|
|
213
|
+
? [experiment.simtVersion ?? '-', experiment.rulesDigest ?? '-', experiment.corpusDigest ?? '-'].join('/')
|
|
214
|
+
: '';
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
task: String(run?.task ?? ''),
|
|
218
|
+
stratum: String(run?.stratum ?? 'unknown'),
|
|
219
|
+
arm: String(run?.arm ?? 'default'),
|
|
220
|
+
harness: String(run?.harness ?? ''),
|
|
221
|
+
model: String(run?.model ?? ''),
|
|
222
|
+
experiment: experimentKey,
|
|
223
|
+
rep: Number(run?.rep ?? 1),
|
|
224
|
+
R,
|
|
225
|
+
Q,
|
|
226
|
+
C: cost.usd,
|
|
227
|
+
costSource: cost.source,
|
|
228
|
+
turns,
|
|
229
|
+
toolCalls,
|
|
230
|
+
input,
|
|
231
|
+
cacheWrite,
|
|
232
|
+
cacheRead,
|
|
233
|
+
output,
|
|
234
|
+
cacheHitRatio,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Stored benchmark run records, schema v2.
|
|
2
|
+
//
|
|
3
|
+
// A v1 record said what an arm scored and nothing about the conditions it scored under.
|
|
4
|
+
// Two records from different rule text, different corpora or different harness versions
|
|
5
|
+
// were indistinguishable, so a report could compare them and call the difference an
|
|
6
|
+
// effect (review 0003 Q78). The record now carries the experiment that produced it, and
|
|
7
|
+
// the run id names the model whenever a model other than the harness default was used:
|
|
8
|
+
// two models driven through one harness used to overwrite each other's run directory.
|
|
9
|
+
//
|
|
10
|
+
// Loading is tolerant in one direction only: a record that does not parse is skipped and
|
|
11
|
+
// reported as a warning, because one corrupt file is not an empty corpus. A reader that
|
|
12
|
+
// answers "no runs" for a directory of unreadable files hides the corruption entirely.
|
|
13
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { arch, cpus, platform } from 'node:os';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { LOCAL_DIR } from '../state.mjs';
|
|
19
|
+
import { packageVersion } from '../package-version.mjs';
|
|
20
|
+
|
|
21
|
+
export const RECORD_SCHEMA_VERSION = 2;
|
|
22
|
+
|
|
23
|
+
/** Directory a run's records are stored under, with the pre-move path as a fallback. */
|
|
24
|
+
export function runsDir(root) {
|
|
25
|
+
return join(resolve(root), LOCAL_DIR, 'bench', 'runs');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function legacyRunsDir(root) {
|
|
29
|
+
return join(resolve(root), '.simt', 'bench', 'runs');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The run directory name for one arm and repetition.
|
|
34
|
+
*
|
|
35
|
+
* The model is part of the name when it is a real choice. A `default` model (or a
|
|
36
|
+
* harness whose model is itself) adds nothing to the name, and a name that changes for
|
|
37
|
+
* every run would make the stored path useless to a reader.
|
|
38
|
+
*
|
|
39
|
+
* @param {{harness: string, model?: string|null, arm: string, rep: number}} spec
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function runIdFor({ harness, model, arm, rep }) {
|
|
43
|
+
const named =
|
|
44
|
+
typeof model === 'string' && model.length > 0 && model !== 'default' && model !== harness;
|
|
45
|
+
return `${harness}-${named ? `${model}-` : ''}${arm}-r${rep}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The conditions a run was produced under, recorded so a report can refuse to pool
|
|
50
|
+
* records that were not comparable.
|
|
51
|
+
*
|
|
52
|
+
* @param {{simtVersion?: string, rulesDigest?: string|null, adapterVersion?: string|null,
|
|
53
|
+
* corpusDigest?: string|null, seed?: number|null, isolation?: string}} input
|
|
54
|
+
* @returns {object}
|
|
55
|
+
*/
|
|
56
|
+
export function experimentOf({
|
|
57
|
+
simtVersion = packageVersion(),
|
|
58
|
+
rulesDigest = null,
|
|
59
|
+
adapterVersion = null,
|
|
60
|
+
corpusDigest = null,
|
|
61
|
+
seed = null,
|
|
62
|
+
isolation = 'none',
|
|
63
|
+
} = {}) {
|
|
64
|
+
return {
|
|
65
|
+
simtVersion,
|
|
66
|
+
rulesDigest,
|
|
67
|
+
adapterVersion,
|
|
68
|
+
corpusDigest,
|
|
69
|
+
seed,
|
|
70
|
+
isolation,
|
|
71
|
+
hardware: `${platform()}/${arch()}/${cpus().length}cpu`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A digest of the corpus a run was measured against.
|
|
77
|
+
*
|
|
78
|
+
* It is the sorted task ids and their base commits, hashed: a re-mine that changes which
|
|
79
|
+
* tasks exist, or where they start, changes the digest, and a report can say the two
|
|
80
|
+
* records were not measured on the same corpus.
|
|
81
|
+
*
|
|
82
|
+
* @param {object[]} tasks normalised task descriptors
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function corpusDigestOf(tasks = []) {
|
|
86
|
+
const lines = [...tasks]
|
|
87
|
+
.map((task) => `${task.id}\0${task.base}`)
|
|
88
|
+
.sort()
|
|
89
|
+
.join('\n');
|
|
90
|
+
return createHash('sha256').update(lines).digest('hex').slice(0, 16);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build one record.
|
|
95
|
+
*
|
|
96
|
+
* `status` is the record's own claim: `ok` measured something, `error` did not. An error
|
|
97
|
+
* record keeps the run's identity so the report can exclude it by name rather than by
|
|
98
|
+
* looking for a missing field.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} input
|
|
101
|
+
* @returns {object}
|
|
102
|
+
*/
|
|
103
|
+
export function runRecord({
|
|
104
|
+
task,
|
|
105
|
+
arm,
|
|
106
|
+
harness,
|
|
107
|
+
model,
|
|
108
|
+
rep,
|
|
109
|
+
measured = true,
|
|
110
|
+
isolation = 'none',
|
|
111
|
+
experiment = null,
|
|
112
|
+
status = 'ok',
|
|
113
|
+
error = null,
|
|
114
|
+
agent = null,
|
|
115
|
+
resolved = false,
|
|
116
|
+
gatesPassed = false,
|
|
117
|
+
f2pPassed = [],
|
|
118
|
+
usage = null,
|
|
119
|
+
costUsd = null,
|
|
120
|
+
diff = { files: 0, outsideBlastRadius: 0 },
|
|
121
|
+
skills = null,
|
|
122
|
+
probe = null,
|
|
123
|
+
}) {
|
|
124
|
+
const record = {
|
|
125
|
+
schemaVersion: RECORD_SCHEMA_VERSION,
|
|
126
|
+
task: String(task?.id ?? task ?? ''),
|
|
127
|
+
stratum: String(task?.stratum ?? 'unknown'),
|
|
128
|
+
arm,
|
|
129
|
+
harness,
|
|
130
|
+
model,
|
|
131
|
+
rep,
|
|
132
|
+
measured: Boolean(measured),
|
|
133
|
+
isolation,
|
|
134
|
+
experiment,
|
|
135
|
+
status,
|
|
136
|
+
agent,
|
|
137
|
+
resolved: Boolean(resolved),
|
|
138
|
+
gatesPassed: Boolean(gatesPassed),
|
|
139
|
+
f2pPassed: [...f2pPassed],
|
|
140
|
+
usage: usage ?? { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, turns: 0, toolCalls: 0 },
|
|
141
|
+
costUsd: typeof costUsd === 'number' && Number.isFinite(costUsd) ? costUsd : null,
|
|
142
|
+
diff,
|
|
143
|
+
skills,
|
|
144
|
+
probe,
|
|
145
|
+
};
|
|
146
|
+
if (error !== null) record.error = String(error);
|
|
147
|
+
return record;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read every stored record, and report the files it could not read.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} root
|
|
154
|
+
* @returns {Promise<{records: object[], warnings: string[]}>}
|
|
155
|
+
*/
|
|
156
|
+
export async function readRunRecords(root) {
|
|
157
|
+
let base = runsDir(root);
|
|
158
|
+
let entries = await readdir(base, { withFileTypes: true }).catch(() => null);
|
|
159
|
+
if (entries === null) {
|
|
160
|
+
base = legacyRunsDir(root);
|
|
161
|
+
entries = await readdir(base, { withFileTypes: true }).catch(() => []);
|
|
162
|
+
}
|
|
163
|
+
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
164
|
+
|
|
165
|
+
const records = [];
|
|
166
|
+
const warnings = [];
|
|
167
|
+
const readOne = async (file) => {
|
|
168
|
+
let text;
|
|
169
|
+
try {
|
|
170
|
+
text = await readFile(file, 'utf8');
|
|
171
|
+
} catch (error) {
|
|
172
|
+
warnings.push(`could not read ${file}: ${error.message ?? error}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const record = JSON.parse(text);
|
|
177
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
178
|
+
warnings.push(`skipped ${file}: not a record object`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
records.push(record);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
warnings.push(`skipped ${file}: ${error.message ?? error}`);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
for (const entry of sorted) {
|
|
188
|
+
if (entry.name.startsWith('.')) continue;
|
|
189
|
+
const full = join(base, entry.name);
|
|
190
|
+
if (entry.isDirectory()) {
|
|
191
|
+
const files = (await readdir(full).catch(() => []))
|
|
192
|
+
.filter((file) => file.endsWith('.json') && !file.startsWith('.'))
|
|
193
|
+
.sort();
|
|
194
|
+
for (const file of files) await readOne(join(full, file));
|
|
195
|
+
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
196
|
+
await readOne(full);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { records, warnings };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Every stored record. A caller that has nowhere to put a warning gets the records and
|
|
204
|
+
* no warnings; the report is the caller that shows them.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} root
|
|
207
|
+
* @returns {Promise<object[]>}
|
|
208
|
+
*/
|
|
209
|
+
export async function loadRunRecords(root) {
|
|
210
|
+
return (await readRunRecords(root)).records;
|
|
211
|
+
}
|