@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,119 @@
|
|
|
1
|
+
// promoteGeneration (todo 10): the human-only gate. Import policy: this module is
|
|
2
|
+
// pulled in ONLY by src/commands/promote.ts — run-loop/evolve code must never reach
|
|
3
|
+
// it (plan Must NOT: no auto-promote path importable by evolve modules; pinned by
|
|
4
|
+
// src/test/promote.test.ts). Every fact comes from the LEDGER, never from worktree
|
|
5
|
+
// contents or exit codes (todo-9 rule): a forged nominated row still has to survive
|
|
6
|
+
// the independent sealed-path diff below before any ref or manifest moves.
|
|
7
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { ExitSignal, blocked, cannotAnswer } from "../exit.js";
|
|
10
|
+
import { compileGlob } from "./glob.js";
|
|
11
|
+
import { fastForwardIncumbent } from "./incumbent.js";
|
|
12
|
+
import { buildManifest, kernelsDirOf, manifestPathFor, readManifestFile, serializeManifest, } from "./kernel.js";
|
|
13
|
+
import { Ledger, ledgerPath } from "./ledger.js";
|
|
14
|
+
import { gitOpts, requireSha } from "./genome-paths.js";
|
|
15
|
+
import { snapshotCommit } from "./snapshot.js";
|
|
16
|
+
import { tryGit } from "../util/git.js";
|
|
17
|
+
import { decodeGenerationRecord } from "./evolve/run-bench.js";
|
|
18
|
+
export const LEDGER_KIND_PROMOTE = "promote";
|
|
19
|
+
/** Ledger/display-safe flattening of untrusted text (todo-8 sealMessage discipline). */
|
|
20
|
+
function display(value, max = 96) {
|
|
21
|
+
const flat = value.replace(/[^ -~]/g, " ").replace(/\s+/g, " ").trim();
|
|
22
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
23
|
+
}
|
|
24
|
+
function short(sha) {
|
|
25
|
+
return sha.slice(0, 12);
|
|
26
|
+
}
|
|
27
|
+
function firstLine(text) {
|
|
28
|
+
return text.split("\n")[0]?.trim() ?? "";
|
|
29
|
+
}
|
|
30
|
+
/** Read the nomination from the ledger. Never from worktree files or exit codes. */
|
|
31
|
+
function nominatedGeneration(record, shown) {
|
|
32
|
+
if (record.source !== "candidate") {
|
|
33
|
+
blocked(`promote: '${shown}' is an incumbent baseline row — only candidate generations are promotable`);
|
|
34
|
+
}
|
|
35
|
+
if (record.verdict === undefined) {
|
|
36
|
+
blocked(`promote: gen '${shown}' has no verdict in the ledger — cannot promote what the gate never judged`);
|
|
37
|
+
}
|
|
38
|
+
if (record.verdict !== "nominated") {
|
|
39
|
+
blocked(`promote: gen '${shown}' verdict is '${record.verdict}', not 'nominated' — refusing`);
|
|
40
|
+
}
|
|
41
|
+
if (record.commitSha === undefined) {
|
|
42
|
+
blocked(`promote: gen '${shown}' nominated row is missing its commitSha`);
|
|
43
|
+
}
|
|
44
|
+
return { commitSha: requireSha(record.commitSha, `promote: gen '${shown}' commit sha`), parent: record.headCommit };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Second, independent enforcement of the kernel boundary: git-diff the sealed gen
|
|
48
|
+
* range and cross every touched path against BOTH the spec's immutableGlobs and
|
|
49
|
+
* the config-home manifest's file set. Runs before any mutation; a hit blocks even
|
|
50
|
+
* when the ledger says nominated (tampered-ledger posture).
|
|
51
|
+
*/
|
|
52
|
+
async function sealedPathCheck(genId, nom, matchers, sealedPaths, cwd, opts) {
|
|
53
|
+
const run = await tryGit(["diff", "--name-only", "-z", nom.parent, nom.commitSha], gitOpts(opts, cwd));
|
|
54
|
+
if (!run.ok) {
|
|
55
|
+
blocked(`promote: cannot diff ${short(nom.parent)}..${short(nom.commitSha)} for gen '${genId}' in ${cwd}: ${firstLine(run.error.stderr)}`, "the ledger names a commit this repo does not contain — investigate before promoting");
|
|
56
|
+
}
|
|
57
|
+
const touched = run.stdout.split("\0").filter((p) => p.length > 0);
|
|
58
|
+
const hits = touched.filter((p) => sealedPaths.has(p) || matchers.some((re) => re.test(p)));
|
|
59
|
+
const unique = [...new Set(hits)].sort();
|
|
60
|
+
if (unique.length > 0) {
|
|
61
|
+
const shown = unique.slice(0, 10).map((p) => display(p, 200)).join(", ");
|
|
62
|
+
const more = unique.length > 10 ? ` (+${String(unique.length - 10)} more)` : "";
|
|
63
|
+
blocked(`promote: gen '${genId}' touches sealed paths: ${shown}${more} — ledger verdict 'nominated' is not sufficient; refusing`, "sealed kernel paths must not move inside a generation; cull it with `abathur tombstone`");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function promoteGeneration(req) {
|
|
67
|
+
const { entry, configDir } = req;
|
|
68
|
+
const repo = entry.spec.repoPath;
|
|
69
|
+
const shown = display(req.genId);
|
|
70
|
+
const opts = req.env === undefined ? {} : { env: req.env };
|
|
71
|
+
const ledger = Ledger.open(repo);
|
|
72
|
+
const records = ledger.readAll();
|
|
73
|
+
const genRows = records.filter((r) => r.kind === "generation_complete" && r.genId === req.genId);
|
|
74
|
+
const lastRow = genRows.at(-1);
|
|
75
|
+
if (lastRow === undefined) {
|
|
76
|
+
blocked(`promote: no generation_complete ledger row for gen '${shown}' of '${entry.label}'`, `state is read only from ${ledgerPath(repo)} — check candidates with \`abathur status ${entry.label}\``);
|
|
77
|
+
}
|
|
78
|
+
if (records.some((r) => r.kind === LEDGER_KIND_PROMOTE && r.genId === req.genId)) {
|
|
79
|
+
blocked(`promote: gen '${shown}' is already promoted (promote row in ledger) — nothing to do`);
|
|
80
|
+
}
|
|
81
|
+
const nom = nominatedGeneration(decodeGenerationRecord(lastRow), shown);
|
|
82
|
+
const manifestFile = manifestPathFor(configDir, entry.fingerprint);
|
|
83
|
+
let manifestEntries;
|
|
84
|
+
try {
|
|
85
|
+
manifestEntries = readManifestFile(manifestFile);
|
|
86
|
+
}
|
|
87
|
+
catch (cause) {
|
|
88
|
+
if (cause instanceof ExitSignal)
|
|
89
|
+
throw cause;
|
|
90
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
91
|
+
cannotAnswer(`promote: kernel manifest unreadable, refusing to gate: ${message}`);
|
|
92
|
+
}
|
|
93
|
+
const sealedPaths = new Set(manifestEntries.map((e) => e.path));
|
|
94
|
+
const matchers = entry.spec.kernel.immutableGlobs.map((glob) => compileGlob(glob));
|
|
95
|
+
await sealedPathCheck(req.genId, nom, matchers, sealedPaths, repo, opts);
|
|
96
|
+
const genome = { repoPath: repo, genomeFp: entry.fingerprint };
|
|
97
|
+
const ff = await fastForwardIncumbent(genome, nom.commitSha, opts);
|
|
98
|
+
// regeneration happens ONLY here, after the human-invoked diff passed:
|
|
99
|
+
// manifest reborn from the new incumbent's committed tree, not the worktree.
|
|
100
|
+
const snap = await snapshotCommit(genome, nom.commitSha, opts);
|
|
101
|
+
const fresh = buildManifest(snap.snapshotPath, entry.spec.kernel.immutableGlobs);
|
|
102
|
+
mkdirSync(kernelsDirOf(configDir), { recursive: true });
|
|
103
|
+
writeFileSync(manifestFile, serializeManifest(fresh), "utf8");
|
|
104
|
+
ledger.append({
|
|
105
|
+
kind: LEDGER_KIND_PROMOTE,
|
|
106
|
+
genId: req.genId,
|
|
107
|
+
data: { actor: "cli", genId: req.genId, from: ff.fromSha, to: ff.toSha },
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
genId: req.genId,
|
|
111
|
+
fromSha: ff.fromSha,
|
|
112
|
+
toSha: ff.toSha,
|
|
113
|
+
lines: [
|
|
114
|
+
`promoted '${entry.label}' (${entry.fingerprint}): ${ff.fromSha === null ? "incumbent branch created" : short(ff.fromSha)} -> ${short(ff.toSha)} (gen ${shown})`,
|
|
115
|
+
`manifest: rewrote ${path.relative(process.cwd(), manifestFile) || manifestFile} from tree ${short(nom.commitSha)} (${String(fresh.length)} sealed ${fresh.length === 1 ? "entry" : "entries"})`,
|
|
116
|
+
`ledger: appended '${LEDGER_KIND_PROMOTE}' row (actor: cli) to ${ledgerPath(repo)}`,
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// snapshotCommit (todo 3): a READ-ONLY copy of a commit's tree for self-bench
|
|
2
|
+
// (todo 11). Materialized as a tiny standalone repo (init + fetch of the exact
|
|
3
|
+
// sha + detached checkout) so `git -C <snapshot> rev-parse HEAD^{tree}` answers
|
|
4
|
+
// with the tree sha, then every write bit is stripped (files 0o444/0o555, dirs
|
|
5
|
+
// 0o555). The copy is content-addressed and idempotent: re-snapshotting the
|
|
6
|
+
// same sha returns the cached dir after an integrity check.
|
|
7
|
+
import { mkdir, rm, stat } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { blocked } from "../exit.js";
|
|
10
|
+
import { git, tryGit } from "../util/git.js";
|
|
11
|
+
import { freezeTree, thawTree } from "../util/freeze.js";
|
|
12
|
+
import { gitOpts, requireSha, snapshotsDir } from "./genome-paths.js";
|
|
13
|
+
/** Thaw (if needed) then remove a frozen tree. Tolerates a vanished root. */
|
|
14
|
+
export async function removeFrozenTree(target) {
|
|
15
|
+
try {
|
|
16
|
+
await thawTree(target);
|
|
17
|
+
}
|
|
18
|
+
catch (cause) {
|
|
19
|
+
// Already gone (ENOENT) — nothing to thaw; rm stays idempotent.
|
|
20
|
+
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
|
|
21
|
+
throw cause;
|
|
22
|
+
}
|
|
23
|
+
await rm(target, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
async function treeOf(cwd, rev, opts) {
|
|
26
|
+
const run = await tryGit(["rev-parse", rev], gitOpts(opts, cwd));
|
|
27
|
+
return run.ok ? run.stdout.trim() : null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Copy `commitSha`'s tree into <cache>/<genomeFp>/snapshots/<sha> and freeze it.
|
|
31
|
+
* Returns the snapshot path plus the commit's tree sha (verified equal to the
|
|
32
|
+
* snapshot's checked-out tree before returning).
|
|
33
|
+
*/
|
|
34
|
+
export async function snapshotCommit(genome, commitSha, opts = {}) {
|
|
35
|
+
const sha = requireSha(commitSha);
|
|
36
|
+
const env = opts.env ?? process.env;
|
|
37
|
+
const repo = path.resolve(genome.repoPath);
|
|
38
|
+
const repoTree = await treeOf(repo, `${sha}^{tree}`, opts);
|
|
39
|
+
if (repoTree === null) {
|
|
40
|
+
blocked(`worktree: commit ${sha} is not reachable in genome repo ${repo}`);
|
|
41
|
+
}
|
|
42
|
+
const snap = path.join(snapshotsDir(env, genome), sha);
|
|
43
|
+
if ((await stat(snap).catch(() => null)) !== null) {
|
|
44
|
+
const cached = await treeOf(snap, "HEAD^{tree}", opts);
|
|
45
|
+
if (cached === repoTree) {
|
|
46
|
+
return { snapshotPath: snap, commitSha: sha, treeSha: repoTree };
|
|
47
|
+
}
|
|
48
|
+
await removeFrozenTree(snap); // half-written or corrupted — rebuild
|
|
49
|
+
}
|
|
50
|
+
await mkdir(path.dirname(snap), { recursive: true });
|
|
51
|
+
await git(["init", "-q", snap], gitOpts(opts, repo));
|
|
52
|
+
await git(["fetch", "-q", repo, sha], gitOpts(opts, snap));
|
|
53
|
+
await git(["checkout", "-q", "--detach", "FETCH_HEAD"], gitOpts(opts, snap));
|
|
54
|
+
const snapTree = await treeOf(snap, "HEAD^{tree}", opts);
|
|
55
|
+
if (snapTree !== repoTree) {
|
|
56
|
+
await removeFrozenTree(snap);
|
|
57
|
+
blocked(`worktree: snapshot integrity mismatch at ${snap}`);
|
|
58
|
+
}
|
|
59
|
+
await freezeTree(snap);
|
|
60
|
+
return { snapshotPath: snap, commitSha: sha, treeSha: repoTree };
|
|
61
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// GenomeSpec schema (todo 4, plan line 102) — the contract todos 5-9 consume, so the
|
|
2
|
+
// field names below are load-bearing. Identity comes ONLY from the content
|
|
3
|
+
// fingerprint: `label` is a free string and may repeat across distinct genomes.
|
|
4
|
+
// Every bench.stats threshold is REQUIRED — an absent entry is an explicit config
|
|
5
|
+
// error naming the key, mirroring the hr discipline (hr/configs/thresholds.yaml:32-34:
|
|
6
|
+
// "missing entry = explicit config error", never a silent default).
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { cannotAnswer } from "../exit.js";
|
|
11
|
+
import { parseJsonc } from "../jsonc.js";
|
|
12
|
+
export const benchUnitSchema = z.strictObject({
|
|
13
|
+
id: z.string().min(1),
|
|
14
|
+
/** Path to the fixture/scenario file inside the genome repo. */
|
|
15
|
+
path: z.string().min(1),
|
|
16
|
+
split: z.enum(["train", "val"]),
|
|
17
|
+
});
|
|
18
|
+
/** Decision thresholds for the accept/indeterminate/reject gate — all mandatory. */
|
|
19
|
+
export const benchStatsSchema = z.strictObject({
|
|
20
|
+
/** Half-width of the acceptance interval around an observed delta. */
|
|
21
|
+
halfWidth: z.number().positive(),
|
|
22
|
+
/** Smallest effect that can justify acceptance. */
|
|
23
|
+
minEffect: z.number().nonnegative(),
|
|
24
|
+
nReps: z.strictObject({
|
|
25
|
+
initial: z.number().int().positive(),
|
|
26
|
+
max: z.number().int().positive(),
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
export const benchSchema = z
|
|
30
|
+
.strictObject({
|
|
31
|
+
type: z.enum(["toy", "opencode-fixture-scenarios"]),
|
|
32
|
+
units: z.array(benchUnitSchema).min(1),
|
|
33
|
+
seedCommand: z.string().min(1).optional(),
|
|
34
|
+
resetCommand: z.string().min(1).optional(),
|
|
35
|
+
runCommand: z.string().min(1),
|
|
36
|
+
graderCommand: z.string().min(1),
|
|
37
|
+
judgeCommand: z.string().min(1).optional(),
|
|
38
|
+
judgeModel: z.string().min(1).optional(),
|
|
39
|
+
agentModel: z.string().min(1).optional(),
|
|
40
|
+
timeoutS: z.number().int().positive(),
|
|
41
|
+
stats: benchStatsSchema,
|
|
42
|
+
})
|
|
43
|
+
.superRefine((bench, context) => {
|
|
44
|
+
if (!bench.units.some((unit) => unit.split === "val")) {
|
|
45
|
+
context.addIssue({
|
|
46
|
+
code: "custom",
|
|
47
|
+
path: ["units"],
|
|
48
|
+
message: 'bench.units needs at least one split: "val" unit — validation splits are mandatory',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (bench.type === "opencode-fixture-scenarios" && bench.agentModel === undefined) {
|
|
52
|
+
context.addIssue({
|
|
53
|
+
code: "custom",
|
|
54
|
+
path: ["agentModel"],
|
|
55
|
+
message: 'bench.agentModel is required when bench.type is "opencode-fixture-scenarios"',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (bench.judgeCommand !== undefined && bench.judgeModel === undefined) {
|
|
59
|
+
context.addIssue({
|
|
60
|
+
code: "custom",
|
|
61
|
+
path: ["judgeModel"],
|
|
62
|
+
message: "bench.judgeModel is required when bench.judgeCommand is set",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* Bundle section (todo 12, additive): machine-local literals that `bundle export`
|
|
68
|
+
* masks out of every manifest/patch/evidence/README member (placeholders
|
|
69
|
+
* <HOME>/<GENOME>/<MASKED-n>). HOME and the genome repoPath are ALWAYS masked by
|
|
70
|
+
* the exporter; entries here EXTEND that set — typically from the gitignored
|
|
71
|
+
* *.local.jsonc overlay, keeping the committed spec machine-independent.
|
|
72
|
+
*/
|
|
73
|
+
export const bundleSectionSchema = z.strictObject({
|
|
74
|
+
maskLiterals: z.array(z.string().min(1)),
|
|
75
|
+
});
|
|
76
|
+
export const genomeSpecSchema = z.strictObject({
|
|
77
|
+
/** Free-form identity; genomes are distinguished by fingerprint, never by label. */
|
|
78
|
+
label: z.string().min(1),
|
|
79
|
+
/** Genome repo root; registry + kernel manifests live structurally OUTSIDE it. */
|
|
80
|
+
repoPath: z.string().min(1),
|
|
81
|
+
bench: benchSchema,
|
|
82
|
+
budget: z.strictObject({
|
|
83
|
+
maxCandidates: z.number().int().positive(),
|
|
84
|
+
maxModelCalls: z.number().int().positive(),
|
|
85
|
+
maxTokens: z.number().int().positive(),
|
|
86
|
+
maxWallS: z.number().int().positive(),
|
|
87
|
+
}),
|
|
88
|
+
kernel: z.strictObject({
|
|
89
|
+
/** Sealed working-tree patterns; may only grow until a promote reseals. */
|
|
90
|
+
immutableGlobs: z.array(z.string().min(1)),
|
|
91
|
+
}),
|
|
92
|
+
requires: z
|
|
93
|
+
.array(z.strictObject({
|
|
94
|
+
cmd: z.string().min(1),
|
|
95
|
+
args: z.array(z.string()).optional(),
|
|
96
|
+
/** Exit code that counts as "engine present" for the one-shot probe. */
|
|
97
|
+
probeExit: z.number().int().nonnegative(),
|
|
98
|
+
}))
|
|
99
|
+
.optional(),
|
|
100
|
+
opencodeBinVersion: z.strictObject({ minVersion: z.string().min(1) }).optional(),
|
|
101
|
+
bundle: bundleSectionSchema.optional(),
|
|
102
|
+
});
|
|
103
|
+
function keyPathOf(path) {
|
|
104
|
+
return path.length === 0 ? "<root>" : path.map(String).join(".");
|
|
105
|
+
}
|
|
106
|
+
function formatIssue(issue) {
|
|
107
|
+
if (issue.code === "unrecognized_keys") {
|
|
108
|
+
const listed = issue.keys.map((key) => `"${key}"`).join(", ");
|
|
109
|
+
return `unknown ${issue.keys.length === 1 ? "key" : "keys"} ${listed} at "${keyPathOf(issue.path)}" — GenomeSpec is strict`;
|
|
110
|
+
}
|
|
111
|
+
return `invalid value at "${keyPathOf(issue.path)}": ${issue.message}`;
|
|
112
|
+
}
|
|
113
|
+
/** zod issues → one line per issue, each NAMING the offending key path (hr discipline). */
|
|
114
|
+
export function formatZodIssues(issues) {
|
|
115
|
+
return issues.map(formatIssue);
|
|
116
|
+
}
|
|
117
|
+
export function errorText(cause) {
|
|
118
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
119
|
+
}
|
|
120
|
+
// ------------------------------------------------ env-literal repo paths (todo 11)
|
|
121
|
+
/**
|
|
122
|
+
* `${VAR}` repo-path literal: the abathur-self seed stores repoPath as the
|
|
123
|
+
* UNRESOLVED literal `${ABATHUR_SELF_REPO}` so the spec bytes — and therefore
|
|
124
|
+
* its content fingerprint and registry stem — stay machine-independent
|
|
125
|
+
* (graft matching requires identical specs across machines). The env var is
|
|
126
|
+
* resolved ONLY at FS boundaries, never inside the spec.
|
|
127
|
+
*/
|
|
128
|
+
const ENV_REPO_LITERAL = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
129
|
+
/** True when the spec stores an unresolved `${VAR}` env literal (self-genome marker). */
|
|
130
|
+
export function isEnvRepoLiteral(repoPath) {
|
|
131
|
+
return ENV_REPO_LITERAL.test(repoPath);
|
|
132
|
+
}
|
|
133
|
+
/** The literal itself when self, else null. */
|
|
134
|
+
export function envRepoName(repoPath) {
|
|
135
|
+
const match = ENV_REPO_LITERAL.exec(repoPath);
|
|
136
|
+
return match?.[1] ?? null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Resolve a spec repoPath to a filesystem path. Non-literals behave exactly as
|
|
140
|
+
* `path.resolve` before; `${VAR}` literals demand the env var to be set (missing
|
|
141
|
+
* or empty ⇒ clean exit 2 naming the variable — never a silently-created
|
|
142
|
+
* directory named `${ABATHUR_SELF_REPO}`).
|
|
143
|
+
*/
|
|
144
|
+
export function effectiveRepoPath(repoPath, env = process.env) {
|
|
145
|
+
const name = envRepoName(repoPath);
|
|
146
|
+
if (name === null)
|
|
147
|
+
return path.resolve(repoPath);
|
|
148
|
+
const value = env[name];
|
|
149
|
+
if (value === undefined || value.length === 0) {
|
|
150
|
+
cannotAnswer(`genome: repoPath literal '\${${name}}' requires env var ${name} to be set (absolute path to the harness repo)`, `export ${name}=/path/to/abathur before running genomes that reference the harness itself`);
|
|
151
|
+
}
|
|
152
|
+
return path.resolve(value);
|
|
153
|
+
}
|
|
154
|
+
/** Parse + strict-validate a GenomeSpec document; every failure is a clean exit 2. */
|
|
155
|
+
export function parseGenomeSpecDocument(document, origin) {
|
|
156
|
+
const result = genomeSpecSchema.safeParse(document);
|
|
157
|
+
if (result.success)
|
|
158
|
+
return result.data;
|
|
159
|
+
cannotAnswer(`genome: invalid spec in ${origin}:\n - ${formatZodIssues(result.error.issues).join("\n - ")}`);
|
|
160
|
+
}
|
|
161
|
+
/** Read a `<file>.jsonc` GenomeSpec: malformed JSONC or schema violation ⇒ exit 2. */
|
|
162
|
+
export function loadGenomeSpecFile(filePath) {
|
|
163
|
+
let source;
|
|
164
|
+
try {
|
|
165
|
+
source = readFileSync(filePath, "utf8");
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return cannotAnswer(`genome: cannot read spec file ${filePath}`);
|
|
169
|
+
}
|
|
170
|
+
let document;
|
|
171
|
+
try {
|
|
172
|
+
document = parseJsonc(source);
|
|
173
|
+
}
|
|
174
|
+
catch (cause) {
|
|
175
|
+
return cannotAnswer(`genome: malformed JSONC in ${filePath}: ${errorText(cause)}`);
|
|
176
|
+
}
|
|
177
|
+
return parseGenomeSpecDocument(document, filePath);
|
|
178
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Student-t critical values, implemented from scratch (plan todo 7): the
|
|
2
|
+
// dependency-free inverse-t via bisection on the regularized incomplete beta
|
|
3
|
+
// (Numerical-Recipes-style Lanczos gamma + Lentz continued fraction) — a new
|
|
4
|
+
// runtime dep is forbidden by the plan. Accuracy: |t − table| < 1e-3 for the
|
|
5
|
+
// t-distribution rows checked in stats.test.ts (df=1..30, α=0.05).
|
|
6
|
+
//
|
|
7
|
+
// Split out of stats.ts (pure-LOC ceiling); stats.ts re-exports studentTQuantile
|
|
8
|
+
// so consumers import it from "../core/stats.js" unchanged.
|
|
9
|
+
const BISECTION_ITERATIONS = 60;
|
|
10
|
+
const LANCZOS = [
|
|
11
|
+
0.99999999999980993, 676.5203681218851, -1259.1392167224028, 771.32342877765313,
|
|
12
|
+
-176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6,
|
|
13
|
+
1.5056327351493116e-7,
|
|
14
|
+
];
|
|
15
|
+
// Lanczos g=7 pairs with the 9 coefficients (c0..c8) above.
|
|
16
|
+
const LANCZOS_G = 7;
|
|
17
|
+
function logGamma(z) {
|
|
18
|
+
let x = z - 1;
|
|
19
|
+
let acc = LANCZOS[0];
|
|
20
|
+
for (let i = 1; i < LANCZOS.length; i++) {
|
|
21
|
+
acc += LANCZOS[i] / (x + i);
|
|
22
|
+
}
|
|
23
|
+
const t = x + LANCZOS_G + 0.5;
|
|
24
|
+
return 0.5 * Math.log(2 * Math.PI) + (x + 0.5) * Math.log(t) - t + Math.log(acc);
|
|
25
|
+
}
|
|
26
|
+
/** Continued fraction for the regularized incomplete beta (Lentz, NR 6.4.2). */
|
|
27
|
+
function betacf(a, b, x) {
|
|
28
|
+
const MAXIT = 200;
|
|
29
|
+
const EPS = 3e-14;
|
|
30
|
+
const FPMIN = 1e-300;
|
|
31
|
+
const qab = a + b;
|
|
32
|
+
const qap = a + 1;
|
|
33
|
+
const qam = a - 1;
|
|
34
|
+
let c = 1;
|
|
35
|
+
let d = 1 - (qab * x) / qap;
|
|
36
|
+
if (Math.abs(d) < FPMIN)
|
|
37
|
+
d = FPMIN;
|
|
38
|
+
d = 1 / d;
|
|
39
|
+
let h = d;
|
|
40
|
+
for (let m = 1; m <= MAXIT; m++) {
|
|
41
|
+
const m2 = 2 * m;
|
|
42
|
+
let aa = (m * (b - m) * x) / ((qam + m2) * (a + m2));
|
|
43
|
+
d = 1 + aa * d;
|
|
44
|
+
if (Math.abs(d) < FPMIN)
|
|
45
|
+
d = FPMIN;
|
|
46
|
+
c = 1 + aa / c;
|
|
47
|
+
if (Math.abs(c) < FPMIN)
|
|
48
|
+
c = FPMIN;
|
|
49
|
+
d = 1 / d;
|
|
50
|
+
h *= d * c;
|
|
51
|
+
aa = (-(a + m) * (qab + m) * x) / ((a + m2) * (qap + m2));
|
|
52
|
+
d = 1 + aa * d;
|
|
53
|
+
if (Math.abs(d) < FPMIN)
|
|
54
|
+
d = FPMIN;
|
|
55
|
+
c = 1 + aa / c;
|
|
56
|
+
if (Math.abs(c) < FPMIN)
|
|
57
|
+
c = FPMIN;
|
|
58
|
+
d = 1 / d;
|
|
59
|
+
const del = d * c;
|
|
60
|
+
h *= del;
|
|
61
|
+
if (Math.abs(del - 1) < EPS)
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
return h;
|
|
65
|
+
}
|
|
66
|
+
/** Regularized incomplete beta I_x(a, b), b=1/2 in every call here. */
|
|
67
|
+
function betai(a, b, x) {
|
|
68
|
+
if (x <= 0)
|
|
69
|
+
return 0;
|
|
70
|
+
if (x >= 1)
|
|
71
|
+
return 1;
|
|
72
|
+
const front = Math.exp(logGamma(a + b) - logGamma(a) - logGamma(b) + a * Math.log(x) + b * Math.log(1 - x));
|
|
73
|
+
if (x < (a + 1) / (a + b + 2))
|
|
74
|
+
return (front * betacf(a, b, x)) / a;
|
|
75
|
+
return 1 - (front * betacf(b, a, 1 - x)) / b;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Two-sided Student-t critical value: the t such that P(|T| ≤ t) = 1 − alpha
|
|
79
|
+
* with T ~ t(df). Planned accuracy: matches t-table values to < 1e-3 for the
|
|
80
|
+
* checked rows (stats.test.ts). alpha in (0,1), df ≥ 1.
|
|
81
|
+
*/
|
|
82
|
+
export function studentTQuantile(alpha, df) {
|
|
83
|
+
if (!(alpha > 0 && alpha < 1))
|
|
84
|
+
throw new RangeError(`studentTQuantile: alpha=${String(alpha)} must be in (0, 1)`);
|
|
85
|
+
if (!(df >= 1))
|
|
86
|
+
throw new RangeError(`studentTQuantile: df=${String(df)} must be >= 1`);
|
|
87
|
+
// T ~ t(ν): P(T ≤ t) = 1 − ½·I_{ν/(ν+t²)}(ν/2, ½). For the two-sided critical
|
|
88
|
+
// value I_{ν/(ν+t²)}(ν/2, ½) = alpha ⇒ t = √(ν(1−z)/z) with z = ν/(ν+t²).
|
|
89
|
+
const a = df / 2;
|
|
90
|
+
const b = 0.5;
|
|
91
|
+
let lo = 0;
|
|
92
|
+
let hi = 1;
|
|
93
|
+
for (let i = 0; i < BISECTION_ITERATIONS; i++) {
|
|
94
|
+
const mid = (lo + hi) / 2;
|
|
95
|
+
if (betai(a, b, mid) < alpha)
|
|
96
|
+
lo = mid;
|
|
97
|
+
else
|
|
98
|
+
hi = mid;
|
|
99
|
+
}
|
|
100
|
+
const z = (lo + hi) / 2;
|
|
101
|
+
return Math.sqrt((df * (1 - z)) / z);
|
|
102
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Pareto-frontier ranking of complete generations (plan todo 7): the human-facing
|
|
2
|
+
// rank list over (trainScore, tokens, wallS), deterministic per seed. Split out
|
|
3
|
+
// of stats.ts (pure-LOC ceiling); stats.ts re-exports seededRandom, paretoFrontier
|
|
4
|
+
// and the ParetoPoint type, so consumers import them from "../core/stats.js"
|
|
5
|
+
// unchanged.
|
|
6
|
+
/** mulberry32 — dependency-free deterministic PRNG for tie-breaks. */
|
|
7
|
+
export function seededRandom(seed) {
|
|
8
|
+
let state = seed >>> 0;
|
|
9
|
+
return () => {
|
|
10
|
+
state = (state + 0x6d2b79f5) | 0;
|
|
11
|
+
let t = state;
|
|
12
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
13
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
14
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function dominates(q, p) {
|
|
18
|
+
return q.trainScore >= p.trainScore && q.tokens <= p.tokens && q.wallS <= p.wallS && (q.trainScore > p.trainScore || q.tokens < p.tokens || q.wallS < p.wallS);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Non-dominated complete candidates, ranked for the human: trainScore desc,
|
|
22
|
+
* then tokens asc, then wallS asc; exact ties share a seeded rank order
|
|
23
|
+
* (same seed ⇒ same order). Budget-truncated generations never enter the set.
|
|
24
|
+
*/
|
|
25
|
+
export function paretoFrontier(points, seed = 1) {
|
|
26
|
+
const complete = points.filter((p) => p.complete);
|
|
27
|
+
const frontier = complete.filter((p) => !complete.some((q) => dominates(q, p)));
|
|
28
|
+
frontier.sort((a, b) => {
|
|
29
|
+
if (a.trainScore !== b.trainScore)
|
|
30
|
+
return b.trainScore - a.trainScore;
|
|
31
|
+
if (a.tokens !== b.tokens)
|
|
32
|
+
return a.tokens - b.tokens;
|
|
33
|
+
return a.wallS - b.wallS;
|
|
34
|
+
});
|
|
35
|
+
const rng = seededRandom(seed);
|
|
36
|
+
const ids = [];
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < frontier.length) {
|
|
39
|
+
let j = i + 1;
|
|
40
|
+
while (j < frontier.length &&
|
|
41
|
+
frontier[j].trainScore === frontier[i].trainScore &&
|
|
42
|
+
frontier[j].tokens === frontier[i].tokens &&
|
|
43
|
+
frontier[j].wallS === frontier[i].wallS) {
|
|
44
|
+
j += 1;
|
|
45
|
+
}
|
|
46
|
+
const group = frontier.slice(i, j);
|
|
47
|
+
// Fisher–Yates shuffle: deterministic per seed.
|
|
48
|
+
for (let k = group.length - 1; k > 0; k--) {
|
|
49
|
+
const r = Math.floor(rng() * (k + 1));
|
|
50
|
+
[group[k], group[r]] = [group[r], group[k]];
|
|
51
|
+
}
|
|
52
|
+
for (const p of group)
|
|
53
|
+
ids.push(p.id);
|
|
54
|
+
i = j;
|
|
55
|
+
}
|
|
56
|
+
return ids;
|
|
57
|
+
}
|