@tachikomagundam/abathur 0.2.5 → 0.2.7
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/README.md +3 -3
- package/dist/cli.js +4 -0
- package/dist/commands/genome.js +2 -2
- package/dist/commands/graft.js +1 -1
- package/dist/commands/readjudicate.js +28 -0
- package/dist/commands/retract.js +53 -0
- package/dist/commands/run.js +1 -1
- package/dist/commands/self-eval.js +1 -1
- package/dist/commands/tombstone.js +30 -16
- package/dist/core/evolve/run-loop.js +14 -0
- package/dist/core/evolve/run-rows.js +14 -0
- package/dist/core/evolve/score-bank.js +214 -0
- package/dist/core/graft-rebench.js +3 -0
- package/dist/core/ledger.js +8 -1
- package/dist/core/promote.js +63 -47
- package/dist/core/readjudicate.js +247 -0
- package/dist/core/retract.js +166 -0
- package/dist/core/stats-math.js +39 -0
- package/dist/core/stats.js +93 -19
- package/dist/core/worktree.js +1 -1
- package/dist/genomes/toy-smoke/init.mjs +1 -1
- package/dist/test/bundle.test.js +1 -1
- package/dist/test/fixture-loop.test.js +1 -1
- package/dist/test/fixtures-self.js +1 -1
- package/dist/test/fixtures-wt.js +1 -1
- package/dist/test/friction.test.js +3 -3
- package/dist/test/graft.test.js +1 -1
- package/dist/test/historian-grader-integrity.test.js +509 -2
- package/dist/test/promote.test.js +222 -5
- package/dist/test/readjudicate.test.js +212 -0
- package/dist/test/reflect.test.js +1 -1
- package/dist/test/repopath-seams.test.js +55 -0
- package/dist/test/score-bank.test.js +235 -0
- package/dist/test/seam-gates.test.js +84 -0
- package/dist/test/self-snapshot.test.js +2 -2
- package/dist/test/stats-acceptance.test.js +329 -0
- package/graders/historian/grader-core.d.mts +27 -0
- package/graders/historian/grader-core.mjs +311 -19
- package/graders/historian/grader.mjs +5 -5
- package/graders/historian/judge-poststage-19.mjs +212 -0
- package/graders/historian/run-scenario-19.sh +43 -0
- package/graders/historian/run-scenario.sh +16 -0
- package/package.json +1 -1
- package/plugin/abathur-command.md +3 -3
- package/plugin/abathur.ts +6 -6
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ instance learn from another's evidence; nothing in v1 talks to a network.
|
|
|
30
30
|
`peerClaim` metadata and never feed nomination math.
|
|
31
31
|
- **run loop** — incumbent → brief (failure observations) → mutator session →
|
|
32
32
|
candidate diffs → seal → re-bench → verdicts. Nominated generations sit in
|
|
33
|
-
the quarantine queue until a human runs `promote`; `tombstone` buries them.
|
|
33
|
+
the quarantine queue until a human runs `promote`; `tombstone` buries them; `retract` undoes a promote (LIFO, descendant-guarded).
|
|
34
34
|
Both are append-only ledger decisions; nothing is ever deleted.
|
|
35
35
|
|
|
36
36
|
### Install
|
|
@@ -289,7 +289,7 @@ table are [docs/federation.md](docs/federation.md).
|
|
|
289
289
|
same historian genome against one wiki will clobber each other mid-run.
|
|
290
290
|
Cross-machine serialization of historian benches is the operator's
|
|
291
291
|
responsibility (schedule, lockbox, one-wiki-one-bench rule — your call).
|
|
292
|
-
- Promotion and
|
|
292
|
+
- Promotion, burial and retraction are yours alone: `promote`/`tombstone`/`retract` carry no
|
|
293
293
|
`--confirm` because the CLI invocation **is** the gate; there is no
|
|
294
294
|
`--force` anywhere in the binary. Review `abathur status` quarantine depth
|
|
295
295
|
and the `graft decisions` rows before promoting.
|
|
@@ -476,7 +476,7 @@ opencode 会在启动时自行从 npm 把包下载到它自己的缓存
|
|
|
476
476
|
`self-eval`、`kernel`、`--help`),120 秒超时、64 KB 输出封顶。二进制从
|
|
477
477
|
`PATH` 解析,除非用 `ABATHUR_BIN` 覆盖。诚实的权限说明:该工具具备与
|
|
478
478
|
bash 等同的权限——`run` 与 `genome` 按设计就会 spawn 变异器/引擎二进制——
|
|
479
|
-
所以允许清单限制的是笔误与体验,而非能力。`promote` 与 `
|
|
479
|
+
所以允许清单限制的是笔误与体验,而非能力。`promote`、`tombstone` 与 `retract` 刻意
|
|
480
480
|
不可经由工具触达:它们是人类闸门,必须在终端里运行。
|
|
481
481
|
- 用户命令 **`/abathur <参数…>`**——斜杠命令,指示智能体把参数
|
|
482
482
|
翻译成工具调用并回报退出码。路线 A 由复制的 `commands/abathur.md`
|
package/dist/cli.js
CHANGED
|
@@ -12,10 +12,12 @@ import { graftCommand } from "./commands/graft.js";
|
|
|
12
12
|
import { kernelCommand } from "./commands/kernel.js";
|
|
13
13
|
import { opencodeCommand } from "./commands/opencode.js";
|
|
14
14
|
import { promoteCommand } from "./commands/promote.js";
|
|
15
|
+
import { readjudicateCommand } from "./commands/readjudicate.js";
|
|
15
16
|
import { runCommand } from "./commands/run.js";
|
|
16
17
|
import { selfEvalCommand } from "./commands/self-eval.js";
|
|
17
18
|
import { statusCommand } from "./commands/status.js";
|
|
18
19
|
import { tombstoneCommand } from "./commands/tombstone.js";
|
|
20
|
+
import { retractCommand } from "./commands/retract.js";
|
|
19
21
|
import { ConfigError, loadConfig } from "./config.js";
|
|
20
22
|
import { EXIT_CANNOT_ANSWER, EXIT_OK, ExitSignal, cannotAnswer, renderExitSignal, } from "./exit.js";
|
|
21
23
|
// Router order mirrors the plan; summaries appear verbatim in --help.
|
|
@@ -24,7 +26,9 @@ export const COMMANDS = [
|
|
|
24
26
|
runCommand,
|
|
25
27
|
statusCommand,
|
|
26
28
|
promoteCommand,
|
|
29
|
+
readjudicateCommand,
|
|
27
30
|
tombstoneCommand,
|
|
31
|
+
retractCommand,
|
|
28
32
|
bundleCommand,
|
|
29
33
|
graftCommand,
|
|
30
34
|
selfEvalCommand,
|
package/dist/commands/genome.js
CHANGED
|
@@ -53,7 +53,7 @@ function genomeSeals(configDir, label) {
|
|
|
53
53
|
for (const warning of scan.warnings)
|
|
54
54
|
writeStdout(`warning: ${warning}`);
|
|
55
55
|
for (const entry of scan.entries) {
|
|
56
|
-
writeStdout(`# effective seal globs for '${entry.label}' (${entry.fingerprint}) under ${entry.spec.repoPath}`);
|
|
56
|
+
writeStdout(`# effective seal globs for '${entry.label}' (${entry.fingerprint}) under ${entry.spec.repoPath}`); // raw-repoPath: display string only; no FS access
|
|
57
57
|
for (const glob of entry.spec.kernel.immutableGlobs)
|
|
58
58
|
writeStdout(glob);
|
|
59
59
|
}
|
|
@@ -61,7 +61,7 @@ function genomeSeals(configDir, label) {
|
|
|
61
61
|
}
|
|
62
62
|
function genomeRm(configDir, label) {
|
|
63
63
|
const entry = resolveUniqueEntry(configDir, label, "genome rm");
|
|
64
|
-
const file = ledgerPath(entry.spec.repoPath);
|
|
64
|
+
const file = ledgerPath(entry.spec.repoPath); // raw-repoPath: ledgerPath() resolves env literals at the FS seam (ledger.ts)
|
|
65
65
|
if (existsSync(file) && readFileSync(file, "utf8").trim().length > 0) {
|
|
66
66
|
blocked(`genome rm refused: '${entry.label}' (${entry.fingerprint}) has ledger history — archive instead`, `lineage lives in ${file}; unregistering a lived genome would orphan its evidence. Keep it registered or move the repo aside.`);
|
|
67
67
|
}
|
package/dist/commands/graft.js
CHANGED
|
@@ -43,7 +43,7 @@ function entryForLabel(configDir, label) {
|
|
|
43
43
|
const matches = readRegistry(configDir).entries.filter((entry) => entry.label === label);
|
|
44
44
|
if (matches.length > 1) {
|
|
45
45
|
cannotAnswer(`graft: label '${label}' is ambiguous — ${String(matches.length)} genomes share it: ${matches
|
|
46
|
-
.map((e) => `${e.fingerprint} @ ${e.spec.repoPath}`)
|
|
46
|
+
.map((e) => `${e.fingerprint} @ ${e.spec.repoPath}`) // raw-repoPath: display string only; no FS access
|
|
47
47
|
.join(", ")}`, "give the specs distinct labels; the fingerprint is the identity");
|
|
48
48
|
}
|
|
49
49
|
return matches[0] ?? null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `abathur re-adjudicate <label> <genId>` — replay the CURRENT gate over an epoch's
|
|
2
|
+
// archived rows and append a corrected verdict row (with provenance). Re-judges, never
|
|
3
|
+
// flips by hand; promote continues to read the LAST row. See core/readjudicate.ts for
|
|
4
|
+
// the replay contract (counters minus own slot, bank excludes this candidate's trees).
|
|
5
|
+
import { resolveConfigDir } from "../config.js";
|
|
6
|
+
import { EXIT_OK, EXIT_CANNOT_ANSWER, cannotAnswer } from "../exit.js";
|
|
7
|
+
import { readjudicateGeneration } from "../core/readjudicate.js";
|
|
8
|
+
import { writeStdout } from "../out.js";
|
|
9
|
+
import { resolveUniqueEntry } from "./run.js";
|
|
10
|
+
const USAGE = "usage: abathur re-adjudicate <label> <genId>";
|
|
11
|
+
function runReadjudicate(args) {
|
|
12
|
+
const [label, genId] = args;
|
|
13
|
+
if (label === undefined || genId === undefined || args.length !== 2)
|
|
14
|
+
cannotAnswer(`re-adjudicate: expected exactly <label> <genId>`, USAGE);
|
|
15
|
+
const configDir = resolveConfigDir();
|
|
16
|
+
const entry = resolveUniqueEntry(configDir, label, "re-adjudicate");
|
|
17
|
+
const outcome = readjudicateGeneration({ entry, configDir, genId, now: () => new Date() });
|
|
18
|
+
for (const line of outcome.lines)
|
|
19
|
+
writeStdout(line);
|
|
20
|
+
// an inconclusive replay means the gate cannot answer — surface it in the exit code,
|
|
21
|
+
// matching the VERDICT_EXIT semantics of run/promote consumers.
|
|
22
|
+
return outcome.verdict === "inconclusive" ? EXIT_CANNOT_ANSWER : EXIT_OK;
|
|
23
|
+
}
|
|
24
|
+
export const readjudicateCommand = {
|
|
25
|
+
name: "re-adjudicate",
|
|
26
|
+
summary: "replay the current gate over a gen's archived rows; append corrected verdict + provenance (files stay immutable)",
|
|
27
|
+
run: ({ args }) => Promise.resolve(runReadjudicate(args)),
|
|
28
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `abathur retract <label> <genId> --reason <text>` — the human gate that
|
|
2
|
+
// undoes a promote (core in core/retract.ts, imported ONLY here — the
|
|
3
|
+
// import-policy pin lives in src/test/promote.test.ts). Terminal-only like
|
|
4
|
+
// promote/tombstone: the opencode plugin tool must never reach it. No --force:
|
|
5
|
+
// every refusal is a bug report, not a flag.
|
|
6
|
+
import { resolveConfigDir } from "../config.js";
|
|
7
|
+
import { EXIT_OK, cannotAnswer } from "../exit.js";
|
|
8
|
+
import { retractGeneration } from "../core/retract.js";
|
|
9
|
+
import { writeStdout } from "../out.js";
|
|
10
|
+
import { resolveUniqueEntry } from "./run.js";
|
|
11
|
+
const USAGE = "usage: abathur retract <label> <genId> --reason <text>";
|
|
12
|
+
const MAX_REASON_CHARS = 500;
|
|
13
|
+
async function runRetract(args) {
|
|
14
|
+
const positional = [];
|
|
15
|
+
let reason = null;
|
|
16
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
17
|
+
const arg = args[i];
|
|
18
|
+
if (arg === "--reason") {
|
|
19
|
+
const value = args[(i += 1)];
|
|
20
|
+
if (value === undefined || value.startsWith("--"))
|
|
21
|
+
cannotAnswer("retract: --reason requires a value", USAGE);
|
|
22
|
+
if (reason !== null)
|
|
23
|
+
cannotAnswer("retract: --reason given twice", USAGE);
|
|
24
|
+
reason = value;
|
|
25
|
+
}
|
|
26
|
+
else if (arg.startsWith("--")) {
|
|
27
|
+
cannotAnswer(`retract: unknown flag ${arg}`, USAGE);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
positional.push(arg);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const [label, genId] = positional;
|
|
34
|
+
if (label === undefined || genId === undefined || positional.length !== 2) {
|
|
35
|
+
cannotAnswer("retract: expected exactly <label> <genId> --reason <text>", USAGE);
|
|
36
|
+
}
|
|
37
|
+
if (reason === null || reason.trim().length === 0) {
|
|
38
|
+
cannotAnswer("retract: --reason <text> is required (why is this promote being undone?)", USAGE);
|
|
39
|
+
}
|
|
40
|
+
if (reason.length > MAX_REASON_CHARS)
|
|
41
|
+
cannotAnswer(`retract: --reason exceeds ${String(MAX_REASON_CHARS)} chars`, USAGE);
|
|
42
|
+
const configDir = resolveConfigDir();
|
|
43
|
+
const entry = resolveUniqueEntry(configDir, label, "retract");
|
|
44
|
+
const outcome = await retractGeneration({ entry, configDir, genId, reason });
|
|
45
|
+
for (const line of outcome.lines)
|
|
46
|
+
writeStdout(line);
|
|
47
|
+
return EXIT_OK;
|
|
48
|
+
}
|
|
49
|
+
export const retractCommand = {
|
|
50
|
+
name: "retract",
|
|
51
|
+
summary: "human gate: undo a promote (LIFO, descendant-guarded; append-only row, ref deleted, manifest cleared)",
|
|
52
|
+
run: ({ args }) => runRetract(args),
|
|
53
|
+
};
|
package/dist/commands/run.js
CHANGED
|
@@ -69,7 +69,7 @@ export function resolveUniqueEntry(configDir, label, who = "run") {
|
|
|
69
69
|
const scan = requireGenomesByLabel(configDir, label);
|
|
70
70
|
if (scan.entries.length > 1) {
|
|
71
71
|
cannotAnswer(`${who}: label '${label}' is ambiguous — ${String(scan.entries.length)} genomes share it: ${scan.entries
|
|
72
|
-
.map((e) => `${e.fingerprint} @ ${e.spec.repoPath}`)
|
|
72
|
+
.map((e) => `${e.fingerprint} @ ${e.spec.repoPath}`) // raw-repoPath: display string only; no FS access
|
|
73
73
|
.join(", ")}`, "give the specs distinct labels; the fingerprint is the identity");
|
|
74
74
|
}
|
|
75
75
|
const entry = scan.entries[0];
|
|
@@ -110,7 +110,7 @@ async function selfEvalHandler(context) {
|
|
|
110
110
|
for (const warning of readRegistry(configDir).warnings)
|
|
111
111
|
writeStdout(`warning: ${warning}`);
|
|
112
112
|
const entry = resolveUniqueEntry(configDir, flags.label, "self-eval");
|
|
113
|
-
if (!isEnvRepoLiteral(entry.spec.repoPath)) {
|
|
113
|
+
if (!isEnvRepoLiteral(entry.spec.repoPath)) { // raw-repoPath: self-mode detection must inspect the STORED literal, not the resolved path
|
|
114
114
|
cannotAnswer(`self-eval: '${entry.spec.label}' is not a self genome — repoPath must be the \${ABATHUR_SELF_REPO} literal`, "self-eval grades the harness against its own sealed kernel; see genomes/abathur-self.jsonc");
|
|
115
115
|
}
|
|
116
116
|
const audit = auditKernel(entry, configDir);
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// row survive; the reason is stored verbatim (canonicalJson escapes control
|
|
4
4
|
// bytes) but NEVER echoed raw — the confirmation line is sanitized like todo-8's
|
|
5
5
|
// sealMessage so untrusted text cannot forge terminal output.
|
|
6
|
-
import
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { acquireGenomeLock, acquireLock, LEDGER_KIND_TOMBSTONE, Ledger } from "../core/ledger.js";
|
|
8
|
+
import { effectiveRepoPath } from "../core/spec.js";
|
|
7
9
|
import { resolveConfigDir } from "../config.js";
|
|
8
10
|
import { EXIT_OK, blocked, cannotAnswer } from "../exit.js";
|
|
9
11
|
import { writeStdout } from "../out.js";
|
|
10
12
|
import { resolveUniqueEntry } from "./run.js";
|
|
11
|
-
const LEDGER_KIND_TOMBSTONE = "tombstone";
|
|
12
13
|
const MAX_REASON_CHARS = 4000;
|
|
13
14
|
const USAGE = "usage: abathur tombstone <label> <genId> --reason <text>";
|
|
14
15
|
function echoed(reason) {
|
|
@@ -48,22 +49,35 @@ function runTombstone(args) {
|
|
|
48
49
|
const { label, genId, reason } = parseArgs(args);
|
|
49
50
|
const configDir = resolveConfigDir();
|
|
50
51
|
const entry = resolveUniqueEntry(configDir, label, "tombstone");
|
|
51
|
-
const ledger = Ledger.open(entry.spec.repoPath);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
const ledger = Ledger.open(effectiveRepoPath(entry.spec.repoPath));
|
|
53
|
+
// single-flight with the run loop and sibling gates (same postmortem as promote)
|
|
54
|
+
const lease = acquireGenomeLock({ ledger, configDir, genomeFp: entry.fingerprint });
|
|
55
|
+
try {
|
|
56
|
+
const repoLease = acquireLock({ configDir: path.join(effectiveRepoPath(entry.spec.repoPath), ".state", "abathur"), key: "gate", label: "gate lock" });
|
|
57
|
+
try {
|
|
58
|
+
const records = ledger.readAll();
|
|
59
|
+
if (!records.some((r) => r.kind === "generation_complete" && r.genId === genId)) {
|
|
60
|
+
blocked(`tombstone: no generation_complete ledger row for gen '${genId}' of '${label}' — nothing to bury`);
|
|
61
|
+
}
|
|
62
|
+
const promoted = records.find((r) => r.kind === "promote" && r.genId === genId);
|
|
63
|
+
if (promoted !== undefined) {
|
|
64
|
+
blocked(`tombstone: gen '${genId}' was already promoted at ${promoted.ts} — cull is for candidates the human never accepted`);
|
|
65
|
+
}
|
|
66
|
+
const buried = records.find((r) => r.kind === LEDGER_KIND_TOMBSTONE && r.genId === genId);
|
|
67
|
+
if (buried !== undefined) {
|
|
68
|
+
blocked(`tombstone: gen '${genId}' is already tombstoned at ${buried.ts}`);
|
|
69
|
+
}
|
|
70
|
+
ledger.append({ kind: LEDGER_KIND_TOMBSTONE, genId, data: { genId, reason, actor: "cli" } });
|
|
71
|
+
writeStdout(`tombstone recorded: '${genId}' (reason: ${echoed(reason)}) — commits and lineage preserved (cull ≠ delete)`);
|
|
72
|
+
return EXIT_OK;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
repoLease.release();
|
|
76
|
+
}
|
|
59
77
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
blocked(`tombstone: gen '${genId}' is already tombstoned at ${buried.ts}`);
|
|
78
|
+
finally {
|
|
79
|
+
lease.release();
|
|
63
80
|
}
|
|
64
|
-
ledger.append({ kind: LEDGER_KIND_TOMBSTONE, genId, data: { genId, reason, actor: "cli" } });
|
|
65
|
-
writeStdout(`tombstone recorded: '${genId}' (reason: ${echoed(reason)}) — commits and lineage preserved (cull ≠ delete)`);
|
|
66
|
-
return EXIT_OK;
|
|
67
81
|
}
|
|
68
82
|
export const tombstoneCommand = {
|
|
69
83
|
name: "tombstone",
|
|
@@ -38,6 +38,7 @@ import { buildBrief } from "./brief.js";
|
|
|
38
38
|
import { startRunFriction } from "./run-friction.js";
|
|
39
39
|
import { runMutatorSession } from "./reflect.js";
|
|
40
40
|
import { asReplicates, benchTarget, cloneMatrixRow, copyProvenance } from "./run-bench.js";
|
|
41
|
+
import { loadScoreBank } from "./score-bank.js";
|
|
41
42
|
import { selfGuardVerdict } from "./self-snapshot.js";
|
|
42
43
|
import { effectiveCaps, planLines } from "./run-plan.js";
|
|
43
44
|
import { addCounters, readResume, } from "./run-rows.js";
|
|
@@ -167,6 +168,18 @@ async function evolve(opts, mutatorCommand, ledger, caps, reps, env, now) {
|
|
|
167
168
|
incUnits = out.units;
|
|
168
169
|
lines.push(`incumbent baseline: ${String(out.units.length)} units x ${String(reps)} reps${out.complete ? "" : " (BUDGET-TRUNCATED)"}`);
|
|
169
170
|
}
|
|
171
|
+
// ---- score bank for acceptance-semantics gates (loaded after the incumbent
|
|
172
|
+
// row commit so this campaign's baseline reps price the bank — gate-time view;
|
|
173
|
+
// candidate rows land only after evaluate, never feeding their own verdict).
|
|
174
|
+
const scoreBank = loadScoreBank(genomeRepo);
|
|
175
|
+
if (scoreBank === null) {
|
|
176
|
+
lines.push("score bank: none — legacy cross-arm gate");
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
lines.push(`score bank: ${String(scoreBank.units.size)} unit(s), prior sigma ${scoreBank.priorSigma.toFixed(4)}, pooled df ${String(scoreBank.totalDf)}`);
|
|
180
|
+
for (const notice of scoreBank.notices)
|
|
181
|
+
lines.push(`score bank: ${notice}`);
|
|
182
|
+
}
|
|
170
183
|
// ---- candidate generation session
|
|
171
184
|
const remaining = caps.maxCandidates - counters.candidates;
|
|
172
185
|
const considered = [];
|
|
@@ -242,6 +255,7 @@ async function evolve(opts, mutatorCommand, ledger, caps, reps, env, now) {
|
|
|
242
255
|
stats: spec.bench.stats,
|
|
243
256
|
budgetCaps: caps,
|
|
244
257
|
nPairs,
|
|
258
|
+
...(scoreBank === null ? {} : { bank: scoreBank.units }),
|
|
245
259
|
});
|
|
246
260
|
const guarded = selfMode ? selfGuardVerdict(out.failures, verdict.verdict) : verdict.verdict;
|
|
247
261
|
const finalVerdict = guarded;
|
|
@@ -49,6 +49,20 @@ export const generationRowDataSchema = z
|
|
|
49
49
|
gateFailures: z.array(z.string()).optional(),
|
|
50
50
|
/** Unrelated worktree dirtiness notices (todo 3 contract: todo 9 books them). */
|
|
51
51
|
dirtyWorktree: z.array(z.strictObject({ xy: z.string(), file: z.string() })).optional(),
|
|
52
|
+
/** Present only on rows appended by `abathur re-adjudicate`: the corrected verdict with
|
|
53
|
+
* full replay provenance (source rows, bank state, replaced verdict). The original row
|
|
54
|
+
* stays in the files untouched — this field documents the amendment, never hides it. */
|
|
55
|
+
readjudication: z.strictObject({
|
|
56
|
+
at: z.string().min(1),
|
|
57
|
+
specFingerprint: z.string().min(1),
|
|
58
|
+
gate: z.enum(["legacy", "acceptance"]),
|
|
59
|
+
bank: z.strictObject({ units: z.number().int().nonnegative(), priorSigma: z.number(), pooledDf: z.number() }).nullable(),
|
|
60
|
+
sourceRows: z.strictObject({
|
|
61
|
+
candidate: z.strictObject({ file: z.string().min(1), ts: z.string().min(1) }),
|
|
62
|
+
incumbent: z.strictObject({ file: z.string().min(1), ts: z.string().min(1) }),
|
|
63
|
+
}),
|
|
64
|
+
replacedVerdict: z.string().min(1),
|
|
65
|
+
}).optional(),
|
|
52
66
|
benchProvenance: z.strictObject({
|
|
53
67
|
benchType: z.enum(["toy", "opencode-fixture-scenarios"]),
|
|
54
68
|
versions: z.array(z.strictObject({ bin: z.string().min(1), version: z.string() })),
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Historical per-unit variance bank for the acceptance-semantics gate
|
|
2
|
+
// (engine redesign 2026-09-24; the design doc + power analysis live in the
|
|
3
|
+
// target genome repo's evidence folder, not shipped here).
|
|
4
|
+
//
|
|
5
|
+
// WHAT IT IS: for every unit, σ pooled from the within-group replicate scatter
|
|
6
|
+
// of ALL generation_complete rows in <genomeRepo>/.state/abathur/ledger*.jsonl
|
|
7
|
+
// (live ledger + campaign archives + pilot). A group = one bench event on one
|
|
8
|
+
// arm (one row's scores array for that unit), so its mean is that doc's mean:
|
|
9
|
+
// pooling mean-removed sums of squares across doc lineages is the classical
|
|
10
|
+
// ANOVA pooled within-group variance — champion-doc changes (the campaign-3
|
|
11
|
+
// promote at 7ca80d7 moved the incumbent head 6ca3ff68→556e1350→062a9fdb→
|
|
12
|
+
// 8fe7e34a→de607dab) shift group MEANS, never this estimate. Pooling raw
|
|
13
|
+
// scores/means across lineages would poison the bank and is what the per-group
|
|
14
|
+
// mean removal exists to prevent.
|
|
15
|
+
//
|
|
16
|
+
// SHIELDING: σ² = (df·σ̂² + ν₀·σ²_glob)/(df+ν₀) with ν₀ = BANK_PRIOR_DF = 2 —
|
|
17
|
+
// [0.5, 0.5] is two reps that failed to observe noise, not proof of σ=0. Thin
|
|
18
|
+
// history (df < BANK_THIN_DF) additionally floors σ at BANK_QUANTUM = 0.125/√2:
|
|
19
|
+
// unit scores are fractions of checklist items on a ~1/8 grid, so two reps one
|
|
20
|
+
// flipped item apart differ by exactly 0.125 and a one-step-pair has sd
|
|
21
|
+
// 0.125/√2; a unit quieter than that has only been lucky, not measured.
|
|
22
|
+
// Proven-quiet units (df ≥ BANK_THIN_DF, e.g. the guards with 12–14 zero-SS
|
|
23
|
+
// df) keep their tight shrunk σ — earned across independent benches.
|
|
24
|
+
//
|
|
25
|
+
// READ-ONLY AND FAIL-CLOSED: never Ledger.open (that mkdirs .state — the same
|
|
26
|
+
// discipline as peekPlanState in run-rows.ts), never writes, never exits the
|
|
27
|
+
// process. A row that fails schema validation contributes nothing and only
|
|
28
|
+
// adds a notice (archives may legitimately predate schema evolutions). No
|
|
29
|
+
// files / no readable rows / zero pooled df ⇒ null ⇒ the caller must run the
|
|
30
|
+
// legacy cross-arm gate verbatim: the bank can only inform, absence can never
|
|
31
|
+
// invent a nomination or a cull.
|
|
32
|
+
//
|
|
33
|
+
// ACTIVATION REQUIRES A ROTATED ARCHIVE: with only the live ledger.jsonl
|
|
34
|
+
// present the bank stays null. A lone live file may be one campaign young
|
|
35
|
+
// (gate-time df=1 per unit — zero cross-doc information) or, worse, the fresh
|
|
36
|
+
// .state of any toy/fixture genome whose very first run would silently switch
|
|
37
|
+
// gates mid-test. Rotation (ledger.campaign<N>-*.jsonl) is the operator's own
|
|
38
|
+
// mark that a genome has earned lifetime history, so it is also the mark that
|
|
39
|
+
// entitles the acceptance gate to override the proven legacy default.
|
|
40
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { z } from "zod";
|
|
43
|
+
import { LEDGER_KIND_GENERATION_COMPLETE, ledgerPath, ledgerRecordSchema } from "../ledger.js";
|
|
44
|
+
import { generationRowDataSchema } from "./run-rows.js";
|
|
45
|
+
// ------------------------------------------------------------- bank epoch
|
|
46
|
+
// Lineage-restart quarantine (institutional addition 2026-09-25, born of the
|
|
47
|
+
// s19 pin era + the s16 churn window): an operator-ruled epoch start for a
|
|
48
|
+
// unit voids every bank group whose row.ts predates it — a broken or polluted
|
|
49
|
+
// measurement era must not teach the gate the wrong noise level. The LEDGER
|
|
50
|
+
// FILES STAY IMMUTABLE (doctrine): the verdict on which rows count lives in
|
|
51
|
+
// this sidecar, beside its prose provenance (rotation NOTEs / evidence pages).
|
|
52
|
+
// FAIL-CLOSED IN BOTH DIRECTIONS: absent sidecar ⇒ no quarantine; present but
|
|
53
|
+
// malformed/unreadable/unparsable-dates ⇒ the WHOLE bank returns null and the
|
|
54
|
+
// caller runs the legacy gate verbatim — a quarantine we cannot read honestly
|
|
55
|
+
// is a quarantine we cannot silently ignore.
|
|
56
|
+
const BANK_EPOCH_FILE = "bank-epoch.json";
|
|
57
|
+
const bankEpochSchema = z.record(z.string().min(1), z
|
|
58
|
+
.object({ sinceIso: z.string().min(1), why: z.string().optional() })
|
|
59
|
+
.strict());
|
|
60
|
+
function loadBankEpoch(dir, note) {
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
raw = readFileSync(path.join(dir, BANK_EPOCH_FILE), "utf8");
|
|
64
|
+
}
|
|
65
|
+
catch (cause) {
|
|
66
|
+
const code = cause?.code;
|
|
67
|
+
if (code === "ENOENT")
|
|
68
|
+
return undefined; // absent: nothing quarantined
|
|
69
|
+
note(`${BANK_EPOCH_FILE}: unreadable (${String(cause)}) — bank disabled, legacy gate`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
let doc;
|
|
73
|
+
try {
|
|
74
|
+
doc = JSON.parse(raw);
|
|
75
|
+
}
|
|
76
|
+
catch (cause) {
|
|
77
|
+
note(`${BANK_EPOCH_FILE}: invalid JSON (${String(cause)}) — bank disabled, legacy gate`);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const parsed = bankEpochSchema.safeParse(doc);
|
|
81
|
+
if (!parsed.success) {
|
|
82
|
+
note(`${BANK_EPOCH_FILE}: schema violation (${parsed.error.issues[0]?.message ?? "?"}) — bank disabled, legacy gate`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const out = new Map();
|
|
86
|
+
for (const [unitId, rule] of Object.entries(parsed.data)) {
|
|
87
|
+
const sinceMs = Date.parse(rule.sinceIso);
|
|
88
|
+
if (!Number.isFinite(sinceMs)) {
|
|
89
|
+
note(`${BANK_EPOCH_FILE}: bad sinceIso for '${unitId}' — bank disabled, legacy gate`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
out.set(unitId, { sinceIso: rule.sinceIso, sinceMs, why: rule.why ?? "" });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** Half of one 1/8-grid checklist step between two reps — the sd of that pair. */
|
|
97
|
+
export const BANK_QUANTUM = 0.125 / Math.SQRT2;
|
|
98
|
+
/** Phantom df of the bank-wide pooled prior used to shrink each unit's σ̂². */
|
|
99
|
+
export const BANK_PRIOR_DF = 2;
|
|
100
|
+
/** Below this pooled df a unit's history counts as thin and the quantum floor binds. */
|
|
101
|
+
export const BANK_THIN_DF = 6;
|
|
102
|
+
/** Notices stop accumulating past this many lines (a corrupt archive must not flood run output). */
|
|
103
|
+
const NOTICE_CAP = 20;
|
|
104
|
+
/** One group = one row × unit; a multi-rep scores array contributes n−1 df of within-mean scatter. */
|
|
105
|
+
function accumulate(acc, unitId, scores) {
|
|
106
|
+
const n = scores.length;
|
|
107
|
+
if (n < 2)
|
|
108
|
+
return;
|
|
109
|
+
let sum = 0;
|
|
110
|
+
for (const s of scores)
|
|
111
|
+
sum += s;
|
|
112
|
+
const mean = sum / n;
|
|
113
|
+
let ss = 0;
|
|
114
|
+
for (const s of scores) {
|
|
115
|
+
const d = s - mean;
|
|
116
|
+
ss += d * d;
|
|
117
|
+
}
|
|
118
|
+
const cur = acc.get(unitId) ?? { df: 0, ss: 0 };
|
|
119
|
+
acc.set(unitId, { df: cur.df + (n - 1), ss: cur.ss + ss });
|
|
120
|
+
}
|
|
121
|
+
const LIVE_LEDGER = "ledger.jsonl";
|
|
122
|
+
function isRotatedArchive(name) {
|
|
123
|
+
return name.startsWith("ledger.") && name.endsWith(".jsonl") && name !== LIVE_LEDGER && !name.startsWith("ledger.corrupt-");
|
|
124
|
+
}
|
|
125
|
+
export function loadScoreBank(genomeRepo, opts = {}) {
|
|
126
|
+
const dir = path.dirname(ledgerPath(genomeRepo));
|
|
127
|
+
let names;
|
|
128
|
+
try {
|
|
129
|
+
names = readdirSync(dir);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null; // no .state/abathur at all — fresh genome, legacy gate
|
|
133
|
+
}
|
|
134
|
+
const excluded = new Set(opts.excludeCandidateTrees ?? []);
|
|
135
|
+
const excludedFiles = new Set(opts.excludeFiles ?? []);
|
|
136
|
+
const files = names.filter((n) => (n === LIVE_LEDGER || isRotatedArchive(n)) && !excludedFiles.has(n)).sort();
|
|
137
|
+
if (!files.some(isRotatedArchive))
|
|
138
|
+
return null; // activation law: rotated history or legacy gate
|
|
139
|
+
const notices = [];
|
|
140
|
+
const note = (line) => {
|
|
141
|
+
if (notices.length < NOTICE_CAP)
|
|
142
|
+
notices.push(line);
|
|
143
|
+
else if (notices.length === NOTICE_CAP)
|
|
144
|
+
notices.push("score bank: further notices suppressed");
|
|
145
|
+
};
|
|
146
|
+
const bankEpoch = loadBankEpoch(dir, note);
|
|
147
|
+
if (bankEpoch === null)
|
|
148
|
+
return null; // malformed quarantine ⇒ legacy gate, never a silently ignored one
|
|
149
|
+
const quarantined = new Map();
|
|
150
|
+
const acc = new Map();
|
|
151
|
+
for (const file of files) {
|
|
152
|
+
let text;
|
|
153
|
+
try {
|
|
154
|
+
text = readFileSync(path.join(dir, file), "utf8");
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
note(`${file}: unreadable — archive skipped`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const lines = text.split("\n");
|
|
161
|
+
lines.forEach((raw, index) => {
|
|
162
|
+
const line = raw.trim();
|
|
163
|
+
if (line.length === 0)
|
|
164
|
+
return;
|
|
165
|
+
let record;
|
|
166
|
+
try {
|
|
167
|
+
record = JSON.parse(line);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
note(`${file}:${String(index + 1)}: not JSON — row skipped`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const parsed = ledgerRecordSchema.safeParse(record);
|
|
174
|
+
if (!parsed.success || parsed.data.kind !== LEDGER_KIND_GENERATION_COMPLETE)
|
|
175
|
+
return;
|
|
176
|
+
const row = generationRowDataSchema.safeParse(parsed.data.data);
|
|
177
|
+
if (!row.success) {
|
|
178
|
+
note(`${file}:${String(index + 1)}: generation_complete row unreadable — row skipped`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (row.data.source === "candidate" && row.data.treeSha !== undefined && excluded.has(row.data.treeSha))
|
|
182
|
+
return;
|
|
183
|
+
const rowMs = Date.parse(parsed.data.ts);
|
|
184
|
+
for (const u of row.data.units) {
|
|
185
|
+
const rule = bankEpoch?.get(u.unitId);
|
|
186
|
+
if (rule !== undefined && !(Number.isFinite(rowMs) && rowMs >= rule.sinceMs)) {
|
|
187
|
+
quarantined.set(u.unitId, (quarantined.get(u.unitId) ?? 0) + 1);
|
|
188
|
+
continue; // only rows provably at/after the epoch start count; unreadable ts fails closed
|
|
189
|
+
}
|
|
190
|
+
accumulate(acc, u.unitId, u.scores);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
for (const [unitId, rule] of [...(bankEpoch ?? new Map()).entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
195
|
+
note(`bank-epoch '${unitId}' since ${rule.sinceIso}: ${String(quarantined.get(unitId) ?? 0)} group(s) quarantined${rule.why === "" ? "" : ` — ${rule.why}`}`);
|
|
196
|
+
}
|
|
197
|
+
let totalDf = 0;
|
|
198
|
+
let totalSs = 0;
|
|
199
|
+
for (const { df, ss } of acc.values()) {
|
|
200
|
+
totalDf += df;
|
|
201
|
+
totalSs += ss;
|
|
202
|
+
}
|
|
203
|
+
if (totalDf === 0)
|
|
204
|
+
return null;
|
|
205
|
+
const priorVar = totalSs / totalDf;
|
|
206
|
+
const units = new Map();
|
|
207
|
+
for (const [unitId, { df, ss }] of [...acc.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
208
|
+
const within = ss / df;
|
|
209
|
+
const shrunk = (df * within + BANK_PRIOR_DF * priorVar) / (df + BANK_PRIOR_DF);
|
|
210
|
+
const sigma = Math.sqrt(shrunk);
|
|
211
|
+
units.set(unitId, { sigma: df >= BANK_THIN_DF ? sigma : Math.max(sigma, BANK_QUANTUM), df });
|
|
212
|
+
}
|
|
213
|
+
return { units, priorSigma: Math.sqrt(priorVar), totalDf, notices };
|
|
214
|
+
}
|
|
@@ -13,6 +13,7 @@ import { LEDGER_KIND_GENERATION_COMPLETE } from "./ledger.js";
|
|
|
13
13
|
import { newGeneration, openGenome, sealGeneration, snapshotCommit } from "./worktree.js";
|
|
14
14
|
import { git, tryGit } from "../util/git.js";
|
|
15
15
|
import { asReplicates, benchTarget, cloneMatrixRow, copyProvenance } from "./evolve/run-bench.js";
|
|
16
|
+
import { loadScoreBank } from "./evolve/score-bank.js";
|
|
16
17
|
import { addCounters, readResume } from "./evolve/run-rows.js";
|
|
17
18
|
import { ChildTracker, reapOrphans } from "./evolve/child-track.js";
|
|
18
19
|
import { clampReps, evaluate, VERDICT_EXIT } from "./stats.js";
|
|
@@ -82,6 +83,7 @@ export async function graftAndBench(req, ctx) {
|
|
|
82
83
|
ledger.append({ kind: LEDGER_KIND_GENERATION_COMPLETE, genId: incGen, data });
|
|
83
84
|
incUnits = out.units;
|
|
84
85
|
}
|
|
86
|
+
const scoreBank = loadScoreBank(spec.repoPath);
|
|
85
87
|
const out = await benchTarget({
|
|
86
88
|
spec: { ...spec, repoPath: gen.worktreePath },
|
|
87
89
|
genId: graftGenId,
|
|
@@ -103,6 +105,7 @@ export async function graftAndBench(req, ctx) {
|
|
|
103
105
|
stats: spec.bench.stats,
|
|
104
106
|
budgetCaps: caps,
|
|
105
107
|
nPairs: 1,
|
|
108
|
+
...(scoreBank === null ? {} : { bank: scoreBank.units }),
|
|
106
109
|
});
|
|
107
110
|
// EXACTLY ONE graft_import decision row lands BEFORE any further writes.
|
|
108
111
|
ctx.recordDecision(gate.verdict, localScoreReason(gate, manifest, reps), { graftGenId, ...sealed });
|
package/dist/core/ledger.js
CHANGED
|
@@ -9,10 +9,13 @@ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync
|
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { canonicalJson, compactUtc } from "./ids.js";
|
|
12
|
+
import { effectiveRepoPath } from "./spec.js";
|
|
12
13
|
import { acquireLock } from "./locks.js";
|
|
13
14
|
export { acquireLock, lockDirFor } from "./locks.js";
|
|
14
15
|
export const LEDGER_KIND_LOCK_TAKEOVER = "lock_takeover";
|
|
15
16
|
export const LEDGER_KIND_GENERATION_COMPLETE = "generation_complete";
|
|
17
|
+
export const LEDGER_KIND_PROMOTE = "promote";
|
|
18
|
+
export const LEDGER_KIND_TOMBSTONE = "tombstone";
|
|
16
19
|
export const ledgerRecordSchema = z.strictObject({
|
|
17
20
|
v: z.literal(1),
|
|
18
21
|
ts: z.string().min(1),
|
|
@@ -30,7 +33,11 @@ export class LedgerError extends Error {
|
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
35
|
export function ledgerPath(genomeRepo) {
|
|
33
|
-
|
|
36
|
+
// FS-seam rule: the stored repoPath may be an unresolved `${VAR}` literal
|
|
37
|
+
// (0.2.x convention; see 4997618 / 863e6c2). Resolve HERE at the single
|
|
38
|
+
// ledger seam so no caller can ever read an empty ledger under a literal
|
|
39
|
+
// path and report a misleading downstream error again.
|
|
40
|
+
return path.join(effectiveRepoPath(genomeRepo), ".state", "abathur", "ledger.jsonl");
|
|
34
41
|
}
|
|
35
42
|
function buildRecord(input, now) {
|
|
36
43
|
const candidate = {
|