@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,240 @@
|
|
|
1
|
+
// Pure-node tar (USTAR + PAX 'path' override) for lineage bundles (todo 12).
|
|
2
|
+
// Deterministic output (mtime 0, uid/gid 0, fixed mode, sorted-in-callers paths)
|
|
3
|
+
// so a re-export of the same ledger state is byte-identical; the reader is the
|
|
4
|
+
// adversarial boundary — it never materialises anything, validates every header
|
|
5
|
+
// checksum, and refuses absolute / traversal / odd-type member names.
|
|
6
|
+
const BLOCK = 512;
|
|
7
|
+
const NAME_MAX = 99;
|
|
8
|
+
const PREFIX_MAX = 154;
|
|
9
|
+
export const TAR_TOTAL_CAP_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
export class TarError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "TarError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function ascii(s, len) {
|
|
17
|
+
if (s.length > len)
|
|
18
|
+
throw new TarError(`tar field overflow: "${s}" > ${String(len)} bytes`);
|
|
19
|
+
const b = new Uint8Array(len);
|
|
20
|
+
for (let i = 0; i < s.length; i += 1)
|
|
21
|
+
b[i] = s.charCodeAt(i);
|
|
22
|
+
return b;
|
|
23
|
+
}
|
|
24
|
+
function octal(value, len) {
|
|
25
|
+
if (!Number.isInteger(value) || value < 0)
|
|
26
|
+
throw new TarError(`tar octal: bad value ${String(value)}`);
|
|
27
|
+
const digits = (value >>> 0).toString(8).padStart(len - 1, "0");
|
|
28
|
+
if (digits.length > len - 1)
|
|
29
|
+
throw new TarError(`tar octal overflow: ${String(value)}`);
|
|
30
|
+
return ascii(`${digits}\0`, len);
|
|
31
|
+
}
|
|
32
|
+
function assertSafePath(p) {
|
|
33
|
+
if (p.length === 0)
|
|
34
|
+
throw new TarError("tar: empty member path");
|
|
35
|
+
if (p.startsWith("/") || p.includes("\\"))
|
|
36
|
+
throw new TarError(`tar: unsafe member path ${p}`);
|
|
37
|
+
for (const seg of p.split("/")) {
|
|
38
|
+
if (seg === ".." || seg === "")
|
|
39
|
+
throw new TarError(`tar: traversal/unsafe member path ${p}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function zeroBlock() {
|
|
43
|
+
return new Uint8Array(BLOCK);
|
|
44
|
+
}
|
|
45
|
+
function headerBlock(fields) {
|
|
46
|
+
const h = new Uint8Array(BLOCK);
|
|
47
|
+
const parts = [
|
|
48
|
+
[ascii(fields.name, 100), 0],
|
|
49
|
+
[octal(0o644, 8), 100],
|
|
50
|
+
[octal(0, 8), 108],
|
|
51
|
+
[octal(0, 8), 116],
|
|
52
|
+
[octal(fields.size, 12), 124],
|
|
53
|
+
[octal(0, 12), 136],
|
|
54
|
+
[new Uint8Array(8).fill(0x20), 148],
|
|
55
|
+
[ascii(fields.typeflag, 1), 156],
|
|
56
|
+
[new Uint8Array(100), 157],
|
|
57
|
+
[ascii("ustar\0", 6), 257],
|
|
58
|
+
[ascii("00", 2), 263],
|
|
59
|
+
[new Uint8Array(32), 265],
|
|
60
|
+
[new Uint8Array(32), 297],
|
|
61
|
+
[octal(0, 8), 329],
|
|
62
|
+
[octal(0, 8), 337],
|
|
63
|
+
[ascii(fields.prefix, PREFIX_MAX + 1), 345],
|
|
64
|
+
];
|
|
65
|
+
for (const [bytes, offset] of parts)
|
|
66
|
+
h.set(bytes, offset);
|
|
67
|
+
let sum = 0;
|
|
68
|
+
for (const byte of h)
|
|
69
|
+
sum += byte;
|
|
70
|
+
h.set(ascii(`${(sum & 0o777777).toString(8).padStart(6, "0")}\0 `, 8), 148);
|
|
71
|
+
return h;
|
|
72
|
+
}
|
|
73
|
+
function splitLongPath(p) {
|
|
74
|
+
if (p.length <= NAME_MAX)
|
|
75
|
+
return { name: p, prefix: "" };
|
|
76
|
+
let cut = -1;
|
|
77
|
+
for (let i = p.length - 1; i >= 0; i -= 1) {
|
|
78
|
+
if (p[i] === "/" && p.slice(0, i).length <= PREFIX_MAX && p.slice(i + 1).length <= NAME_MAX) {
|
|
79
|
+
cut = i;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (cut < 0)
|
|
84
|
+
return null;
|
|
85
|
+
return { name: p.slice(cut + 1), prefix: p.slice(0, cut) };
|
|
86
|
+
}
|
|
87
|
+
function paxPathHeader(p) {
|
|
88
|
+
// PAX len counts BYTES of "<len> path=<p>\n" — for non-ASCII names the UTF-8
|
|
89
|
+
// byte length exceeds the string length, so the fixed point is byte-based.
|
|
90
|
+
const enc = new TextEncoder();
|
|
91
|
+
const body = `path=${p}\n`;
|
|
92
|
+
let len = enc.encode(body).length + 5;
|
|
93
|
+
for (;;) {
|
|
94
|
+
const record = `${String(len)} ${body}`;
|
|
95
|
+
const bytes = enc.encode(record);
|
|
96
|
+
if (bytes.length === len) {
|
|
97
|
+
const name = `././PaxHeaders.${p.split("/").pop() ?? "x"}`.slice(0, 99);
|
|
98
|
+
const header = headerBlock({ name, prefix: "", size: bytes.length, typeflag: "x" });
|
|
99
|
+
const data = new Uint8Array(Math.ceil(bytes.length / BLOCK) * BLOCK);
|
|
100
|
+
data.set(bytes);
|
|
101
|
+
return new Uint8Array([...header, ...data]);
|
|
102
|
+
}
|
|
103
|
+
len = bytes.length;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function pad(data) {
|
|
107
|
+
const rem = data.length % BLOCK;
|
|
108
|
+
if (rem === 0)
|
|
109
|
+
return data;
|
|
110
|
+
const out = new Uint8Array(data.length + (BLOCK - rem));
|
|
111
|
+
out.set(data);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
/** Serialise members (callers keep them sorted) into deterministic tar bytes. */
|
|
115
|
+
export function writeTar(members) {
|
|
116
|
+
const chunks = [];
|
|
117
|
+
for (const m of members) {
|
|
118
|
+
assertSafePath(m.path);
|
|
119
|
+
if (m.path.length > PREFIX_MAX + 1 + NAME_MAX)
|
|
120
|
+
throw new TarError(`tar: path too long: ${m.path}`);
|
|
121
|
+
const split = m.path.length <= NAME_MAX ? { name: m.path, prefix: "" } : splitLongPath(m.path);
|
|
122
|
+
// USTAR name/prefix fields are 7-bit (ascii() truncates each char to its low
|
|
123
|
+
// byte), so anything outside printable ASCII travels in a PAX 'path' override.
|
|
124
|
+
if (split === null || !/^[\x20-\x7E]+$/.test(m.path)) {
|
|
125
|
+
chunks.push(paxPathHeader(m.path));
|
|
126
|
+
chunks.push(headerBlock({ name: m.path.slice(0, NAME_MAX), prefix: "", size: m.content.length, typeflag: "0" }));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
chunks.push(headerBlock({ name: split.name, prefix: split.prefix, size: m.content.length, typeflag: "0" }));
|
|
130
|
+
}
|
|
131
|
+
const data = pad(m.content);
|
|
132
|
+
chunks.push(data);
|
|
133
|
+
}
|
|
134
|
+
chunks.push(zeroBlock(), zeroBlock());
|
|
135
|
+
return concat(chunks);
|
|
136
|
+
}
|
|
137
|
+
function concat(chunks) {
|
|
138
|
+
const size = chunks.reduce((acc, c) => acc + c.length, 0);
|
|
139
|
+
const out = new Uint8Array(size);
|
|
140
|
+
let off = 0;
|
|
141
|
+
for (const c of chunks) {
|
|
142
|
+
out.set(c, off);
|
|
143
|
+
off += c.length;
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
function parseOctal(field) {
|
|
148
|
+
const s = new TextDecoder().decode(field).replace(/\0| /g, "");
|
|
149
|
+
if (s.length === 0)
|
|
150
|
+
return 0;
|
|
151
|
+
if (!/^[0-7]+$/.test(s))
|
|
152
|
+
throw new TarError(`tar: bad octal field "${s}"`);
|
|
153
|
+
const v = Number.parseInt(s, 8);
|
|
154
|
+
if (!Number.isSafeInteger(v) || v < 0)
|
|
155
|
+
throw new TarError("tar: octal field out of range");
|
|
156
|
+
return v;
|
|
157
|
+
}
|
|
158
|
+
function verifyChecksum(block) {
|
|
159
|
+
const stored = parseOctal(block.subarray(148, 156));
|
|
160
|
+
let sum = 0;
|
|
161
|
+
for (let i = 0; i < BLOCK; i += 1) {
|
|
162
|
+
sum += i >= 148 && i < 156 ? 0x20 : (block[i] ?? 0);
|
|
163
|
+
}
|
|
164
|
+
if (sum !== stored)
|
|
165
|
+
throw new TarError("tar: header checksum mismatch (corrupt or truncated archive)");
|
|
166
|
+
}
|
|
167
|
+
/** Parse tar bytes; throws TarError on any corruption, traversal or odd member type. */
|
|
168
|
+
export function readTar(bytes) {
|
|
169
|
+
const members = [];
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
let off = 0;
|
|
172
|
+
let pendingPath = null;
|
|
173
|
+
let totalBytes = 0;
|
|
174
|
+
const dec = new TextDecoder();
|
|
175
|
+
const cstring = (b) => dec.decode(b).split("\0")[0] ?? "";
|
|
176
|
+
while (off < bytes.length) {
|
|
177
|
+
if (bytes.length - off < BLOCK) {
|
|
178
|
+
throw new TarError("tar: truncated archive (partial block at end of stream)");
|
|
179
|
+
}
|
|
180
|
+
const block = bytes.subarray(off, off + BLOCK);
|
|
181
|
+
off += BLOCK;
|
|
182
|
+
if (block.every((x) => x === 0)) {
|
|
183
|
+
const rest = bytes.subarray(off);
|
|
184
|
+
if (bytes.length - (off - BLOCK) < 2 * BLOCK || rest.length % BLOCK !== 0 || !rest.every((x) => x === 0)) {
|
|
185
|
+
throw new TarError("tar: truncated archive (incomplete end-of-archive marker)");
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
verifyChecksum(block);
|
|
190
|
+
const magic = cstring(block.subarray(257, 265));
|
|
191
|
+
if (!magic.startsWith("ustar"))
|
|
192
|
+
throw new TarError("tar: missing ustar magic — not a tar archive");
|
|
193
|
+
const rawName = cstring(block.subarray(0, 100));
|
|
194
|
+
const prefix = cstring(block.subarray(345, 500));
|
|
195
|
+
const size = parseOctal(block.subarray(124, 136));
|
|
196
|
+
const typeflag = String.fromCharCode(block[156] ?? 0x30);
|
|
197
|
+
const name = prefix.length > 0 && pendingPath === null ? `${prefix}/${rawName}` : rawName;
|
|
198
|
+
if (size > TAR_TOTAL_CAP_BYTES || totalBytes + size > TAR_TOTAL_CAP_BYTES) {
|
|
199
|
+
throw new TarError(`tar: member ${name} exceeds size cap`);
|
|
200
|
+
}
|
|
201
|
+
if (bytes.length - off < size)
|
|
202
|
+
throw new TarError(`tar: truncated archive (member ${name} data missing)`);
|
|
203
|
+
const data = bytes.subarray(off, off + Math.ceil(size / BLOCK) * BLOCK).subarray(0, size);
|
|
204
|
+
off += Math.ceil(size / BLOCK) * BLOCK;
|
|
205
|
+
if (typeflag === "x" || typeflag === "g") {
|
|
206
|
+
pendingPath = extractPaxPath(dec.decode(data)) ?? pendingPath;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (typeflag === "5")
|
|
210
|
+
continue;
|
|
211
|
+
if (typeflag !== "0" && typeflag !== "\0") {
|
|
212
|
+
throw new TarError(`tar: refusing member ${name} of type '${typeflag}' (only regular files allowed)`);
|
|
213
|
+
}
|
|
214
|
+
const finalPath = pendingPath ?? name;
|
|
215
|
+
pendingPath = null;
|
|
216
|
+
assertSafePath(finalPath);
|
|
217
|
+
if (seen.has(finalPath))
|
|
218
|
+
throw new TarError(`tar: duplicate member path ${finalPath}`);
|
|
219
|
+
seen.add(finalPath);
|
|
220
|
+
totalBytes += size;
|
|
221
|
+
members.push({ path: finalPath, content: new Uint8Array(data) });
|
|
222
|
+
}
|
|
223
|
+
return members;
|
|
224
|
+
}
|
|
225
|
+
function extractPaxPath(records) {
|
|
226
|
+
const enc = new TextEncoder();
|
|
227
|
+
let path = null;
|
|
228
|
+
for (const record of records.split("\n")) {
|
|
229
|
+
if (record.length === 0)
|
|
230
|
+
continue;
|
|
231
|
+
const space = record.indexOf(" ");
|
|
232
|
+
const eq = record.indexOf("=", space + 1);
|
|
233
|
+
// the declared len is the record's BYTE length + the split-off "\n"
|
|
234
|
+
if (space > 0 && eq > space && record.slice(0, space) === String(enc.encode(record).length + 1)) {
|
|
235
|
+
if (record.slice(space + 1, eq) === "path")
|
|
236
|
+
path = record.slice(eq + 1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return path;
|
|
240
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Lineage bundles (todo 12) — public surface: export + inspect with provenance
|
|
2
|
+
// and redacted train-only evidence. Members are verified byte-for-byte on
|
|
3
|
+
// inspect; nothing inside a bundle implies trust, only self-consistency.
|
|
4
|
+
export { exportBundle } from "./bundle-export.js";
|
|
5
|
+
export { inspectBundle } from "./bundle-inspect.js";
|
|
6
|
+
export { bundleManifestSchema, DIGEST_ALGO, BUNDLE_SCHEMA_VERSION } from "./bundle-manifest.js";
|
|
7
|
+
export { buildMaskPlan, scanMemberLeaks } from "./bundle-mask.js";
|
|
8
|
+
export { readTar, writeTar, TarError } from "./bundle-tar.js";
|
|
9
|
+
export { benchDigestFor, containedSpec, maskPlanFor, primaryGenId, sha256Hex, treeOf } from "./bundle-common.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Reflection brief builder (todo 8, plan lines 133-140): pure spec + bench
|
|
2
|
+
// results in → mutator-facing text out. Only failed/inconclusive TRAIN units get
|
|
3
|
+
// detail; VAL units are referenced by opaque aliases (val-1, val-2…) — never topic
|
|
4
|
+
// text, file names, or scores.
|
|
5
|
+
function oneLine(text, max) {
|
|
6
|
+
const flat = text.replace(/[\r\n\t]+/g, " ");
|
|
7
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
8
|
+
}
|
|
9
|
+
function isFailing(u) {
|
|
10
|
+
return u.failures.length > 0 || u.scores.length === 0 || u.scores.some((s) => s < 1);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Reflection brief from FAILED TRAIN units only. Val units never appear with
|
|
14
|
+
* identity: count + opaque aliases, no ids, no paths, no scores, no content.
|
|
15
|
+
*/
|
|
16
|
+
export function buildBrief(spec, units, counters) {
|
|
17
|
+
const failing = units.filter((u) => u.unit.split === "train" && isFailing(u));
|
|
18
|
+
const valCount = units.filter((u) => u.unit.split === "val").length;
|
|
19
|
+
const lines = [];
|
|
20
|
+
lines.push(`# Abathur mutation brief — genome "${spec.label}"`);
|
|
21
|
+
lines.push("");
|
|
22
|
+
lines.push(`Budget spent so far: candidates=${String(counters.candidates)} modelCalls=${String(counters.modelCalls)} ` +
|
|
23
|
+
`tokens=${String(counters.tokens)} wallS=${String(counters.wallS)}.`);
|
|
24
|
+
lines.push(`Caps: maxCandidates=${String(spec.budget.maxCandidates)} maxModelCalls=${String(spec.budget.maxModelCalls)} ` +
|
|
25
|
+
`maxTokens=${String(spec.budget.maxTokens)} maxWallS=${String(spec.budget.maxWallS)}.`);
|
|
26
|
+
if (failing.length === 0) {
|
|
27
|
+
lines.push("", "No failing train units to reflect on this session.");
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
lines.push("", `Failing train units (${String(failing.length)}):`);
|
|
31
|
+
for (const f of failing) {
|
|
32
|
+
lines.push("", `## ${f.unit.path} (unit id: ${f.unit.id})`);
|
|
33
|
+
lines.push(`- scores over ${String(f.scores.length)} reps: ${f.scores.map(String).join(", ")}`);
|
|
34
|
+
for (const failure of f.failures)
|
|
35
|
+
lines.push(`- failure: ${oneLine(failure, 500)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
lines.push("", "Candidate contract: stdout JSON {candidates:[{id?,rationale,diffs:[unified-diff strings]}]}.");
|
|
39
|
+
lines.push("Deletions, renames and binary hunks are refused; any kernel-immutable or artifact path in a candidate rejects the WHOLE candidate.");
|
|
40
|
+
if (valCount > 0) {
|
|
41
|
+
const aliases = Array.from({ length: valCount }, (_, k) => `val-${String(k + 1)}`).join(", ");
|
|
42
|
+
lines.push("", `Validation split: ${String(valCount)} held-out units (${aliases}). Their topics, file names, scores and content are withheld from you by policy.`);
|
|
43
|
+
}
|
|
44
|
+
return `${lines.join("\n")}\n`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Candidate validation for the mutator session (todo 8, plan lines 133-140).
|
|
2
|
+
// Everything is pure: zod schema for the child's output contract, the central
|
|
3
|
+
// ARTIFACT_GLOBS policy, gitignore → glob translation, and validateCandidate
|
|
4
|
+
// which turns one raw candidate into parsed FileChanges or a staged rejection.
|
|
5
|
+
// WHOLE-CANDIDATE REJECTION is the rule: any single violating path rejects the
|
|
6
|
+
// entire candidate — there is no partial-apply path through this module.
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { compileGlob } from "../glob.js";
|
|
10
|
+
import { formatZodIssues } from "../spec.js";
|
|
11
|
+
import { parseUnifiedDiff } from "./udiff.js";
|
|
12
|
+
/**
|
|
13
|
+
* Build/hidden state that evolution must never write. Plan wording:
|
|
14
|
+
* ".gitignore'd outputs, dist/**, node_modules/**, .state/**, *.local.jsonc".
|
|
15
|
+
* `.state/**` doubles as the ledger-seal surface — mutating it from a candidate
|
|
16
|
+
* is self-tampering. Deep variants cover nested installs; the repo's own
|
|
17
|
+
* .gitignore is folded in via artifactGlobsFromGitignore at session time.
|
|
18
|
+
*/
|
|
19
|
+
export const ARTIFACT_GLOBS = Object.freeze([
|
|
20
|
+
"dist/**",
|
|
21
|
+
"**/dist/**",
|
|
22
|
+
"node_modules/**",
|
|
23
|
+
"**/node_modules/**",
|
|
24
|
+
".state/**",
|
|
25
|
+
"**/.state/**",
|
|
26
|
+
"*.local.jsonc",
|
|
27
|
+
"**/*.local.jsonc",
|
|
28
|
+
]);
|
|
29
|
+
/** Translate .gitignore lines into our glob language (fail-closed broadening). */
|
|
30
|
+
export function artifactGlobsFromGitignore(text) {
|
|
31
|
+
const globs = [];
|
|
32
|
+
for (const raw of text.split("\n")) {
|
|
33
|
+
const line = raw.trim();
|
|
34
|
+
if (line === "" || line.startsWith("#") || line.startsWith("!"))
|
|
35
|
+
continue;
|
|
36
|
+
const dirStyle = line.endsWith("/");
|
|
37
|
+
const body = dirStyle ? line.slice(0, -1) : line;
|
|
38
|
+
const core = body.startsWith("/") ? body.slice(1) : body;
|
|
39
|
+
if (core === "")
|
|
40
|
+
continue;
|
|
41
|
+
if (dirStyle) {
|
|
42
|
+
globs.push(`${core}/**`);
|
|
43
|
+
if (!core.includes("/"))
|
|
44
|
+
globs.push(`**/${core}/**`);
|
|
45
|
+
}
|
|
46
|
+
else if (!core.includes("/")) {
|
|
47
|
+
globs.push(core, `**/${core}`);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
globs.push(core);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return globs;
|
|
54
|
+
}
|
|
55
|
+
/** One mutator candidate: unified diff chunks + a rationale string (plan contract). */
|
|
56
|
+
export const candidateSchema = z.strictObject({
|
|
57
|
+
id: z
|
|
58
|
+
.string()
|
|
59
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/)
|
|
60
|
+
.optional(),
|
|
61
|
+
rationale: z.string().min(1).max(4000),
|
|
62
|
+
diffs: z.array(z.string().min(1)).min(1),
|
|
63
|
+
});
|
|
64
|
+
/** Top level of the child's stdout; elements are schema-checked per candidate. */
|
|
65
|
+
export const mutatorOutputSchema = z.strictObject({
|
|
66
|
+
candidates: z.array(z.unknown()).min(1),
|
|
67
|
+
});
|
|
68
|
+
/** Candidate ids are attacker-influenced; the fallback keeps ledger rows stable. */
|
|
69
|
+
export function candidateIdOf(raw, index) {
|
|
70
|
+
if (typeof raw === "object" && raw !== null && "id" in raw) {
|
|
71
|
+
const id = raw.id;
|
|
72
|
+
if (typeof id === "string" && id.length > 0)
|
|
73
|
+
return id;
|
|
74
|
+
}
|
|
75
|
+
return `candidate-${String(index + 1)}`;
|
|
76
|
+
}
|
|
77
|
+
/** Reject absolute / traversal / VCS / control-char paths before any glob runs. */
|
|
78
|
+
function unsafePathReason(p) {
|
|
79
|
+
if (p.length === 0)
|
|
80
|
+
return "empty path";
|
|
81
|
+
if (p.startsWith("/") || p.includes("\\"))
|
|
82
|
+
return "absolute or drive-style path";
|
|
83
|
+
if (p.includes("\0") || p.includes("\n") || p.includes("\r"))
|
|
84
|
+
return "control characters";
|
|
85
|
+
if (p.endsWith("/"))
|
|
86
|
+
return "directory path";
|
|
87
|
+
const segments = p.split("/");
|
|
88
|
+
if (segments.some((s) => s === ".."))
|
|
89
|
+
return "directory traversal";
|
|
90
|
+
if (segments.some((s) => s === ".git"))
|
|
91
|
+
return "VCS metadata path";
|
|
92
|
+
if (path.posix.normalize(p) !== p)
|
|
93
|
+
return "non-canonical path";
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
function policyHit(p, globs) {
|
|
97
|
+
for (const g of globs) {
|
|
98
|
+
if (compileGlob(g).test(p))
|
|
99
|
+
return g;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Validate one raw candidate against the schema, diff syntax, and the sealed
|
|
105
|
+
* paths (kernel.immutableGlobs + artifact globs). Returns the parsed changes
|
|
106
|
+
* ONLY when every file of the candidate passes — a single violation rejects
|
|
107
|
+
* the whole candidate with the stage and reason the session logs to the ledger.
|
|
108
|
+
*/
|
|
109
|
+
export function validateCandidate(raw, policy, fallbackId = "candidate") {
|
|
110
|
+
const parsed = candidateSchema.safeParse(raw);
|
|
111
|
+
if (!parsed.success) {
|
|
112
|
+
return { ok: false, stage: "schema", reason: formatZodIssues(parsed.error.issues).join("; ") };
|
|
113
|
+
}
|
|
114
|
+
const changes = [];
|
|
115
|
+
for (const diff of parsed.data.diffs) {
|
|
116
|
+
const p = parseUnifiedDiff(diff);
|
|
117
|
+
if (!p.ok)
|
|
118
|
+
return { ok: false, stage: "syntax", reason: p.error };
|
|
119
|
+
for (const c of p.changes) {
|
|
120
|
+
if (changes.some((e) => e.path === c.path)) {
|
|
121
|
+
return { ok: false, stage: "syntax", reason: `duplicate file section across diff chunks: ${c.path}` };
|
|
122
|
+
}
|
|
123
|
+
changes.push(c);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const c of changes) {
|
|
127
|
+
const unsafe = unsafePathReason(c.path);
|
|
128
|
+
if (unsafe !== null)
|
|
129
|
+
return { ok: false, stage: "path", reason: `refused path (${unsafe}): ${c.path}` };
|
|
130
|
+
const sealed = policyHit(c.path, policy.immutableGlobs);
|
|
131
|
+
if (sealed !== null) {
|
|
132
|
+
return { ok: false, stage: "path", reason: `path '${c.path}' touches kernel-immutable glob '${sealed}' — whole candidate rejected` };
|
|
133
|
+
}
|
|
134
|
+
const artifact = policyHit(c.path, policy.artifactGlobs);
|
|
135
|
+
if (artifact !== null) {
|
|
136
|
+
return { ok: false, stage: "path", reason: `path '${c.path}' touches artifact glob '${artifact}' — whole candidate rejected` };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, candidateId: parsed.data.id ?? fallbackId, rationale: parsed.data.rationale, changes };
|
|
140
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Active-child tracking + orphan reaping for the run-loop (plan todo 9).
|
|
2
|
+
//
|
|
3
|
+
// Every child the loop spawns (mutator, bench unit, grader, engine probe) is
|
|
4
|
+
// recorded in <genomeRepo>/.state/abathur/active-children.jsonl the instant it
|
|
5
|
+
// spawns — BEFORE any await — with its pid. Children run detached (pid ===
|
|
6
|
+
// pgid, see bench/adapter.ts runChild), so the recorded pid doubles as the
|
|
7
|
+
// process-group id and the reap can SIGKILL whole groups.
|
|
8
|
+
//
|
|
9
|
+
// Reaper contract (startup of every `abathur run`):
|
|
10
|
+
// - ONLY pids present in this log are ever signalled — an unrecorded process
|
|
11
|
+
// is off-limits, always;
|
|
12
|
+
// - the log is read at startup only while the genome lock is held, so every
|
|
13
|
+
// entry belongs to a DEAD prior run; liveness is recorded per entry
|
|
14
|
+
// (kill-0: ESRCH dead, EPERM foreign-but-alive) and the group gets
|
|
15
|
+
// kill(-pgid, SIGKILL), ESRCH being a benign no-op;
|
|
16
|
+
// - self pid and structurally invalid records are skipped (never killed);
|
|
17
|
+
// - malformed lines are counted and skipped: corrupt tracking state must
|
|
18
|
+
// degrade the reap, never crash the run;
|
|
19
|
+
// - after killing, the log is truncated — reap is once-per-entry.
|
|
20
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { canonicalJson } from "../ids.js";
|
|
23
|
+
export function activeChildrenPath(genomeRepo) {
|
|
24
|
+
return path.join(genomeRepo, ".state", "abathur", "active-children.jsonl");
|
|
25
|
+
}
|
|
26
|
+
function parseRecord(line) {
|
|
27
|
+
let doc;
|
|
28
|
+
try {
|
|
29
|
+
doc = JSON.parse(line);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc))
|
|
35
|
+
return null;
|
|
36
|
+
const raw = doc;
|
|
37
|
+
const { pid, pgid, genId } = raw;
|
|
38
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 1)
|
|
39
|
+
return null;
|
|
40
|
+
const resolvedGid = typeof pgid === "number" && Number.isInteger(pgid) && pgid > 1 ? pgid : pid;
|
|
41
|
+
if (typeof genId !== "string" || genId.length === 0)
|
|
42
|
+
return null;
|
|
43
|
+
const kindVal = raw["kind"];
|
|
44
|
+
const atVal = raw["at"];
|
|
45
|
+
return {
|
|
46
|
+
v: 1,
|
|
47
|
+
pid,
|
|
48
|
+
pgid: resolvedGid,
|
|
49
|
+
genId,
|
|
50
|
+
kind: typeof kindVal === "string" && kindVal.length > 0 ? kindVal : "child",
|
|
51
|
+
at: typeof atVal === "string" ? atVal : "",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Tolerant reader: malformed lines yield nothing, the file may be absent. */
|
|
55
|
+
export function readChildRecords(genomeRepo) {
|
|
56
|
+
let text;
|
|
57
|
+
try {
|
|
58
|
+
text = readFileSync(activeChildrenPath(genomeRepo), "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const line of text.split("\n")) {
|
|
65
|
+
if (line.length === 0)
|
|
66
|
+
continue;
|
|
67
|
+
const rec = parseRecord(line);
|
|
68
|
+
if (rec !== null)
|
|
69
|
+
out.push(rec);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function pidAlive(pid) {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(pid, 0);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return error.code === "EPERM"; // foreign-owned ⇒ alive
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function killGroup(pgid) {
|
|
83
|
+
try {
|
|
84
|
+
process.kill(-pgid, "SIGKILL");
|
|
85
|
+
return "sent";
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const code = error.code;
|
|
89
|
+
if (code === "ESRCH")
|
|
90
|
+
return "already-gone";
|
|
91
|
+
return "foreign";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* SIGKILL every recorded group of the dead prior run(s), then truncate the log.
|
|
96
|
+
* Call only under the genome lock: live entries from a concurrent run must
|
|
97
|
+
* never pass through here.
|
|
98
|
+
*/
|
|
99
|
+
export function reapOrphans(genomeRepo) {
|
|
100
|
+
const filePath = activeChildrenPath(genomeRepo);
|
|
101
|
+
let text;
|
|
102
|
+
try {
|
|
103
|
+
text = readFileSync(filePath, "utf8");
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return { reaped: [], skipped: [], malformed: 0 }; // no tracking state ⇒ nothing to reap
|
|
107
|
+
}
|
|
108
|
+
const reaped = [];
|
|
109
|
+
const skipped = [];
|
|
110
|
+
let malformed = 0;
|
|
111
|
+
for (const line of text.split("\n")) {
|
|
112
|
+
if (line.length === 0)
|
|
113
|
+
continue;
|
|
114
|
+
const rec = parseRecord(line);
|
|
115
|
+
if (rec === null) {
|
|
116
|
+
malformed += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (rec.pid === process.pid) {
|
|
120
|
+
skipped.push(`self pid ${String(rec.pid)} (never kill own process group)`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const alive = pidAlive(rec.pid);
|
|
124
|
+
const outcome = killGroup(rec.pgid);
|
|
125
|
+
if (outcome === "foreign") {
|
|
126
|
+
skipped.push(`pid ${String(rec.pid)} group is foreign-owned (EPERM)`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
reaped.push({ pid: rec.pid, alive });
|
|
130
|
+
}
|
|
131
|
+
writeFileSync(filePath, "", "utf8"); // truncate AFTER kills: a crash mid-reap re-kills idempotently
|
|
132
|
+
return { reaped, skipped, malformed };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Live-run recorder: an entry appears the moment a child spawns and vanishes
|
|
136
|
+
* when it exits, so a clean run leaves an empty log and a crashed run leaves
|
|
137
|
+
* exactly its orphans for the next reap. All file mutations are synchronous —
|
|
138
|
+
* there is no await window between spawn and record.
|
|
139
|
+
*/
|
|
140
|
+
export class ChildTracker {
|
|
141
|
+
filePath;
|
|
142
|
+
genId = "unassigned";
|
|
143
|
+
kind = "child";
|
|
144
|
+
pending = new Set();
|
|
145
|
+
constructor(genomeRepo) {
|
|
146
|
+
this.filePath = activeChildrenPath(genomeRepo);
|
|
147
|
+
}
|
|
148
|
+
/** Tag every child spawned from here on (phase transitions are sequential). */
|
|
149
|
+
phase(genId, kind) {
|
|
150
|
+
this.genId = genId;
|
|
151
|
+
this.kind = kind;
|
|
152
|
+
}
|
|
153
|
+
/** Adapter-facing ChildOptions.onChild sink: append-before-exec, remove-on-exit. */
|
|
154
|
+
onChild = (handle) => {
|
|
155
|
+
mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
156
|
+
const record = {
|
|
157
|
+
v: 1,
|
|
158
|
+
pid: handle.pid,
|
|
159
|
+
pgid: handle.pid, // runChild spawns detached ⇒ child.pid is its own process-group id
|
|
160
|
+
genId: this.genId,
|
|
161
|
+
kind: this.kind,
|
|
162
|
+
at: new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
writeFileSync(this.filePath, `${canonicalJson(record)}\n`, { flag: "a" });
|
|
165
|
+
const settled = handle.exited.then(() => {
|
|
166
|
+
this.pending.delete(settled);
|
|
167
|
+
this.remove(handle.pid);
|
|
168
|
+
});
|
|
169
|
+
this.pending.add(settled);
|
|
170
|
+
};
|
|
171
|
+
/** Resolve once every recorded child that exited has been removed from the log. */
|
|
172
|
+
async drain() {
|
|
173
|
+
while (this.pending.size > 0) {
|
|
174
|
+
await Promise.all([...this.pending]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
remove(pid) {
|
|
178
|
+
let text;
|
|
179
|
+
try {
|
|
180
|
+
text = readFileSync(this.filePath, "utf8");
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return; // truncation (e.g. a reap) already owns the log
|
|
184
|
+
}
|
|
185
|
+
const lines = text.split("\n").filter((line) => line.length > 0);
|
|
186
|
+
const kept = lines.filter((line) => {
|
|
187
|
+
const rec = parseRecord(line);
|
|
188
|
+
return rec === null || rec.pid !== pid; // malformed lines survive until the next reap
|
|
189
|
+
});
|
|
190
|
+
if (kept.length === lines.length)
|
|
191
|
+
return;
|
|
192
|
+
const body = kept.length === 0 ? "" : `${kept.join("\n")}\n`;
|
|
193
|
+
const tmp = `${this.filePath}.tmp-${String(pid)}`;
|
|
194
|
+
writeFileSync(tmp, body, "utf8");
|
|
195
|
+
renameSync(tmp, this.filePath); // atomic swap: a crash mid-rewrite never truncates live entries
|
|
196
|
+
}
|
|
197
|
+
}
|