@tachikomagundam/abathur 0.1.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/.github/workflows/ci.yml +29 -0
- package/.github/workflows/publish.yml +74 -0
- package/LICENSE +21 -0
- package/README.md +461 -0
- package/config/abathur.jsonc +17 -0
- package/config/genomes/historian.example.jsonc +124 -0
- package/dist/bench/adapter.js +201 -0
- package/dist/bench/fixture-probe.js +92 -0
- package/dist/bench/fixture-support.js +173 -0
- package/dist/bench/fixture.js +236 -0
- package/dist/bench/toy.js +152 -0
- package/dist/cli.js +110 -0
- package/dist/commands/bundle.js +79 -0
- package/dist/commands/genome.js +94 -0
- package/dist/commands/graft.js +71 -0
- package/dist/commands/kernel.js +47 -0
- package/dist/commands/promote.js +25 -0
- package/dist/commands/run.js +145 -0
- package/dist/commands/self-eval.js +240 -0
- package/dist/commands/status.js +186 -0
- package/dist/commands/tombstone.js +72 -0
- package/dist/config.js +161 -0
- package/dist/core/bundle-common.js +119 -0
- package/dist/core/bundle-export.js +212 -0
- package/dist/core/bundle-inspect.js +143 -0
- package/dist/core/bundle-manifest.js +105 -0
- package/dist/core/bundle-mask.js +75 -0
- package/dist/core/bundle-tar.js +240 -0
- package/dist/core/bundle.js +9 -0
- package/dist/core/evolve/brief.js +45 -0
- package/dist/core/evolve/candidate.js +140 -0
- package/dist/core/evolve/child-track.js +197 -0
- package/dist/core/evolve/friction.js +150 -0
- package/dist/core/evolve/reflect.js +191 -0
- package/dist/core/evolve/run-bench.js +170 -0
- package/dist/core/evolve/run-friction.js +63 -0
- package/dist/core/evolve/run-loop.js +282 -0
- package/dist/core/evolve/run-plan.js +39 -0
- package/dist/core/evolve/run-rows.js +145 -0
- package/dist/core/evolve/self-overlay.js +213 -0
- package/dist/core/evolve/self-snapshot.js +170 -0
- package/dist/core/evolve/stub-mutators.mjs +105 -0
- package/dist/core/evolve/udiff.js +189 -0
- package/dist/core/genome-paths.js +76 -0
- package/dist/core/genome.js +176 -0
- package/dist/core/glob.js +106 -0
- package/dist/core/graft-gates.js +184 -0
- package/dist/core/graft-rebench.js +187 -0
- package/dist/core/graft-support.js +181 -0
- package/dist/core/graft.js +218 -0
- package/dist/core/ids.js +154 -0
- package/dist/core/incumbent.js +46 -0
- package/dist/core/kernel.js +112 -0
- package/dist/core/ledger.js +198 -0
- package/dist/core/locks.js +172 -0
- package/dist/core/promote.js +119 -0
- package/dist/core/snapshot.js +61 -0
- package/dist/core/spec.js +178 -0
- package/dist/core/stats-math.js +102 -0
- package/dist/core/stats-pareto.js +57 -0
- package/dist/core/stats.js +184 -0
- package/dist/core/worktree.js +190 -0
- package/dist/exit.js +32 -0
- package/dist/genomes/toy-smoke/genome.jsonc +30 -0
- package/dist/genomes/toy-smoke/grader.mjs +61 -0
- package/dist/genomes/toy-smoke/init.mjs +63 -0
- package/dist/genomes/toy-smoke/units/add.mjs +17 -0
- package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
- package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
- package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
- package/dist/jsonc.js +77 -0
- package/dist/out.js +5 -0
- package/dist/test/bench-adapter.test.js +33 -0
- package/dist/test/bench-fixture.test.js +407 -0
- package/dist/test/bench-toy.test.js +251 -0
- package/dist/test/bundle.test.js +659 -0
- package/dist/test/config.test.js +185 -0
- package/dist/test/d7-gate.test.js +56 -0
- package/dist/test/fixture-loop.test.js +267 -0
- package/dist/test/fixtures/friction-writer.js +16 -0
- package/dist/test/fixtures-historian.js +82 -0
- package/dist/test/fixtures-self.js +143 -0
- package/dist/test/fixtures-wt.js +64 -0
- package/dist/test/friction.test.js +398 -0
- package/dist/test/genome.test.js +453 -0
- package/dist/test/git.test.js +69 -0
- package/dist/test/graft.test.js +567 -0
- package/dist/test/historian-genome.test.js +134 -0
- package/dist/test/historian-grader-io.test.js +148 -0
- package/dist/test/historian-grader.test.js +209 -0
- package/dist/test/ids.test.js +116 -0
- package/dist/test/include-val.test.js +120 -0
- package/dist/test/ledger-lock.test.js +99 -0
- package/dist/test/ledger.test.js +102 -0
- package/dist/test/promote.test.js +394 -0
- package/dist/test/reflect.test.js +410 -0
- package/dist/test/run-loop.test.js +433 -0
- package/dist/test/self-snapshot.test.js +328 -0
- package/dist/test/snapshot.test.js +86 -0
- package/dist/test/stats.test.js +423 -0
- package/dist/test/stub-mutators.test.js +17 -0
- package/dist/test/testutil.js +30 -0
- package/dist/test/worktree.test.js +198 -0
- package/dist/util/freeze.js +30 -0
- package/dist/util/git.js +85 -0
- package/docs/federation.md +184 -0
- package/docs/immutable-kernel.md +87 -0
- package/graders/historian/grader-core.d.mts +53 -0
- package/graders/historian/grader-core.mjs +276 -0
- package/graders/historian/grader-support.d.mts +57 -0
- package/graders/historian/grader-support.mjs +137 -0
- package/graders/historian/grader.mjs +113 -0
- package/graders/historian/mutate.sh +114 -0
- package/graders/historian/reset-sandbox.sh +60 -0
- package/graders/historian/run-scenario.sh +49 -0
- package/graders/historian/seed-wrapped.sh +32 -0
- package/package.json +42 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Run-loop orchestrator (plan todo 9): generate → bench → select, resumable.
|
|
2
|
+
//
|
|
3
|
+
// Startup gate ORDER IS LOAD-BEARING (AC e): kernel audit first — drift refuses
|
|
4
|
+
// the run before any state exists; then the dry-run plan; then ledger open +
|
|
5
|
+
// resume read; then the genome lock (single flight); only THEN the orphan reap
|
|
6
|
+
// (so a concurrent live run's children can never be reaped); then openGenome
|
|
7
|
+
// (dirty bench-target paths refuse before anything spawns).
|
|
8
|
+
//
|
|
9
|
+
// Evolution loop: reflection brief from the incumbent's bench evidence →
|
|
10
|
+
// runMutatorSession (todo 8) applies candidates into sealed throwaway worktrees
|
|
11
|
+
// → each candidate tree is benched (all units, train AND val, `reps` replicates
|
|
12
|
+
// within budget) → stats.evaluate nominates/culls → one generation_complete
|
|
13
|
+
// row per candidate. The mutator driver never writes generation_complete; this
|
|
14
|
+
// module owns it.
|
|
15
|
+
//
|
|
16
|
+
// Resume/exactly-once: incumbent rows dedup by headCommit, candidates by
|
|
17
|
+
// content treeSha (stable across reseals, unlike commit shas). A reused row is
|
|
18
|
+
// never re-benched, so score samples and budget counters survive a SIGKILL
|
|
19
|
+
// without doubling. Row schema: run-bench.ts.
|
|
20
|
+
//
|
|
21
|
+
// Pure-LOC documented exception (F2 review, 2026-09-10): 283 pure LOC, above the
|
|
22
|
+
// 250 ceiling — F1-fix2's regression pins demand loop-local construction, and the
|
|
23
|
+
// resume/selection ordering that broke once must not be moved behind a fresh seam
|
|
24
|
+
// (minimal-diff mandate). Accepted exception: do not grow this file; split at the
|
|
25
|
+
// next real feature.
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { EXIT_BLOCKED, EXIT_CANNOT_ANSWER, EXIT_OK, blocked, cannotAnswer } from "../../exit.js";
|
|
28
|
+
import { probeRequiresOnly } from "../../bench/fixture-probe.js";
|
|
29
|
+
import { fingerprint16 } from "../genome.js";
|
|
30
|
+
import { auditKernel } from "../kernel.js";
|
|
31
|
+
import { compactUtc, fingerprint, genId } from "../ids.js";
|
|
32
|
+
import { LEDGER_KIND_GENERATION_COMPLETE, acquireGenomeLock, Ledger } from "../ledger.js";
|
|
33
|
+
import { openGenome } from "../worktree.js";
|
|
34
|
+
import { clampReps, evaluate, needsExit } from "../stats.js";
|
|
35
|
+
import { effectiveRepoPath, isEnvRepoLiteral } from "../spec.js";
|
|
36
|
+
import { ChildTracker, reapOrphans } from "./child-track.js";
|
|
37
|
+
import { buildBrief } from "./brief.js";
|
|
38
|
+
import { startRunFriction } from "./run-friction.js";
|
|
39
|
+
import { runMutatorSession } from "./reflect.js";
|
|
40
|
+
import { asReplicates, benchTarget, cloneMatrixRow, copyProvenance } from "./run-bench.js";
|
|
41
|
+
import { selfGuardVerdict } from "./self-snapshot.js";
|
|
42
|
+
import { effectiveCaps, planLines } from "./run-plan.js";
|
|
43
|
+
import { addCounters, readResume, } from "./run-rows.js";
|
|
44
|
+
/** Run exit over candidate verdicts: any nomination wins; else inconclusive ⇒ 2, else 1. */
|
|
45
|
+
export function runExitCode(verdicts) {
|
|
46
|
+
if (verdicts.includes("nominated"))
|
|
47
|
+
return EXIT_OK;
|
|
48
|
+
if (verdicts.includes("inconclusive"))
|
|
49
|
+
return EXIT_CANNOT_ANSWER;
|
|
50
|
+
return verdicts.length > 0 ? EXIT_BLOCKED : EXIT_OK;
|
|
51
|
+
}
|
|
52
|
+
export async function runEvolution(opts) {
|
|
53
|
+
const spec = opts.entry.spec;
|
|
54
|
+
const now = opts.now ?? (() => new Date());
|
|
55
|
+
const env = opts.env ?? process.env;
|
|
56
|
+
const caps = effectiveCaps(spec, opts.maxCandidates);
|
|
57
|
+
const reps = clampReps(opts.reps, spec.bench.stats.nReps);
|
|
58
|
+
if (opts.includeVal === true && spec.bench.type !== "opencode-fixture-scenarios") {
|
|
59
|
+
cannotAnswer(`run: --include-val is only supported by the opencode-fixture-scenarios bench — genome bench type is '${spec.bench.type}'`, "drop --include-val, or point --genome at a fixture-bench genome");
|
|
60
|
+
}
|
|
61
|
+
// ---- startup gate: kernel audit FIRST, before any state or spawn exists.
|
|
62
|
+
const audit = auditKernel(opts.entry, opts.configDir);
|
|
63
|
+
if (!audit.ok) {
|
|
64
|
+
const listed = audit.drifted.map((d) => `${d.path} (${d.kind})`);
|
|
65
|
+
const more = listed.length > 10 ? ` (+${String(listed.length - 10)} more)` : "";
|
|
66
|
+
blocked(`run refused: kernel drift for '${spec.label}' (${opts.entry.fingerprint}): ${listed.slice(0, 10).join(", ")}${more}`, "restore the sealed files or resolve drift via 'abathur kernel' — the run-loop never reseals");
|
|
67
|
+
}
|
|
68
|
+
if (opts.dryRun === true) {
|
|
69
|
+
const reqs = spec.requires ?? [];
|
|
70
|
+
const requiresProbed = reqs.length > 0 ? await probeRequiresOnly(spec, effectiveRepoPath(spec.repoPath)) : undefined;
|
|
71
|
+
return {
|
|
72
|
+
exitCode: EXIT_OK,
|
|
73
|
+
lines: planLines({
|
|
74
|
+
entry: opts.entry,
|
|
75
|
+
caps,
|
|
76
|
+
reps,
|
|
77
|
+
mutatorCommand: opts.mutatorCommand ?? null,
|
|
78
|
+
...(requiresProbed === undefined ? {} : { requiresProbed }),
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const mutatorCommand = opts.mutatorCommand;
|
|
83
|
+
if (mutatorCommand === undefined) {
|
|
84
|
+
cannotAnswer("run: --mutator <command template> is required — it is the only candidate source ({worktree} and {brief} placeholders)", "use --dry-run to inspect the full plan without a mutator");
|
|
85
|
+
}
|
|
86
|
+
const ledger = Ledger.open(effectiveRepoPath(spec.repoPath), { now });
|
|
87
|
+
const lease = acquireGenomeLock({
|
|
88
|
+
ledger,
|
|
89
|
+
configDir: opts.configDir,
|
|
90
|
+
genomeFp: fingerprint16(spec),
|
|
91
|
+
now,
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
return await evolve(opts, mutatorCommand, ledger, caps, reps, env, now);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
lease.release();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function evolve(opts, mutatorCommand, ledger, caps, reps, env, now) {
|
|
101
|
+
const spec = opts.entry.spec;
|
|
102
|
+
const lines = [];
|
|
103
|
+
const genomeRepo = effectiveRepoPath(spec.repoPath);
|
|
104
|
+
const selfMode = isEnvRepoLiteral(spec.repoPath);
|
|
105
|
+
const fric = opts.friction === undefined ? null : startRunFriction(opts.friction);
|
|
106
|
+
// Reap only under the genome lock: every remaining log entry belongs to a dead run.
|
|
107
|
+
const reap = reapOrphans(genomeRepo);
|
|
108
|
+
if (reap.reaped.length > 0 || reap.skipped.length > 0 || reap.malformed > 0) {
|
|
109
|
+
const killed = reap.reaped.filter((r) => r.alive).length;
|
|
110
|
+
lines.push(`reap: ${String(reap.reaped.length)} recorded group(s), ${String(killed)} alive killed` +
|
|
111
|
+
(reap.skipped.length > 0 ? `; skipped: ${reap.skipped.join("; ")}` : "") +
|
|
112
|
+
(reap.malformed > 0 ? `; ${String(reap.malformed)} malformed line(s) dropped` : ""));
|
|
113
|
+
}
|
|
114
|
+
const opened = await openGenome(genomeRepo, spec.bench.units.map((u) => u.path), { env });
|
|
115
|
+
const resume = readResume(ledger);
|
|
116
|
+
const genomeFp = fingerprint16(spec);
|
|
117
|
+
const invId = `a-${compactUtc(now())}-${genomeFp.slice(0, 8)}`;
|
|
118
|
+
const selfBenchBase = { genomeRepo, genomeFp, incumbentCommit: opened.headCommit, env };
|
|
119
|
+
const tracker = new ChildTracker(genomeRepo);
|
|
120
|
+
const sandboxBase = path.join(opts.sandboxRoot ?? path.join(opts.configDir, "bench-sandboxes"), invId);
|
|
121
|
+
const configEnv = env;
|
|
122
|
+
let counters = resume.counters;
|
|
123
|
+
// ---- incumbent baseline (resume identity: source + headCommit)
|
|
124
|
+
const storedInc = resume.incumbentByHead.get(opened.headCommit);
|
|
125
|
+
let incUnits;
|
|
126
|
+
if (storedInc !== undefined) {
|
|
127
|
+
incUnits = storedInc.units;
|
|
128
|
+
fric?.noteUnits(incUnits);
|
|
129
|
+
lines.push(`resume: incumbent baseline @ ${opened.headCommit.slice(0, 8)} already benched — reusing ${String(incUnits.length)} unit rows`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const gen = genId(fingerprint({ incumbent: opened.headCommit, at: now().getTime() }), now());
|
|
133
|
+
const out = await benchTarget({
|
|
134
|
+
spec,
|
|
135
|
+
genId: gen,
|
|
136
|
+
reps,
|
|
137
|
+
caps,
|
|
138
|
+
countersBefore: counters,
|
|
139
|
+
sandboxRoot: path.join(sandboxBase, "incumbent"),
|
|
140
|
+
source: "incumbent",
|
|
141
|
+
tracker,
|
|
142
|
+
configDir: opts.configDir,
|
|
143
|
+
...(opts.includeVal === true ? { includeVal: true } : {}),
|
|
144
|
+
...(configEnv === undefined ? {} : { env: configEnv }),
|
|
145
|
+
...(selfMode ? { selfBench: { ...selfBenchBase, candidateCommit: null } } : {}),
|
|
146
|
+
});
|
|
147
|
+
counters = addCounters(counters, out.spent);
|
|
148
|
+
fric?.noteBench(out);
|
|
149
|
+
const data = {
|
|
150
|
+
source: "incumbent",
|
|
151
|
+
headCommit: opened.headCommit,
|
|
152
|
+
complete: out.complete,
|
|
153
|
+
reps,
|
|
154
|
+
units: out.units.map(cloneMatrixRow),
|
|
155
|
+
counters: out.spent,
|
|
156
|
+
manifest: out.manifest.map((m) => ({ glob: m.glob, path: m.path, sha256: m.sha256 })),
|
|
157
|
+
benchProvenance: copyProvenance(out.provenance),
|
|
158
|
+
...(opened.dirtyWorktree.length === 0 ? {} : { dirtyWorktree: opened.dirtyWorktree.map((d) => ({ xy: d.xy, file: d.file })) }),
|
|
159
|
+
};
|
|
160
|
+
ledger.append({ kind: LEDGER_KIND_GENERATION_COMPLETE, genId: gen, runId: invId, data });
|
|
161
|
+
incUnits = out.units;
|
|
162
|
+
lines.push(`incumbent baseline: ${String(out.units.length)} units x ${String(reps)} reps${out.complete ? "" : " (BUDGET-TRUNCATED)"}`);
|
|
163
|
+
}
|
|
164
|
+
// ---- candidate generation session
|
|
165
|
+
const remaining = caps.maxCandidates - counters.candidates;
|
|
166
|
+
const considered = [];
|
|
167
|
+
if (remaining <= 0) {
|
|
168
|
+
lines.push(`budget: candidate cap ${String(caps.maxCandidates)} already spent — resume reports stored verdicts, no new candidates`);
|
|
169
|
+
for (const row of resume.candidatesByTree.values()) {
|
|
170
|
+
if (row.headCommit !== opened.headCommit)
|
|
171
|
+
continue; // verdicts for other heads are history, not this run's outcome
|
|
172
|
+
if (row.verdict !== undefined)
|
|
173
|
+
considered.push(row.verdict);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const evidence = spec.bench.units.map((unit) => {
|
|
178
|
+
const row = incUnits.find((u) => u.unitId === unit.id);
|
|
179
|
+
return { unit, scores: row?.scores ?? [], failures: row?.failures ?? [] };
|
|
180
|
+
});
|
|
181
|
+
const brief = buildBrief(spec, evidence, counters);
|
|
182
|
+
tracker.phase(`mutator-${invId}`, "mutator-session");
|
|
183
|
+
const session = await runMutatorSession({
|
|
184
|
+
spec: selfMode ? { ...spec, repoPath: genomeRepo } : spec,
|
|
185
|
+
brief,
|
|
186
|
+
mutatorCommand,
|
|
187
|
+
...(opts.opencodeBin === undefined ? {} : { opencodeBin: opts.opencodeBin }),
|
|
188
|
+
env,
|
|
189
|
+
maxCandidates: remaining,
|
|
190
|
+
ledger,
|
|
191
|
+
now,
|
|
192
|
+
onChild: tracker.onChild,
|
|
193
|
+
});
|
|
194
|
+
fric?.noteSession(session.rejected, session.applied.length);
|
|
195
|
+
for (const r of session.rejected)
|
|
196
|
+
lines.push(`candidate ${r.candidateId} rejected (${r.stage}): ${r.reason}`);
|
|
197
|
+
if (session.applied.length === 0 && session.rejected.length === 0)
|
|
198
|
+
lines.push("mutator session produced no candidates");
|
|
199
|
+
const nPairs = Math.max(1, session.applied.length);
|
|
200
|
+
// Trees this session re-delivered; anything recorded earlier against this
|
|
201
|
+
// head that the slice could NOT re-deliver (remaining < batch size on a
|
|
202
|
+
// resume) still counts for the exit status — the ledger verdict stands.
|
|
203
|
+
const reported = new Set();
|
|
204
|
+
for (const c of session.applied) {
|
|
205
|
+
const prior = resume.candidatesByTree.get(c.treeSha);
|
|
206
|
+
if (prior !== undefined) {
|
|
207
|
+
const verdict = prior.verdict ?? "indeterminate";
|
|
208
|
+
considered.push(verdict);
|
|
209
|
+
reported.add(c.treeSha);
|
|
210
|
+
lines.push(`candidate ${c.candidateId}: tree ${c.treeSha.slice(0, 12)} already in ledger — resume reuses verdict '${verdict}', no re-bench, no double spend`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const out = await benchTarget({
|
|
214
|
+
spec: { ...spec, repoPath: c.worktreePath },
|
|
215
|
+
genId: c.genId,
|
|
216
|
+
reps,
|
|
217
|
+
caps,
|
|
218
|
+
countersBefore: counters,
|
|
219
|
+
sandboxRoot: path.join(sandboxBase, c.genId),
|
|
220
|
+
source: "candidate",
|
|
221
|
+
tracker,
|
|
222
|
+
configDir: opts.configDir,
|
|
223
|
+
...(opts.includeVal === true ? { includeVal: true } : {}),
|
|
224
|
+
...(configEnv === undefined ? {} : { env: configEnv }),
|
|
225
|
+
...(selfMode ? { selfBench: { ...selfBenchBase, candidateCommit: c.commitSha } } : {}),
|
|
226
|
+
});
|
|
227
|
+
counters = addCounters(counters, out.spent);
|
|
228
|
+
fric?.noteBench(out);
|
|
229
|
+
// The candidate's own slot is already committed by the session clamp; it
|
|
230
|
+
// must not self-trip the cap inside evaluate (that would make the Nth
|
|
231
|
+
// candidate of a full-budget run permanently inconclusive).
|
|
232
|
+
const evalCounters = { ...counters, candidates: counters.candidates - out.spent.candidates };
|
|
233
|
+
const verdict = evaluate({
|
|
234
|
+
candidate: { runId: invId, units: asReplicates(out.units), counters: evalCounters },
|
|
235
|
+
incumbent: { units: asReplicates(incUnits) },
|
|
236
|
+
stats: spec.bench.stats,
|
|
237
|
+
budgetCaps: caps,
|
|
238
|
+
nPairs,
|
|
239
|
+
});
|
|
240
|
+
const guarded = selfMode ? selfGuardVerdict(out.failures, verdict.verdict) : verdict.verdict;
|
|
241
|
+
const finalVerdict = guarded;
|
|
242
|
+
const finalExit = guarded === verdict.verdict ? verdict.exitCode : needsExit(guarded);
|
|
243
|
+
fric?.noteCandidate(c.candidateId, verdict.failures, guarded === verdict.verdict ? null : `${verdict.verdict} -> ${guarded}`);
|
|
244
|
+
considered.push(finalVerdict);
|
|
245
|
+
const data = {
|
|
246
|
+
source: "candidate",
|
|
247
|
+
candidateId: c.candidateId,
|
|
248
|
+
rationale: c.rationale,
|
|
249
|
+
headCommit: opened.headCommit,
|
|
250
|
+
commitSha: c.commitSha,
|
|
251
|
+
treeSha: c.treeSha,
|
|
252
|
+
complete: out.complete,
|
|
253
|
+
reps,
|
|
254
|
+
units: out.units.map(cloneMatrixRow),
|
|
255
|
+
counters: out.spent,
|
|
256
|
+
manifest: out.manifest.map((m) => ({ glob: m.glob, path: m.path, sha256: m.sha256 })),
|
|
257
|
+
verdict: finalVerdict,
|
|
258
|
+
exitCode: finalExit,
|
|
259
|
+
gain: Number.isFinite(verdict.gain) ? verdict.gain : null,
|
|
260
|
+
gateFailures: [...verdict.failures],
|
|
261
|
+
benchProvenance: copyProvenance(out.provenance),
|
|
262
|
+
};
|
|
263
|
+
ledger.append({ kind: LEDGER_KIND_GENERATION_COMPLETE, genId: c.genId, runId: invId, data });
|
|
264
|
+
reported.add(c.treeSha);
|
|
265
|
+
const gain = verdict.gain === null ? "n/a (truncated)" : verdict.gain.toFixed(4);
|
|
266
|
+
lines.push(`candidate ${c.candidateId} [tree ${c.treeSha.slice(0, 12)}]: ${finalVerdict} (gain ${gain}, reps ${String(reps)}${out.complete ? "" : ", BUDGET-TRUNCATED"})`);
|
|
267
|
+
for (const f of verdict.failures)
|
|
268
|
+
lines.push(` gate: ${f}`);
|
|
269
|
+
}
|
|
270
|
+
for (const [tree, row] of resume.candidatesByTree) {
|
|
271
|
+
if (reported.has(tree) || row.headCommit !== opened.headCommit || row.verdict === undefined)
|
|
272
|
+
continue;
|
|
273
|
+
considered.push(row.verdict);
|
|
274
|
+
lines.push(`candidate ${row.candidateId ?? tree.slice(0, 12)}: recorded earlier against this head — verdict '${row.verdict}' carried into this run's exit status`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
await tracker.drain();
|
|
278
|
+
lines.push(`budget spent: candidates=${String(counters.candidates)} tokens=${String(counters.tokens)} wallS=${String(counters.wallS)} (caps candidates=${String(caps.maxCandidates)} tokens=${String(caps.maxTokens)} wallS=${String(caps.maxWallS)})`);
|
|
279
|
+
const exit = runExitCode(considered);
|
|
280
|
+
fric?.emit({ genomeFp, runId: invId, exit, considered, orphanGroups: reap.reaped.length });
|
|
281
|
+
return { exitCode: exit, lines };
|
|
282
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// --dry-run plan rendering + budget-cap resolution (todo 9). Presentation of
|
|
2
|
+
// the resolved startup state only — the dry-run gate in run-loop.ts calls this
|
|
3
|
+
// AFTER the kernel audit and BEFORE any ledger/lock/spawn touch.
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { peekPlanState } from "./run-rows.js";
|
|
6
|
+
/** `--max-candidates` clamps DOWN to the genome's own cap, never above. */
|
|
7
|
+
export function effectiveCaps(spec, clamp) {
|
|
8
|
+
const maxCandidates = Math.min(clamp ?? spec.budget.maxCandidates, spec.budget.maxCandidates);
|
|
9
|
+
return {
|
|
10
|
+
maxCandidates,
|
|
11
|
+
maxModelCalls: spec.budget.maxModelCalls,
|
|
12
|
+
maxTokens: spec.budget.maxTokens,
|
|
13
|
+
maxWallS: spec.budget.maxWallS,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function planLines(req) {
|
|
17
|
+
const spec = req.entry.spec;
|
|
18
|
+
const peek = peekPlanState(spec.repoPath);
|
|
19
|
+
const specMax = spec.budget.maxCandidates;
|
|
20
|
+
const unitList = spec.bench.units.map((u) => `${u.id}(${u.split})`).join(" ");
|
|
21
|
+
return [
|
|
22
|
+
`run plan — genome '${spec.label}' (${req.entry.fingerprint})`,
|
|
23
|
+
` repo: ${path.resolve(spec.repoPath)}`,
|
|
24
|
+
` bench: ${spec.bench.type} — timeoutS=${String(spec.bench.timeoutS)} stats minEffect=${String(spec.bench.stats.minEffect)} halfWidth=${String(spec.bench.stats.halfWidth)}`,
|
|
25
|
+
` units: ${unitList}`,
|
|
26
|
+
...(req.requiresProbed === undefined
|
|
27
|
+
? []
|
|
28
|
+
: [` requires probes: ${String(req.requiresProbed)}/${String(req.requiresProbed)} OK (prereq argv spawned; engine/mutator never spawned in dry-run)`]),
|
|
29
|
+
` reps: ${String(req.reps)}`,
|
|
30
|
+
` budget caps: maxCandidates=${String(req.caps.maxCandidates)}${req.caps.maxCandidates < specMax ? ` (clamped from ${String(specMax)})` : ""} maxModelCalls=${String(req.caps.maxModelCalls)} maxTokens=${String(req.caps.maxTokens)} maxWallS=${String(req.caps.maxWallS)}`,
|
|
31
|
+
peek.lastCompleteGenId === null
|
|
32
|
+
? " resume: no completed generation — full run"
|
|
33
|
+
: ` resume: continue after completed generation ${peek.lastCompleteGenId} (${String(peek.rowCount)} generation row(s) already recorded)`,
|
|
34
|
+
` resume: incumbent bench: ${peek.incumbentHeads.length > 0 ? `present (${peek.incumbentHeads.map((h) => h.slice(0, 8)).join(", ")})` : "missing (baseline will be benched)"}`,
|
|
35
|
+
` resume: spent candidates=${String(peek.counters.candidates)} modelCalls=${String(peek.counters.modelCalls)} tokens=${String(peek.counters.tokens)} wallS=${String(peek.counters.wallS)}`,
|
|
36
|
+
` candidate source: ${req.mutatorCommand ?? "NOT CONFIGURED — a real run requires --mutator <template> (exit 2 before any spawn)"}`,
|
|
37
|
+
" kernel audit: clean (dry-run mutates nothing: no ledger touch, no lock, no reap, no spawns)",
|
|
38
|
+
];
|
|
39
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Generation-row persistence for the run-loop (todo 9): the zod shape of
|
|
2
|
+
// `kind === "generation_complete"` ledger data + resume readers. The ledger
|
|
3
|
+
// format itself belongs to todo 2 and is UNCHANGED.
|
|
4
|
+
//
|
|
5
|
+
// Row accounting rules (exactly-once resume):
|
|
6
|
+
// - counters on a row are THAT ROW's spend; the run's cumulative budget state is
|
|
7
|
+
// the sum over all generation rows, so a resume never double-counts and a
|
|
8
|
+
// crashed candidate whose row never committed re-benches with fresh samples;
|
|
9
|
+
// - candidates are deduplicated by content tree sha (stable across reseals,
|
|
10
|
+
// unlike commit shas), the incumbent by headCommit + source;
|
|
11
|
+
// - budget-truncated benches persist partial evidence (complete:false) — spend
|
|
12
|
+
// already incurred must stay booked or a resume could re-spend it.
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { cannotAnswer } from "../../exit.js";
|
|
16
|
+
import { LEDGER_KIND_GENERATION_COMPLETE, Ledger, ledgerPath, ledgerRecordSchema } from "../ledger.js";
|
|
17
|
+
export const unitMatrixRowSchema = z.strictObject({
|
|
18
|
+
unitId: z.string().min(1),
|
|
19
|
+
split: z.enum(["train", "val"]),
|
|
20
|
+
scores: z.array(z.number()),
|
|
21
|
+
runIds: z.array(z.string()),
|
|
22
|
+
failures: z.array(z.string()),
|
|
23
|
+
});
|
|
24
|
+
const countersSchema = z.strictObject({
|
|
25
|
+
candidates: z.number(),
|
|
26
|
+
modelCalls: z.number(),
|
|
27
|
+
tokens: z.number(),
|
|
28
|
+
wallS: z.number(),
|
|
29
|
+
});
|
|
30
|
+
export const VERDICTS = ["nominated", "culled", "indeterminate", "inconclusive"];
|
|
31
|
+
export const generationRowDataSchema = z
|
|
32
|
+
.strictObject({
|
|
33
|
+
source: z.enum(["candidate", "incumbent"]),
|
|
34
|
+
candidateId: z.string().min(1).optional(),
|
|
35
|
+
rationale: z.string().optional(),
|
|
36
|
+
/** Incumbent HEAD this generation was forked from (resume identity for incumbent rows). */
|
|
37
|
+
headCommit: z.string().min(1),
|
|
38
|
+
commitSha: z.string().optional(),
|
|
39
|
+
/** Content tree sha — the stable dedup key for candidate generations. */
|
|
40
|
+
treeSha: z.string().optional(),
|
|
41
|
+
complete: z.boolean(),
|
|
42
|
+
reps: z.number().int().positive(),
|
|
43
|
+
units: z.array(unitMatrixRowSchema),
|
|
44
|
+
counters: countersSchema,
|
|
45
|
+
manifest: z.array(z.strictObject({ glob: z.string(), path: z.string(), sha256: z.string() })),
|
|
46
|
+
verdict: z.enum(VERDICTS).optional(),
|
|
47
|
+
exitCode: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
|
|
48
|
+
gain: z.number().nullable().optional(),
|
|
49
|
+
gateFailures: z.array(z.string()).optional(),
|
|
50
|
+
/** Unrelated worktree dirtiness notices (todo 3 contract: todo 9 books them). */
|
|
51
|
+
dirtyWorktree: z.array(z.strictObject({ xy: z.string(), file: z.string() })).optional(),
|
|
52
|
+
benchProvenance: z.strictObject({
|
|
53
|
+
benchType: z.enum(["toy", "opencode-fixture-scenarios"]),
|
|
54
|
+
versions: z.array(z.strictObject({ bin: z.string().min(1), version: z.string() })),
|
|
55
|
+
}),
|
|
56
|
+
})
|
|
57
|
+
.superRefine((data, ctx) => {
|
|
58
|
+
if (data.source === "candidate" && (data.candidateId === undefined || data.treeSha === undefined || data.commitSha === undefined)) {
|
|
59
|
+
ctx.addIssue({ code: "custom", message: "candidate rows require candidateId, commitSha and treeSha" });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
export function decodeGenerationRecord(record) {
|
|
63
|
+
const result = generationRowDataSchema.safeParse(record.data);
|
|
64
|
+
if (!result.success) {
|
|
65
|
+
cannotAnswer(`ledger: generation_complete row at ${record.ts} (${String(record.genId ?? "?")}) is unreadable: ${result.error.issues[0]?.message ?? "schema"}`, "the ledger is append-only — restore it from backup rather than editing rows");
|
|
66
|
+
}
|
|
67
|
+
return result.data;
|
|
68
|
+
}
|
|
69
|
+
export function readResume(ledger) {
|
|
70
|
+
const incumbentByHead = new Map();
|
|
71
|
+
const candidatesByTree = new Map();
|
|
72
|
+
let counters = { candidates: 0, modelCalls: 0, tokens: 0, wallS: 0 };
|
|
73
|
+
let rowCount = 0;
|
|
74
|
+
for (const record of ledger.readAll()) {
|
|
75
|
+
if (record.kind !== LEDGER_KIND_GENERATION_COMPLETE)
|
|
76
|
+
continue;
|
|
77
|
+
const data = decodeGenerationRecord(record);
|
|
78
|
+
rowCount += 1;
|
|
79
|
+
counters = addCounters(counters, data.counters);
|
|
80
|
+
if (data.source === "incumbent")
|
|
81
|
+
incumbentByHead.set(data.headCommit, data);
|
|
82
|
+
else if (data.treeSha !== undefined)
|
|
83
|
+
candidatesByTree.set(data.treeSha, data);
|
|
84
|
+
}
|
|
85
|
+
return { incumbentByHead, candidatesByTree, counters, rowCount };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Read-only ledger peek for --dry-run: NEVER Ledger.open (that would mkdir
|
|
89
|
+
* .state — dry-run must leave the repo byte-untouched). A corrupt line is a
|
|
90
|
+
* clean exit 2, matching the real run's integrity stance.
|
|
91
|
+
*/
|
|
92
|
+
export function peekPlanState(genomeRepo) {
|
|
93
|
+
let text;
|
|
94
|
+
try {
|
|
95
|
+
text = readFileSync(ledgerPath(genomeRepo), "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { rowCount: 0, counters: emptyCounters(), incumbentHeads: [], candidateCount: 0, lastCompleteGenId: null };
|
|
99
|
+
}
|
|
100
|
+
const incumbentHeads = [];
|
|
101
|
+
let counters = emptyCounters();
|
|
102
|
+
let rowCount = 0;
|
|
103
|
+
let candidateCount = 0;
|
|
104
|
+
let lastCompleteGenId = null;
|
|
105
|
+
const lines = text.split("\n").filter((line) => line.length > 0);
|
|
106
|
+
lines.forEach((line, index) => {
|
|
107
|
+
let record;
|
|
108
|
+
try {
|
|
109
|
+
record = JSON.parse(line);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
cannotAnswer(`run --dry-run: ledger line ${String(index + 1)} in ${ledgerPath(genomeRepo)} is not valid JSON`);
|
|
113
|
+
}
|
|
114
|
+
const parsed = ledgerRecordSchema.safeParse(record);
|
|
115
|
+
if (!parsed.success) {
|
|
116
|
+
cannotAnswer(`run --dry-run: ledger line ${String(index + 1)} in ${ledgerPath(genomeRepo)} failed schema validation`);
|
|
117
|
+
}
|
|
118
|
+
if (parsed.data.kind !== LEDGER_KIND_GENERATION_COMPLETE)
|
|
119
|
+
return;
|
|
120
|
+
const data = decodeGenerationRecord(parsed.data);
|
|
121
|
+
rowCount += 1;
|
|
122
|
+
counters = addCounters(counters, data.counters);
|
|
123
|
+
if (data.source === "incumbent")
|
|
124
|
+
incumbentHeads.push(data.headCommit);
|
|
125
|
+
else
|
|
126
|
+
candidateCount += 1;
|
|
127
|
+
if (parsed.data.genId !== undefined)
|
|
128
|
+
lastCompleteGenId = parsed.data.genId;
|
|
129
|
+
});
|
|
130
|
+
return { rowCount, counters, incumbentHeads, candidateCount, lastCompleteGenId };
|
|
131
|
+
}
|
|
132
|
+
export function emptyCounters() {
|
|
133
|
+
return { candidates: 0, modelCalls: 0, tokens: 0, wallS: 0 };
|
|
134
|
+
}
|
|
135
|
+
export function addCounters(a, b) {
|
|
136
|
+
return {
|
|
137
|
+
candidates: a.candidates + b.candidates,
|
|
138
|
+
modelCalls: a.modelCalls + b.modelCalls,
|
|
139
|
+
tokens: a.tokens + b.tokens,
|
|
140
|
+
wallS: round3(a.wallS + b.wallS),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export function round3(x) {
|
|
144
|
+
return Math.round(x * 1000) / 1000;
|
|
145
|
+
}
|