forgedock 1.31.0 → 1.32.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/bin/engine-cli.mjs +51 -9
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/lean/batch.test.mjs +44 -1
- package/bin/lean/context.mjs +12 -1
- package/bin/lean/context.test.mjs +18 -1
- package/bin/lean/effects.mjs +13 -2
- package/bin/lean/lean.mjs +67 -15
- package/bin/lean/lean.test.mjs +177 -1
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/orchestrate.mjs +45 -6
- package/bin/lean/precheck.test.mjs +5 -0
- package/bin/lean/prefilter.mjs +69 -8
- package/bin/lean/prefilter.test.mjs +109 -2
- package/bin/lean/prompts.mjs +3 -0
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +48 -0
- package/bin/lean/schedd.test.mjs +21 -0
- package/bin/stats/efficiency.mjs +22 -6
- package/bin/stats/efficiency.test.mjs +35 -2
- package/bin/stats/index.mjs +25 -3
- package/bin/stats/local-source.mjs +59 -5
- package/bin/stats/query.mjs +133 -3
- package/bin/stats/stats.test.mjs +181 -5
- package/docs/CONFIG.md +3 -3
- package/docs/STATS.md +13 -0
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
/**
|
|
5
|
+
* scripts/bench-lightlane.mjs — three-way benchmark: light lane vs full lane vs a plain single-session control (#4453)
|
|
6
|
+
*
|
|
7
|
+
* Runs the same issue set through three arms and grades every arm with the upstream oracle tests (the test files of
|
|
8
|
+
* the real upstream fix, overlaid on the arm's head). Zero dependencies; every process call goes through an
|
|
9
|
+
* injectable `run`, so tests use fakes. Instructions: docs/eval/lightlane-bench.md. Corpus: docs/eval/corpus/.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/bench-lightlane.mjs calibrate --corpus <corpus.json>
|
|
13
|
+
* node scripts/bench-lightlane.mjs run --corpus <corpus.json> --config <runcfg.json> --out <rows.jsonl> [--arms light,full,control] [--issues id,...] [--keep]
|
|
14
|
+
* node scripts/bench-lightlane.mjs aggregate <rows.jsonl|->
|
|
15
|
+
*
|
|
16
|
+
* Arms:
|
|
17
|
+
* light, full: `forgedock run-issue <n> --repo <sandbox> --result-file <f>` on a sandbox repo whose forge.yaml sets
|
|
18
|
+
* lean.light.mode on (light) or off (full). Cost is the run result's cost_usd (the sum of the #4407 per-session
|
|
19
|
+
* deltas); model time is model_ms (summed session time, #4451).
|
|
20
|
+
* control: `claude -p <prompt> --output-format json --model <model>` in a fresh clone at the base; cost is
|
|
21
|
+
* total_cost_usd, wall and model time are duration_ms (one session).
|
|
22
|
+
*
|
|
23
|
+
* The harness never writes to GitHub itself; the only GitHub writes are those of the light/full arms' own run-issue.
|
|
24
|
+
*
|
|
25
|
+
* Exit codes: 0 ok | 1 invalid input or a failed run step | 2 a calibration or a target failed (output still emitted)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
30
|
+
import { tmpdir } from "node:os";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { pathToFileURL } from "node:url";
|
|
33
|
+
import { detectApiShape } from "../bin/lean/apishape.mjs";
|
|
34
|
+
|
|
35
|
+
export const ARMS = ["light", "full", "control"];
|
|
36
|
+
export const TARGETS = Object.freeze({ cost_vs_control: 1.2, wall_vs_model: 1.3 });
|
|
37
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
38
|
+
const CLAIMED_REASONS = new Set(["merged", "ci-pending"]);
|
|
39
|
+
const OUT_CAP = 1 << 20;
|
|
40
|
+
|
|
41
|
+
/** Spawn `cmd args` and collect its output (the last 1 MiB of each stream); never throws. */
|
|
42
|
+
export function benchDefaultRun(cmd, args, { cwd, env } = {}) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
let stdout = "", stderr = "";
|
|
45
|
+
const cap = (s) => (s.length > OUT_CAP ? s.slice(-OUT_CAP) : s);
|
|
46
|
+
let p;
|
|
47
|
+
try { p = spawn(cmd, args, { cwd, env: env ?? process.env, stdio: ["ignore", "pipe", "pipe"] }); } catch (e) { resolve({ code: 127, stdout: "", stderr: String(e?.message ?? e) }); return; }
|
|
48
|
+
p.stdout.on("data", (d) => { stdout = cap(stdout + d); });
|
|
49
|
+
p.stderr.on("data", (d) => { stderr = cap(stderr + d); });
|
|
50
|
+
p.on("error", (e) => resolve({ code: e?.code === "ENOENT" ? 127 : 1, stdout, stderr: stderr + String(e?.message ?? e) }));
|
|
51
|
+
p.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A parsed corpus, or a thrown Error naming the first bad field. */
|
|
56
|
+
export function validateCorpus(c) {
|
|
57
|
+
if (!c || typeof c !== "object") throw new Error("corpus: not an object");
|
|
58
|
+
for (const k of ["clone_url", "suite_command", "control_prompt"]) if (typeof c[k] !== "string" || !c[k].trim()) throw new Error(`corpus: missing ${k}`);
|
|
59
|
+
if (!SHA_RE.test(String(c.base))) throw new Error("corpus: base must be a full 40-hex sha");
|
|
60
|
+
if (!Array.isArray(c.issues) || !c.issues.length) throw new Error("corpus: issues must be a non-empty array");
|
|
61
|
+
const ids = new Set();
|
|
62
|
+
for (const i of c.issues) {
|
|
63
|
+
if (!i?.id || ids.has(i.id)) throw new Error(`corpus: missing or duplicate issue id ${i?.id}`);
|
|
64
|
+
ids.add(i.id);
|
|
65
|
+
if (!SHA_RE.test(String(i.fix_sha))) throw new Error(`corpus: ${i.id}: fix_sha must be a full 40-hex sha`);
|
|
66
|
+
if (typeof i.title !== "string" || typeof i.body !== "string") throw new Error(`corpus: ${i.id}: title and body are required`);
|
|
67
|
+
if (!Array.isArray(i.oracle?.files) || !i.oracle.files.length || typeof i.oracle.command !== "string") throw new Error(`corpus: ${i.id}: oracle {files, command} is required`);
|
|
68
|
+
}
|
|
69
|
+
return c;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The run config for `arms`, or a thrown Error: light/full need {repo, issues: {<corpus id>: n}}, control needs {model}. */
|
|
73
|
+
export function validateRunConfig(cfg, corpus, arms) {
|
|
74
|
+
const a = cfg?.arms;
|
|
75
|
+
if (!a || typeof a !== "object") throw new Error("config: missing arms");
|
|
76
|
+
for (const arm of arms) {
|
|
77
|
+
const x = a[arm];
|
|
78
|
+
if (!x) throw new Error(`config: missing arms.${arm}`);
|
|
79
|
+
if (arm === "control") { if (typeof x.model !== "string" || !x.model) throw new Error("config: arms.control.model is required"); continue; }
|
|
80
|
+
if (typeof x.repo !== "string" || !/^[\w.-]+\/[\w.-]+$/.test(x.repo)) throw new Error(`config: arms.${arm}.repo must be owner/name`);
|
|
81
|
+
for (const i of corpus.issues) if (!Number.isSafeInteger(x.issues?.[i.id]) || x.issues[i.id] <= 0) throw new Error(`config: arms.${arm}.issues.${i.id} must be the sandbox issue number`);
|
|
82
|
+
}
|
|
83
|
+
return cfg;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The control prompt for an issue: `{title}` and `{body}` replaced. */
|
|
87
|
+
export const fillPrompt = (tpl, issue) => String(tpl).replaceAll("{title}", issue.title).replaceAll("{body}", issue.body);
|
|
88
|
+
|
|
89
|
+
const sh = (run, command, cwd) => run("sh", ["-c", command], { cwd });
|
|
90
|
+
async function git(run, args, cwd) {
|
|
91
|
+
const r = await run("git", args, { cwd });
|
|
92
|
+
if (r.code !== 0) throw new Error(`git ${args.join(" ")} failed (${r.code}): ${String(r.stderr).trim().slice(0, 300)}`);
|
|
93
|
+
return String(r.stdout).trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Overlay the oracle test files from the fix commit onto the checkout in `dir` and run the oracle; true when it passes. */
|
|
97
|
+
async function runOracle(run, dir, issue) {
|
|
98
|
+
await git(run, ["checkout", "-q", issue.fix_sha, "--", ...issue.oracle.files], dir);
|
|
99
|
+
return (await sh(run, issue.oracle.command, dir)).code === 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** For each corpus issue: the oracle must fail on the base and pass on the fix. -> [{id, red_on_base, green_on_fix}] */
|
|
103
|
+
export async function calibrate({ corpus, run = benchDefaultRun, tmp }) {
|
|
104
|
+
const dir = join(tmp, "upstream");
|
|
105
|
+
await git(run, ["clone", "-q", corpus.clone_url, dir], tmp);
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const issue of corpus.issues) {
|
|
108
|
+
await git(run, ["checkout", "-q", "-f", corpus.base], dir);
|
|
109
|
+
const red = !(await runOracle(run, dir, issue));
|
|
110
|
+
await git(run, ["checkout", "-q", "-f", issue.fix_sha], dir);
|
|
111
|
+
const green = (await sh(run, issue.oracle.command, dir)).code === 0;
|
|
112
|
+
out.push({ id: issue.id, red_on_base: red, green_on_fix: green });
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const num = (x) => (Number.isFinite(Number(x)) && x !== null && x !== "" ? Number(x) : null);
|
|
118
|
+
const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
119
|
+
|
|
120
|
+
/** Grade `head` from the repo in `src`: own suite, then the oracle overlay, then the API shape from `base`. */
|
|
121
|
+
async function grade({ run, src, head, base, corpus, issue, tmp, tag, apiShape }) {
|
|
122
|
+
await git(run, ["branch", "-f", "bench-head", head], src);
|
|
123
|
+
const dir = join(tmp, `${tag}-grade`);
|
|
124
|
+
await git(run, ["clone", "-q", "-b", "bench-head", src, dir], tmp);
|
|
125
|
+
await git(run, ["fetch", "-q", corpus.clone_url, issue.fix_sha], dir);
|
|
126
|
+
const changed = (await run("git", ["diff", "--quiet", base, "HEAD"], { cwd: dir })).code === 1;
|
|
127
|
+
let shape;
|
|
128
|
+
try { const r = await apiShape({ cwd: dir, base, head: "HEAD" }); shape = { status: r?.status ?? "unknown", changes: Array.isArray(r?.changes) ? r.changes : [] }; }
|
|
129
|
+
catch (e) { shape = { status: "error", changes: [], error: String(e?.message ?? e).slice(0, 200) }; }
|
|
130
|
+
const suite = (await sh(run, corpus.suite_command, dir)).code === 0;
|
|
131
|
+
const oracle = await runOracle(run, dir, issue);
|
|
132
|
+
return { changed, suite_pass: suite, oracle_pass: oracle, api_shape: shape };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Run one arm on one corpus issue and grade it -> one row (see aggregate). `now` and `apiShape` are injectable. */
|
|
136
|
+
export async function runArm({ arm, issue, corpus, cfg, run = benchDefaultRun, tmp, now = () => Date.now(), apiShape = detectApiShape }) {
|
|
137
|
+
const tag = `${arm}-${issue.id}`, a = cfg.arms[arm];
|
|
138
|
+
const row = { arm, id: issue.id, cost_usd: null, wall_ms: null, model_ms: null, harness_wall_ms: null, claimed_fix: false, oracle_pass: false, suite_pass: null,
|
|
139
|
+
api_shape: null, light: null, light_eligibility: null, light_upgrade: null, terminal: null };
|
|
140
|
+
const t0 = now();
|
|
141
|
+
let src = null, head = null, base = corpus.base, claimed = false;
|
|
142
|
+
if (arm === "control") {
|
|
143
|
+
src = join(tmp, `${tag}-work`);
|
|
144
|
+
await git(run, ["clone", "-q", corpus.clone_url, src], tmp);
|
|
145
|
+
await git(run, ["checkout", "-q", "-b", "bench-control", corpus.base], src);
|
|
146
|
+
const [bin, ...pre] = Array.isArray(a.command) && a.command.length ? a.command : ["claude"];
|
|
147
|
+
const r = await run(bin, [...pre, "-p", fillPrompt(corpus.control_prompt, issue), "--output-format", "json", "--model", a.model, ...(a.args ?? [])], { cwd: src });
|
|
148
|
+
row.harness_wall_ms = now() - t0;
|
|
149
|
+
let j = null;
|
|
150
|
+
try { j = JSON.parse(String(r.stdout).trim().split("\n").pop()); } catch { /* reported as terminal below */ }
|
|
151
|
+
row.cost_usd = num(j?.total_cost_usd); row.wall_ms = num(j?.duration_ms); row.model_ms = num(j?.duration_ms);
|
|
152
|
+
row.terminal = r.code !== 0 ? `exit ${r.code}` : !j ? "no-json" : j.is_error ? "error" : "ok";
|
|
153
|
+
claimed = row.terminal === "ok";
|
|
154
|
+
await git(run, ["add", "-A"], src);
|
|
155
|
+
await git(run, ["-c", "user.name=bench", "-c", "user.email=bench@localhost", "commit", "-q", "--allow-empty", "--no-verify", "-m", `control ${issue.id}`], src);
|
|
156
|
+
head = await git(run, ["rev-parse", "HEAD"], src);
|
|
157
|
+
} else {
|
|
158
|
+
const n = a.issues[issue.id], rf = join(tmp, `${tag}-result.json`);
|
|
159
|
+
const [bin, ...pre] = Array.isArray(a.command) && a.command.length ? a.command : ["forgedock", "run-issue"];
|
|
160
|
+
const r = await run(bin, [...pre, String(n), "--repo", a.repo, "--result-file", rf, ...(a.args ?? [])], {});
|
|
161
|
+
row.harness_wall_ms = now() - t0;
|
|
162
|
+
const res = readJson(rf);
|
|
163
|
+
row.terminal = res?.terminalReason ?? `exit ${r.code}`;
|
|
164
|
+
row.cost_usd = num(res?.cost_usd); row.wall_ms = num(res?.wall_ms); row.model_ms = num(res?.model_ms);
|
|
165
|
+
row.light = typeof res?.light === "boolean" ? res.light : null;
|
|
166
|
+
row.light_eligibility = res?.light_eligibility ?? null;
|
|
167
|
+
row.light_upgrade = Array.isArray(res?.light_upgrade) ? res.light_upgrade : null;
|
|
168
|
+
claimed = CLAIMED_REASONS.has(row.terminal);
|
|
169
|
+
base = a.base ?? corpus.base;
|
|
170
|
+
src = join(tmp, `${tag}-src`);
|
|
171
|
+
await git(run, ["clone", "-q", a.clone_url ?? `https://github.com/${a.repo}.git`, src], tmp);
|
|
172
|
+
if (res?.pr) {
|
|
173
|
+
const pr = String(res.pr).match(/(\d+)\s*$/)?.[1];
|
|
174
|
+
const v = await run("gh", ["pr", "view", pr, "-R", a.repo, "--json", "headRefOid", "--jq", ".headRefOid"], {});
|
|
175
|
+
await git(run, ["fetch", "-q", "origin", `refs/pull/${pr}/head`], src);
|
|
176
|
+
head = SHA_RE.test(String(v.stdout).trim()) ? String(v.stdout).trim() : await git(run, ["rev-parse", "FETCH_HEAD"], src);
|
|
177
|
+
} else if (res?.branch) {
|
|
178
|
+
await git(run, ["fetch", "-q", "origin", res.branch], src);
|
|
179
|
+
head = await git(run, ["rev-parse", "FETCH_HEAD"], src);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!head) return row; // no PR and no branch: nothing to grade; oracle_pass stays false
|
|
183
|
+
const g = await grade({ run, src, head, base, corpus, issue, tmp, tag, apiShape });
|
|
184
|
+
return { ...row, claimed_fix: claimed && g.changed, oracle_pass: g.oracle_pass, suite_pass: g.suite_pass, api_shape: g.api_shape };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const sum = (xs) => xs.reduce((s, x) => s + x, 0);
|
|
188
|
+
const nums = (rows, k) => rows.map((r) => r[k]).filter((x) => Number.isFinite(x));
|
|
189
|
+
const rate = (rows, f) => (rows.length ? rows.filter(f).length / rows.length : null);
|
|
190
|
+
const r4 = (x) => (x == null ? null : Math.round(x * 10000) / 10000);
|
|
191
|
+
|
|
192
|
+
/** Per-arm metrics over rows (pure). */
|
|
193
|
+
export function armMetrics(rows) {
|
|
194
|
+
const cost = nums(rows, "cost_usd"), wall = nums(rows, "wall_ms"), model = nums(rows, "model_ms");
|
|
195
|
+
return {
|
|
196
|
+
n: rows.length,
|
|
197
|
+
cost_usd: { sum: r4(sum(cost)), mean: cost.length ? r4(sum(cost) / cost.length) : null },
|
|
198
|
+
wall_ms: sum(wall), model_ms: sum(model), wall_over_model: sum(model) > 0 ? r4(sum(wall) / sum(model)) : null,
|
|
199
|
+
oracle_pass_rate: r4(rate(rows, (r) => r.oracle_pass === true)),
|
|
200
|
+
false_fix_rate: r4(rate(rows, (r) => r.claimed_fix === true && r.oracle_pass !== true)),
|
|
201
|
+
suite_pass_rate: r4(rate(rows, (r) => r.suite_pass === true)),
|
|
202
|
+
api_shape_changed: rows.filter((r) => r.api_shape?.status === "changed").length,
|
|
203
|
+
undetected_api_shape: rows.filter((r) => r.arm === "light" && r.light === true && r.api_shape?.status === "changed").length,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Rows -> {arms, light_eligible, targets} (pure). Cost and wall are judged on the light rows whose eligibility verdict is
|
|
209
|
+
* `eligible` (cost against the control rows of the same issues); oracle, false fix and undetected API shape on all light
|
|
210
|
+
* rows. A target is {pass: true|false|null, value, limit}; null when an arm it needs has no rows.
|
|
211
|
+
*/
|
|
212
|
+
export function aggregate(rows) {
|
|
213
|
+
const by = Object.fromEntries(ARMS.map((a) => [a, rows.filter((r) => r.arm === a)]));
|
|
214
|
+
const arms = Object.fromEntries(ARMS.map((a) => [a, armMetrics(by[a])]));
|
|
215
|
+
const elig = by.light.filter((r) => r.light_eligibility === "eligible");
|
|
216
|
+
const eligIds = new Set(elig.map((r) => r.id));
|
|
217
|
+
const ctlElig = by.control.filter((r) => eligIds.has(r.id));
|
|
218
|
+
const le = armMetrics(elig), ce = armMetrics(ctlElig), L = arms.light, C = arms.control;
|
|
219
|
+
const t = (ok, value, limit) => ({ pass: ok == null ? null : ok, value, limit });
|
|
220
|
+
const costRatio = elig.length && ctlElig.length && ce.cost_usd.sum > 0 ? r4(le.cost_usd.sum / ce.cost_usd.sum) : null;
|
|
221
|
+
const targets = {
|
|
222
|
+
cost_le_1_2x_control: t(costRatio == null ? null : costRatio <= TARGETS.cost_vs_control, costRatio, TARGETS.cost_vs_control),
|
|
223
|
+
wall_le_1_3x_model: t(le.wall_over_model == null ? null : le.wall_over_model <= TARGETS.wall_vs_model, le.wall_over_model, TARGETS.wall_vs_model),
|
|
224
|
+
oracle_ge_control: t(L.n && C.n ? L.oracle_pass_rate >= C.oracle_pass_rate : null, L.oracle_pass_rate, C.oracle_pass_rate),
|
|
225
|
+
false_fix_le_control: t(L.n && C.n ? L.false_fix_rate <= C.false_fix_rate : null, L.false_fix_rate, C.false_fix_rate),
|
|
226
|
+
zero_undetected_api_shape: t(L.n ? L.undetected_api_shape === 0 : null, L.undetected_api_shape, 0),
|
|
227
|
+
};
|
|
228
|
+
return { arms, light_eligible: { ...le, control_cost_usd: ce.cost_usd.sum, ids: [...eligIds].sort() }, targets };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** JSONL (or a JSON array) -> rows. */
|
|
232
|
+
export function parseRows(text) {
|
|
233
|
+
const s = String(text).trim();
|
|
234
|
+
if (!s) return [];
|
|
235
|
+
if (s.startsWith("[")) return JSON.parse(s);
|
|
236
|
+
return s.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const flag = (argv, name) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : null; };
|
|
240
|
+
const list = (v, all) => (v ? v.split(",").map((s) => s.trim()).filter(Boolean) : all);
|
|
241
|
+
|
|
242
|
+
export async function main(argv = process.argv.slice(2), { run = benchDefaultRun, out = process.stdout, err = process.stderr } = {}) {
|
|
243
|
+
const cmd = argv[0];
|
|
244
|
+
try {
|
|
245
|
+
if (cmd === "aggregate") {
|
|
246
|
+
const src = argv[1];
|
|
247
|
+
const text = !src || src === "-" ? readFileSync(0, "utf8") : readFileSync(src, "utf8");
|
|
248
|
+
const agg = aggregate(parseRows(text));
|
|
249
|
+
out.write(JSON.stringify(agg, null, 2) + "\n");
|
|
250
|
+
return Object.values(agg.targets).some((x) => x.pass === false) ? 2 : 0;
|
|
251
|
+
}
|
|
252
|
+
if (cmd !== "calibrate" && cmd !== "run") { err.write("usage: bench-lightlane.mjs calibrate|run|aggregate (see the header)\n"); return 1; }
|
|
253
|
+
const cp = flag(argv, "--corpus");
|
|
254
|
+
if (!cp) throw new Error("--corpus is required");
|
|
255
|
+
const corpus = validateCorpus(JSON.parse(readFileSync(cp, "utf8")));
|
|
256
|
+
const tmp = mkdtempSync(join(tmpdir(), "bench-lightlane-"));
|
|
257
|
+
try {
|
|
258
|
+
if (cmd === "calibrate") {
|
|
259
|
+
const res = await calibrate({ corpus, run, tmp });
|
|
260
|
+
out.write(JSON.stringify(res, null, 2) + "\n");
|
|
261
|
+
return res.every((r) => r.red_on_base && r.green_on_fix) ? 0 : 2;
|
|
262
|
+
}
|
|
263
|
+
const arms = list(flag(argv, "--arms"), ARMS), rowsOut = flag(argv, "--out"), cfgPath = flag(argv, "--config");
|
|
264
|
+
if (!rowsOut || !cfgPath) throw new Error("--config and --out are required");
|
|
265
|
+
const bad = arms.filter((a) => !ARMS.includes(a));
|
|
266
|
+
if (bad.length) throw new Error(`unknown arm(s): ${bad.join(", ")}`);
|
|
267
|
+
const cfg = validateRunConfig(JSON.parse(readFileSync(cfgPath, "utf8")), corpus, arms);
|
|
268
|
+
const ids = list(flag(argv, "--issues"), corpus.issues.map((i) => i.id));
|
|
269
|
+
for (const issue of corpus.issues.filter((i) => ids.includes(i.id))) {
|
|
270
|
+
for (const arm of arms) {
|
|
271
|
+
const row = await runArm({ arm, issue, corpus, cfg, run, tmp });
|
|
272
|
+
appendFileSync(rowsOut, JSON.stringify(row) + "\n");
|
|
273
|
+
err.write(`${arm} ${issue.id}: oracle ${row.oracle_pass ? "pass" : "fail"}, $${row.cost_usd ?? "?"}, ${row.terminal}\n`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return 0;
|
|
277
|
+
} finally {
|
|
278
|
+
if (!argv.includes("--keep")) rmSync(tmp, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
} catch (e) {
|
|
281
|
+
err.write(`ERROR: ${e?.message ?? e}\n`);
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) process.exitCode = await main();
|