static-fire 0.0.0-stage → 0.54.1
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/LICENSE +21 -0
- package/README.md +236 -2
- package/bin/static-fire.js +46 -0
- package/package.json +30 -4
- package/pricing.yaml +20 -0
- package/src/adapters/index.ts +153 -0
- package/src/api.ts +250 -0
- package/src/cli.ts +383 -0
- package/src/core/apkit-install.ts +47 -0
- package/src/core/authored.ts +47 -0
- package/src/core/batch-state.ts +25 -0
- package/src/core/batch.ts +450 -0
- package/src/core/batches.ts +221 -0
- package/src/core/checks.ts +63 -0
- package/src/core/commands.ts +43 -0
- package/src/core/compare.ts +206 -0
- package/src/core/config.ts +25 -0
- package/src/core/copying.ts +18 -0
- package/src/core/coverage.ts +84 -0
- package/src/core/db.ts +608 -0
- package/src/core/derived.ts +74 -0
- package/src/core/engine.ts +85 -0
- package/src/core/facets.ts +143 -0
- package/src/core/grade.ts +197 -0
- package/src/core/grading-versions.ts +281 -0
- package/src/core/identity.ts +263 -0
- package/src/core/init.ts +321 -0
- package/src/core/judge-schema.ts +37 -0
- package/src/core/judge.ts +166 -0
- package/src/core/label.ts +43 -0
- package/src/core/lock.ts +40 -0
- package/src/core/migrate.ts +288 -0
- package/src/core/new-task.ts +85 -0
- package/src/core/outside-tools.ts +129 -0
- package/src/core/plan.ts +84 -0
- package/src/core/profiles.ts +42 -0
- package/src/core/project.ts +140 -0
- package/src/core/regrade.ts +242 -0
- package/src/core/score.ts +148 -0
- package/src/core/settings.ts +44 -0
- package/src/core/task-file.ts +95 -0
- package/src/core/task-page.ts +54 -0
- package/src/core/trial-detail.ts +73 -0
- package/src/core/types.ts +195 -0
- package/src/core/view-query.ts +102 -0
- package/src/core/view.ts +576 -0
- package/src/guide.md +624 -0
- package/src/server.ts +28 -0
- package/web/api.ts +6 -0
- package/web/app.tsx +1162 -0
- package/web/brand/offset-tokens.css +126 -0
- package/web/charts.tsx +190 -0
- package/web/compare.tsx +317 -0
- package/web/encoding.tsx +55 -0
- package/web/filter-menu.ts +195 -0
- package/web/how-it-works.tsx +92 -0
- package/web/index-switches.ts +29 -0
- package/web/index.html +17 -0
- package/web/levels.tsx +15 -0
- package/web/notices.ts +74 -0
- package/web/output-view.ts +61 -0
- package/web/pages.ts +42 -0
- package/web/run-status.ts +51 -0
- package/web/styles.css +368 -0
- package/web/task-card.ts +6 -0
- package/web/tasks.tsx +122 -0
- package/web/wording.ts +38 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Batches after they start: what `list` prints for each one, and `rerun <batch>`, which runs a batch's failed
|
|
2
|
+
// trials again in the same batch.
|
|
3
|
+
//
|
|
4
|
+
// A failed trial's row never changes. A rerun adds a new trial with the next free n for that task and config.
|
|
5
|
+
// Which failed trials still need a rerun is worked out from the rows, with no link column:
|
|
6
|
+
// - the batch asked for `want` trials of each task and config (request.trials for an agent, 1 for a human);
|
|
7
|
+
// - every trial that did not fail (queued, running, done, invalid) fills one of them;
|
|
8
|
+
// - each later trial replaces the earliest failed trial not yet replaced, so the failed trials still to rerun
|
|
9
|
+
// are the latest ones. A trial that failed and then passed on a rerun is never offered again.
|
|
10
|
+
// A queued or running trial fills its place and is never touched: the store holds no evidence that its process
|
|
11
|
+
// died (no pid, no heartbeat), and a long time alone is not proof (a laptop that sleeps keeps a live run going).
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { harnessVersions, type StartOptions, type StartedBatch, type BatchContext } from "./batch";
|
|
15
|
+
import type { BatchRecord, Store, TrialRecord, TrialRecordStatus } from "./db";
|
|
16
|
+
import { canonicalJson, configKey, gradingIdentity, sha256, taskIdentity } from "./identity";
|
|
17
|
+
import { loadSettings, userSetupFiles } from "./settings";
|
|
18
|
+
import { loadConfigs, loadJudges, loadTask } from "./authored";
|
|
19
|
+
import { projectPaths, projectRoot } from "./project";
|
|
20
|
+
import type { AgentConfig, JudgeConfig, Task } from "./types";
|
|
21
|
+
|
|
22
|
+
/** What the batch stored as its request (batch.ts BatchPlan.request). */
|
|
23
|
+
export interface BatchRequest {
|
|
24
|
+
tasks?: string[]; suites?: string[]; configs: string[]; trials: number; judge: string; votes: number; taskIds: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BatchSummary {
|
|
28
|
+
batch: BatchRecord;
|
|
29
|
+
request: BatchRequest;
|
|
30
|
+
counts: Record<TrialRecordStatus, number>;
|
|
31
|
+
/** Failed trials that no later trial has replaced yet: what `rerun` would run. */
|
|
32
|
+
toRerun: number;
|
|
33
|
+
ended: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One failed trial still to rerun. */
|
|
37
|
+
export interface RerunSlot { taskId: string; configId: string; replaces: string }
|
|
38
|
+
|
|
39
|
+
const ENDED_OK: TrialRecordStatus[] = ["queued", "running", "done", "invalid"]; // every status but failed fills a place
|
|
40
|
+
|
|
41
|
+
/** The failed trials of a batch that no later trial replaced, oldest task/config first. */
|
|
42
|
+
export function rerunSlots(request: BatchRequest, trials: TrialRecord[]): RerunSlot[] {
|
|
43
|
+
const groups = new Map<string, TrialRecord[]>();
|
|
44
|
+
for (const t of trials) {
|
|
45
|
+
const k = `${t.taskId}\0${t.configId}`;
|
|
46
|
+
groups.set(k, [...(groups.get(k) ?? []), t]);
|
|
47
|
+
}
|
|
48
|
+
const slots: RerunSlot[] = [];
|
|
49
|
+
for (const group of groups.values()) {
|
|
50
|
+
const want = group[0]!.harness === "human" ? 1 : request.trials;
|
|
51
|
+
const filled = group.filter((t) => ENDED_OK.includes(t.status)).length;
|
|
52
|
+
const failed = group.filter((t) => t.status === "failed").sort((a, b) => a.n - b.n);
|
|
53
|
+
const short = Math.min(Math.max(0, want - filled), failed.length);
|
|
54
|
+
for (const t of failed.slice(failed.length - short)) slots.push({ taskId: t.taskId, configId: t.configId, replaces: t.id });
|
|
55
|
+
}
|
|
56
|
+
return slots.sort((a, b) => (a.replaces < b.replaces ? -1 : a.replaces > b.replaces ? 1 : 0));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function summarizeBatch(store: Store, batch: BatchRecord): BatchSummary {
|
|
60
|
+
const trials = store.listTrials({ batchId: batch.id });
|
|
61
|
+
const counts: Record<TrialRecordStatus, number> = { queued: 0, running: 0, done: 0, failed: 0, invalid: 0 };
|
|
62
|
+
for (const t of trials) counts[t.status]++;
|
|
63
|
+
const request = batch.request as unknown as BatchRequest;
|
|
64
|
+
return { batch, request, counts, toRerun: rerunSlots(request, trials).length, ended: batch.endedAt !== null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Every batch, newest first. */
|
|
68
|
+
export const listBatchSummaries = (store: Store): BatchSummary[] => store.listBatches().map((b) => summarizeBatch(store, b));
|
|
69
|
+
|
|
70
|
+
// ---- rerun ----------------------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/** `rerun` will not run this batch's failed trials as they were: something they ran with has changed or is gone. */
|
|
73
|
+
export class RerunRefused extends Error {}
|
|
74
|
+
|
|
75
|
+
export interface RerunPlan {
|
|
76
|
+
batch: BatchRecord;
|
|
77
|
+
request: BatchRequest;
|
|
78
|
+
slots: RerunSlot[];
|
|
79
|
+
/** Trials still queued or running: never rerun, never changed. */
|
|
80
|
+
busy: string[];
|
|
81
|
+
tasks: Record<string, { task: Task; taskHash: string }>;
|
|
82
|
+
configs: Record<string, AgentConfig>;
|
|
83
|
+
judge: JudgeConfig | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const short = (h: string) => h.slice(0, 8);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Works out what `rerun <batch>` would run and checks it against today's files. Writes nothing. Throws
|
|
90
|
+
* RerunRefused when a task's hash or a config's key differs from what the batch ran with, or the batch's judge
|
|
91
|
+
* is gone. With no failed trial to rerun, returns no slots and checks nothing.
|
|
92
|
+
*/
|
|
93
|
+
export function planRerun(store: Store, batchId: string, root = projectRoot()): RerunPlan {
|
|
94
|
+
const batch = store.getBatch(batchId);
|
|
95
|
+
if (!batch) throw new RerunRefused(`no batch ${batchId}`);
|
|
96
|
+
const request = batch.request as unknown as BatchRequest;
|
|
97
|
+
const trials = store.listTrials({ batchId });
|
|
98
|
+
const slots = rerunSlots(request, trials);
|
|
99
|
+
const busy = trials.filter((t) => t.status === "queued" || t.status === "running").map((t) => t.id);
|
|
100
|
+
const plan: RerunPlan = { batch, request, slots, busy, tasks: {}, configs: {}, judge: null };
|
|
101
|
+
if (!slots.length) return plan;
|
|
102
|
+
|
|
103
|
+
const byId = new Map(trials.map((t) => [t.id, t]));
|
|
104
|
+
const configIds = [...new Set(slots.map((s) => s.configId))];
|
|
105
|
+
const useRun = (taskIds: string[]) =>
|
|
106
|
+
`Use \`static-fire run --tasks ${taskIds.join(",")} --configs ${configIds.join(",")}\` to run it on the current files.`;
|
|
107
|
+
for (const taskId of [...new Set(slots.map((s) => s.taskId))]) {
|
|
108
|
+
const was = byId.get(slots.find((s) => s.taskId === taskId)!.replaces)!.taskHash;
|
|
109
|
+
if (!existsSync(join(root, "evals", taskId, "task.yaml"))) {
|
|
110
|
+
throw new RerunRefused(`task ${taskId} is gone since batch ${batchId} (no evals/${taskId}/task.yaml), so its failed trials cannot run again as they were.`);
|
|
111
|
+
}
|
|
112
|
+
let task: Task;
|
|
113
|
+
try { task = loadTask(taskId, root); } catch (e) {
|
|
114
|
+
throw new RerunRefused(`task ${taskId} cannot be read: ${e instanceof Error ? e.message : String(e)}`);
|
|
115
|
+
}
|
|
116
|
+
const now = taskIdentity(task).hash;
|
|
117
|
+
if (now !== was) {
|
|
118
|
+
throw new RerunRefused(`task ${taskId} changed since batch ${batchId} (task hash ${short(was)} then, ${short(now)} now). `
|
|
119
|
+
+ `A rerun keeps a batch on the task version it ran. ${useRun([taskId])}`);
|
|
120
|
+
}
|
|
121
|
+
plan.tasks[taskId] = { task, taskHash: now };
|
|
122
|
+
}
|
|
123
|
+
const configs = new Map(loadConfigs(root).map((c) => [c.id, c]));
|
|
124
|
+
const taskIds = Object.keys(plan.tasks);
|
|
125
|
+
for (const configId of configIds) {
|
|
126
|
+
const was = byId.get(slots.find((s) => s.configId === configId)!.replaces)!.configKey;
|
|
127
|
+
const c = configs.get(configId);
|
|
128
|
+
if (!c) throw new RerunRefused(`config ${configId} is gone since batch ${batchId} (no configs/${configId}.yaml). ${useRun(taskIds)}`);
|
|
129
|
+
if (configKey(c) !== was) {
|
|
130
|
+
throw new RerunRefused(`config ${configId} changed since batch ${batchId} (${was} then, ${configKey(c)} now). `
|
|
131
|
+
+ `A rerun uses the configs the batch recorded. ${useRun(taskIds)}`);
|
|
132
|
+
}
|
|
133
|
+
plan.configs[configId] = c;
|
|
134
|
+
}
|
|
135
|
+
const judge = loadJudges(root).find((j) => j.id === request.judge);
|
|
136
|
+
if (!judge) throw new RerunRefused(`judge ${request.judge}, which batch ${batchId} used, is no longer in judges.yaml. ${useRun(taskIds)}`);
|
|
137
|
+
plan.judge = judge;
|
|
138
|
+
return plan;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface StartedRerun extends StartedBatch {
|
|
142
|
+
/** New trial id → the failed trial it replaces. */
|
|
143
|
+
replaces: Record<string, string>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Adds one queued trial per slot to the batch, with the next free n for its task and config, counts this rerun
|
|
148
|
+
* as a run of the batch in flight (which clears its end time), and builds the BatchContext to run them (pass the
|
|
149
|
+
* result to runBatch). The slots are worked out again inside one write transaction, so two reruns of one batch at the same time never both add a
|
|
150
|
+
* trial for the same failure; one that finds no slot left throws and writes nothing.
|
|
151
|
+
* Run conditions (user setup, blocked tools, harness version) are today's, as each trial records its own.
|
|
152
|
+
*/
|
|
153
|
+
export async function startRerun(store: Store, plan: RerunPlan, o: StartOptions): Promise<StartedRerun> {
|
|
154
|
+
const root = o.root ?? projectRoot();
|
|
155
|
+
const judge = plan.judge;
|
|
156
|
+
if (!judge || !plan.slots.length) throw new Error(`batch ${plan.batch.id}: nothing to rerun`);
|
|
157
|
+
const configs = Object.values(plan.configs);
|
|
158
|
+
const agentHarnesses = configs.flatMap((c) => (c.harness === "human" ? [] : [c.harness]));
|
|
159
|
+
const versions = await harnessVersions(agentHarnesses, o.readVersion);
|
|
160
|
+
const files = agentHarnesses.length ? (o.userSetup ?? userSetupFiles)() : null;
|
|
161
|
+
const setupHash = files ? sha256(canonicalJson(files)) : null;
|
|
162
|
+
const settings = loadSettings(root);
|
|
163
|
+
const grading = Object.fromEntries(Object.entries(plan.tasks).map(([id, { task }]) => [id, gradingIdentity(task, judge)]));
|
|
164
|
+
const at = (o.now ?? (() => new Date()))().toISOString();
|
|
165
|
+
|
|
166
|
+
const { trials, replaces } = store.transaction(() => {
|
|
167
|
+
const rows = store.listTrials({ batchId: plan.batch.id });
|
|
168
|
+
const slots = rerunSlots(plan.request, rows).filter((s) => plan.tasks[s.taskId] && plan.configs[s.configId]);
|
|
169
|
+
// Another rerun took them since the plan: nothing is written, and no runBatch follows to end the batch.
|
|
170
|
+
if (!slots.length) throw new Error(`batch ${plan.batch.id}: nothing to rerun`);
|
|
171
|
+
for (const [id, { task, taskHash }] of Object.entries(plan.tasks)) {
|
|
172
|
+
const g = grading[id]!;
|
|
173
|
+
store.putTaskVersion({ hash: taskHash, taskId: id, label: task.version ?? null, snapshot: taskIdentity(task).snapshot as unknown as Record<string, unknown>, firstSeen: at });
|
|
174
|
+
store.putGradingSetup({
|
|
175
|
+
hash: g.hash, taskId: id, taskHash,
|
|
176
|
+
judgeHarness: judge.harness, judgeModel: judge.model, judgeEffort: judge.effort ?? null, judgeLabel: judge.label,
|
|
177
|
+
snapshot: g.snapshot as unknown as Record<string, unknown>, firstSeen: at,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (files && setupHash) store.putUserSetup({ hash: setupHash, files });
|
|
181
|
+
const next = new Map<string, number>();
|
|
182
|
+
for (const t of rows) {
|
|
183
|
+
const k = `${t.taskId}\0${t.configId}`;
|
|
184
|
+
next.set(k, Math.max(next.get(k) ?? 1, t.n + 1));
|
|
185
|
+
}
|
|
186
|
+
const byId = new Map(rows.map((t) => [t.id, t]));
|
|
187
|
+
const trials: TrialRecord[] = [];
|
|
188
|
+
const replaces: Record<string, string> = {};
|
|
189
|
+
for (const s of slots) {
|
|
190
|
+
const k = `${s.taskId}\0${s.configId}`;
|
|
191
|
+
const n = next.get(k)!;
|
|
192
|
+
next.set(k, n + 1);
|
|
193
|
+
const failed = byId.get(s.replaces)!;
|
|
194
|
+
const id = `${plan.batch.id}--${s.taskId}--${s.configId}--${n}`;
|
|
195
|
+
const r: TrialRecord = {
|
|
196
|
+
id, batchId: plan.batch.id, taskId: s.taskId, taskHash: failed.taskHash, configKey: failed.configKey, configId: s.configId,
|
|
197
|
+
harness: failed.harness, model: failed.model, effort: failed.effort, profile: failed.profile, provider: failed.provider,
|
|
198
|
+
family: failed.family, n, status: "queued",
|
|
199
|
+
startedAt: null, endedAt: null, durationMs: null,
|
|
200
|
+
inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null, costUsd: null, costSource: null, turns: null,
|
|
201
|
+
setupHash: null, profileHash: null, blockedTools: null, harnessVersion: null, copiedShare: null, error: null,
|
|
202
|
+
dir: `trials/${id}`,
|
|
203
|
+
};
|
|
204
|
+
store.insertTrial(r);
|
|
205
|
+
trials.push(r);
|
|
206
|
+
replaces[id] = s.replaces;
|
|
207
|
+
}
|
|
208
|
+
store.startBatchRun(plan.batch.id); // not ended while this run's trials run and are graded; runBatch ends it again
|
|
209
|
+
return { trials, replaces };
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const ctx: BatchContext = {
|
|
213
|
+
batchId: plan.batch.id, dbPath: o.dbPath,
|
|
214
|
+
tasks: Object.fromEntries(Object.entries(plan.tasks).map(([id, { task, taskHash }]) => [id, { task, taskHash, gradingHash: grading[id]!.hash }])),
|
|
215
|
+
configs: plan.configs,
|
|
216
|
+
judge, votes: plan.request.votes, setupHash, blockedTools: settings.blockedTools, harnessVersions: versions,
|
|
217
|
+
cacheDir: o.cacheDir ?? projectPaths(root).cache,
|
|
218
|
+
};
|
|
219
|
+
return { batch: plan.batch, trials, ctx, replaces };
|
|
220
|
+
}
|
|
221
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Deterministic checks. Each returns a Verdict with one vote (pass/fail) or an error.
|
|
2
|
+
import type { Check, Verdict } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface CheckContext { output: string; outputPath: string; workspace: string; taskDir: string }
|
|
5
|
+
|
|
6
|
+
export function splitFrontmatter(text: string): { fields: Record<string, unknown>; body: string } {
|
|
7
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
8
|
+
if (!m) return { fields: {}, body: text };
|
|
9
|
+
let fields: Record<string, unknown> = {};
|
|
10
|
+
try { fields = (Bun.YAML.parse(m[1]) as Record<string, unknown>) ?? {}; } catch { /* treat as none */ }
|
|
11
|
+
return { fields, body: text.slice(m[0].length).replace(/^\s+/, "") };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const one = (pass: boolean, reason: string): Verdict => ({ score: pass ? 1 : 0, votes: [{ pass, reason }] });
|
|
15
|
+
const err = (error: string): Verdict => ({ score: null, votes: [], error });
|
|
16
|
+
|
|
17
|
+
const within = (n: number, min?: number, max?: number) => (min == null || n >= min) && (max == null || n <= max);
|
|
18
|
+
const bounds = (min?: number, max?: number) => [min != null && `≥ ${min}`, max != null && `≤ ${max}`].filter(Boolean).join(" and ");
|
|
19
|
+
|
|
20
|
+
export async function runCheck(check: Check, ctx: CheckContext): Promise<Verdict> {
|
|
21
|
+
try {
|
|
22
|
+
switch (check.type) {
|
|
23
|
+
// engine:check.regex:begin
|
|
24
|
+
case "regex": {
|
|
25
|
+
const flags = check.flags?.includes("g") ? check.flags : `${check.flags ?? ""}g`;
|
|
26
|
+
const n = [...ctx.output.matchAll(new RegExp(check.pattern, flags))].length;
|
|
27
|
+
const min = check.min ?? (check.max == null ? 1 : undefined);
|
|
28
|
+
return one(within(n, min, check.max), `${n} match${n === 1 ? "" : "es"} of /${check.pattern}/ (needs ${bounds(min, check.max)})`);
|
|
29
|
+
}
|
|
30
|
+
// engine:check.regex:end
|
|
31
|
+
// engine:check.words:begin
|
|
32
|
+
case "words": {
|
|
33
|
+
const n = splitFrontmatter(ctx.output).body.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
|
|
34
|
+
return one(within(n, check.min, check.max), `${n} words (needs ${bounds(check.min, check.max)})`);
|
|
35
|
+
}
|
|
36
|
+
// engine:check.words:end
|
|
37
|
+
// engine:check.frontmatter:begin
|
|
38
|
+
case "frontmatter": {
|
|
39
|
+
const v = splitFrontmatter(ctx.output).fields[check.field];
|
|
40
|
+
if (typeof v !== "string" || !v.trim()) return one(false, `frontmatter field "${check.field}" is missing`);
|
|
41
|
+
return one(within(v.length, check.minChars, check.maxChars), `"${check.field}" is ${v.length} characters (needs ${bounds(check.minChars, check.maxChars)})`);
|
|
42
|
+
}
|
|
43
|
+
// engine:check.frontmatter:end
|
|
44
|
+
// engine:check.command:begin
|
|
45
|
+
case "command": {
|
|
46
|
+
const proc = Bun.spawn(["sh", "-c", check.run], {
|
|
47
|
+
cwd: ctx.workspace, stdin: "ignore", stdout: "pipe", stderr: "pipe",
|
|
48
|
+
env: { ...process.env, OUTPUT: ctx.outputPath, WORKSPACE: ctx.workspace, TASK_DIR: ctx.taskDir },
|
|
49
|
+
});
|
|
50
|
+
let timedOut = false;
|
|
51
|
+
const timer = setTimeout(() => { timedOut = true; proc.kill(); }, (check.timeoutSec ?? 60) * 1000);
|
|
52
|
+
const [out, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
if (timedOut) return err(`command timed out after ${check.timeoutSec ?? 60}s`);
|
|
55
|
+
const said = (out.trim() || stderr.trim()).split("\n").slice(-1)[0]?.slice(0, 300);
|
|
56
|
+
return one(code === 0, said || `exit ${code}`);
|
|
57
|
+
}
|
|
58
|
+
// engine:check.command:end
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The commands of `static-fire` and the flags each one takes: the one list that the CLI (src/cli.ts) parses with,
|
|
2
|
+
// rejects unknown flags by and prints as its usage. The guide for agents (src/guide.md) is checked against it
|
|
3
|
+
// (tests/guide.test.ts), so the guide cannot name a command or a flag that the CLI does not have.
|
|
4
|
+
|
|
5
|
+
/** Every flag, as `parseArgs` takes it. A default here is only used when the command takes the flag. */
|
|
6
|
+
export const OPTIONS = {
|
|
7
|
+
suites: { type: "string" }, tasks: { type: "string" }, grading: { type: "string" }, since: { type: "string" }, until: { type: "string" },
|
|
8
|
+
configs: { type: "string" }, trials: { type: "string", default: "3" },
|
|
9
|
+
judge: { type: "string" }, votes: { type: "string" }, "only-invalid": { type: "boolean" },
|
|
10
|
+
concurrency: { type: "string", default: "4" }, port: { type: "string", default: "4477" }, dev: { type: "boolean" }, json: { type: "boolean" },
|
|
11
|
+
from: { type: "string" }, batch: { type: "string" }, plan: { type: "boolean" },
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export type Flag = keyof typeof OPTIONS;
|
|
15
|
+
|
|
16
|
+
/** Each command: the flags its code reads, and its arguments for the usage text. */
|
|
17
|
+
export const COMMANDS: Record<string, { flags: readonly Flag[]; args?: string }> = {
|
|
18
|
+
init: { flags: ["json"], args: "[<folder>]" },
|
|
19
|
+
guide: { flags: [] },
|
|
20
|
+
home: { flags: [], args: "[<folder>]" },
|
|
21
|
+
serve: { flags: ["port", "dev"] },
|
|
22
|
+
configs: { flags: [] },
|
|
23
|
+
judges: { flags: [] },
|
|
24
|
+
suites: { flags: [] },
|
|
25
|
+
tasks: { flags: [] },
|
|
26
|
+
new: { flags: [], args: "task <id>" },
|
|
27
|
+
run: { flags: ["suites", "tasks", "configs", "trials", "judge", "votes", "concurrency", "plan", "json"] },
|
|
28
|
+
regrade: { flags: ["tasks", "configs", "batch", "judge", "votes", "only-invalid", "concurrency", "plan", "json"] },
|
|
29
|
+
rerun: { flags: ["concurrency"], args: "<batch>" },
|
|
30
|
+
list: { flags: [] },
|
|
31
|
+
show: { flags: ["suites", "tasks", "judge", "grading", "since", "until", "json"] },
|
|
32
|
+
export: { flags: [], args: "[path]" },
|
|
33
|
+
migrate: { flags: ["from"] },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The flags given on the command line that `command` does not take, as written (`--json`). */
|
|
37
|
+
export function unknownFlags(command: string, given: string[]): string[] {
|
|
38
|
+
const takes = new Set<string>(COMMANDS[command]?.flags ?? []);
|
|
39
|
+
return [...new Set(given)].filter((f) => !takes.has(f)).map((f) => `--${f}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The usage line the CLI prints for no command or an unknown one. */
|
|
43
|
+
export const USAGE = `usage: static-fire <${Object.keys(COMMANDS).join("|")}> (static-fire --version for the version)`;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Compare gradings: two grading setups of one task, over the trials that have a valid grading under both.
|
|
2
|
+
// GET /api/compare and the dashboard's comparison card read it through compareGradings().
|
|
3
|
+
//
|
|
4
|
+
// Each side is the view (buildView) with that grading setup picked, so the scope, config facets, date range and
|
|
5
|
+
// the human rule are the view's own. Only the trials valid on both sides are kept, and each side is summarised
|
|
6
|
+
// over exactly those trials by the view's summary, so score.ts computes every number (DEC-2). Gradings are never
|
|
7
|
+
// averaged together: each side reads only its own grading hash.
|
|
8
|
+
import type { GradingSetupRecord, Store, TrialRecord } from "./db";
|
|
9
|
+
import { gradingDiff, versionNames } from "./grading-versions";
|
|
10
|
+
import type { GradingDiff } from "./grading-versions";
|
|
11
|
+
import type { GradingContent } from "./identity";
|
|
12
|
+
import type { Harness, TaskSnapshot } from "./types";
|
|
13
|
+
import { buildView, projectCatalog, summarizeTrials } from "./view";
|
|
14
|
+
import type { Catalog, View, ViewFilters, ViewTrial } from "./view";
|
|
15
|
+
|
|
16
|
+
/** One of the two grading versions compared. */
|
|
17
|
+
export interface CompareSide {
|
|
18
|
+
/** Full grading hash. */
|
|
19
|
+
hash: string;
|
|
20
|
+
/** Its name, from what changed since the version before it for the same task and judge ("Original grading" for the first). */
|
|
21
|
+
name: string;
|
|
22
|
+
/** What changed since that version, in short words; empty for the original grading. */
|
|
23
|
+
nameParts: string[];
|
|
24
|
+
/** The task hash it grades. */
|
|
25
|
+
taskHash: string;
|
|
26
|
+
/** The task file's `version` stored with that task hash (a label only), or null when the task has none. */
|
|
27
|
+
label: number | null;
|
|
28
|
+
judgeLabel: string;
|
|
29
|
+
/** When the setup was first recorded (ISO time). */
|
|
30
|
+
firstSeen: string;
|
|
31
|
+
/** True when it is today's grading setup of the task for the judge in view. */
|
|
32
|
+
current: boolean;
|
|
33
|
+
/** The task's criteria as this setup graded with them. */
|
|
34
|
+
def: TaskSnapshot;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One config's task score under each setup, over the compared trials. */
|
|
38
|
+
export interface CompareConfig {
|
|
39
|
+
configKey: string;
|
|
40
|
+
label: string;
|
|
41
|
+
harness: Harness;
|
|
42
|
+
family: string;
|
|
43
|
+
profile?: string;
|
|
44
|
+
/** Compared trials of this config. */
|
|
45
|
+
n: number;
|
|
46
|
+
/** Task score (0–100) under setup a and setup b, and b − a. */
|
|
47
|
+
a: number | null;
|
|
48
|
+
b: number | null;
|
|
49
|
+
change: number | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One criterion's pass rate (0–1) for one config under each setup. Null when that setup's task has no such criterion. */
|
|
53
|
+
export interface CompareCriterion {
|
|
54
|
+
criterionId: string;
|
|
55
|
+
configKey: string;
|
|
56
|
+
a: number | null;
|
|
57
|
+
b: number | null;
|
|
58
|
+
/**
|
|
59
|
+
* True when the criterion itself differs between the two versions: it was added, removed or changed (`diff.criteria`),
|
|
60
|
+
* or it is a command check whose `run` names a grader file that differs (`diff.files.grader`). A script the named
|
|
61
|
+
* file loads in its turn is not traced.
|
|
62
|
+
*/
|
|
63
|
+
changed: boolean;
|
|
64
|
+
/** True when its pass rate differs between the versions (or it has one under one version only). */
|
|
65
|
+
moved: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface GradingComparison {
|
|
69
|
+
taskId: string;
|
|
70
|
+
a: CompareSide;
|
|
71
|
+
b: CompareSide;
|
|
72
|
+
/** Everything that differs from version a to version b, from their stored snapshots. */
|
|
73
|
+
diff: GradingDiff;
|
|
74
|
+
/** True when both setups grade the same task hash. Setups of two task versions share no trials. */
|
|
75
|
+
sameTask: boolean;
|
|
76
|
+
/** Trials with a valid grading under both setups: the only ones compared. */
|
|
77
|
+
trials: number;
|
|
78
|
+
/** Trials with a valid grading under only setup a, or only setup b. They are not counted. */
|
|
79
|
+
onlyA: number;
|
|
80
|
+
onlyB: number;
|
|
81
|
+
/** Configs with a compared trial, best under setup b first. */
|
|
82
|
+
configs: CompareConfig[];
|
|
83
|
+
/** Every criterion of either setup's task, for each config in `configs`. */
|
|
84
|
+
criteria: CompareCriterion[];
|
|
85
|
+
/** What the page's Reading line states (US-007, DEC-005). Null when no trial is compared, as with two task versions. */
|
|
86
|
+
reading: CompareReading | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The counts behind the Reading line. They count criteria and gates, not criterion-and-config rows: a criterion
|
|
91
|
+
* moved when its pass rate moved for at least one config.
|
|
92
|
+
*/
|
|
93
|
+
export interface CompareReading {
|
|
94
|
+
/** Changed criteria that moved. */
|
|
95
|
+
changedMoved: number;
|
|
96
|
+
/** Changed criteria that did not move. */
|
|
97
|
+
changedSteady: number;
|
|
98
|
+
/** Unchanged criteria that moved. */
|
|
99
|
+
unchangedMoved: number;
|
|
100
|
+
/**
|
|
101
|
+
* True when the difference is the judge varying between runs, not an effect of the change: no changed criterion
|
|
102
|
+
* moved, at least one unchanged criterion moved, and nothing outside criteria and gates differs (`diff.criteriaOnly`).
|
|
103
|
+
*/
|
|
104
|
+
judgeVariation: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const valid = (t: ViewTrial) => t.status === "done" && !!t.grades;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compares grading setups `a` and `b` (full hashes or unique prefixes) of one task in the scope of `filters`.
|
|
111
|
+
* The view's own `grading` filter is replaced by each setup in turn. Throws on an unknown or ambiguous hash, the
|
|
112
|
+
* same setup twice, setups of two tasks, or a task outside the scope.
|
|
113
|
+
*/
|
|
114
|
+
export function compareGradings(store: Store, filters: ViewFilters, a: string, b: string, catalog: Catalog = projectCatalog()): GradingComparison {
|
|
115
|
+
const sa = resolve(store, a, "a"), sb = resolve(store, b, "b");
|
|
116
|
+
if (sa.hash === sb.hash) throw new Error(`a and b are the same grading version (${sa.hash}); pick two different ones`);
|
|
117
|
+
if (sa.taskId !== sb.taskId) throw new Error(`grading versions ${sa.hash.slice(0, 8)} and ${sb.hash.slice(0, 8)} are of two tasks (${sa.taskId}, ${sb.taskId}); compare two grading versions of one task`);
|
|
118
|
+
const taskId = sa.taskId;
|
|
119
|
+
// Each setup brings its own judge (the view refuses another judge with a picked setup).
|
|
120
|
+
// With no scope given, the scope is the task: only its trials are compared, and the default set of suites
|
|
121
|
+
// (DEC-019) may leave its suite out.
|
|
122
|
+
const { judge: _judge, ...given } = filters;
|
|
123
|
+
const rest = given.suites?.length || given.tasks?.length ? given : { ...given, tasks: [taskId] };
|
|
124
|
+
const va = buildView(store, { ...rest, grading: sa.hash }, catalog); // throws when the task is not in scope
|
|
125
|
+
const vb = buildView(store, { ...rest, grading: sb.hash }, catalog);
|
|
126
|
+
const ofTask = (v: View) => new Map(v.trials.filter((t) => t.taskId === taskId && valid(t)).map((t) => [t.id, t]));
|
|
127
|
+
const ta = ofTask(va), tb = ofTask(vb);
|
|
128
|
+
const both = [...ta.keys()].filter((id) => tb.has(id));
|
|
129
|
+
|
|
130
|
+
const records = new Map<string, TrialRecord>(both.map((id) => [id, store.getTrial(id)!]));
|
|
131
|
+
const side = (v: View, trials: Map<string, ViewTrial>) =>
|
|
132
|
+
summarizeTrials(both.map((id) => trials.get(id)!), records, { [taskId]: v.tasks[taskId]! }, []).configs;
|
|
133
|
+
const ca = side(va, ta), cb = side(vb, tb);
|
|
134
|
+
|
|
135
|
+
const scoreOf = (list: typeof ca, key: string) => list.find((c) => c.configKey === key)?.tasks[taskId]?.score.mean ?? null;
|
|
136
|
+
const configs: CompareConfig[] = cb.map((c) => {
|
|
137
|
+
const x = scoreOf(ca, c.configKey), y = c.tasks[taskId]?.score.mean ?? null;
|
|
138
|
+
return {
|
|
139
|
+
configKey: c.configKey, label: c.label, harness: c.harness, family: c.family, ...(c.profile ? { profile: c.profile } : {}),
|
|
140
|
+
n: c.pooled[taskId]?.n ?? 0, a: x, b: y, change: x == null || y == null ? null : y - x,
|
|
141
|
+
};
|
|
142
|
+
}).sort((p, q) => (q.b ?? -1) - (p.b ?? -1) || (p.configKey < q.configKey ? -1 : 1));
|
|
143
|
+
|
|
144
|
+
const ids = (d: TaskSnapshot) => [...d.gates, ...d.measures.flatMap((m) => m.criteria)].map((c) => c.id);
|
|
145
|
+
const da = va.tasks[taskId]!.def, db = vb.tasks[taskId]!.def;
|
|
146
|
+
const inA = new Set(ids(da)), inB = new Set(ids(db));
|
|
147
|
+
const rate = (list: typeof ca, key: string, id: string, has: Set<string>) =>
|
|
148
|
+
has.has(id) ? list.find((c) => c.configKey === key)?.tasks[taskId]?.criteria[id] ?? null : null;
|
|
149
|
+
const diff = gradingDiff({ setup: sa, task: store.getTaskVersion(sa.taskHash) }, { setup: sb, task: store.getTaskVersion(sb.taskHash) });
|
|
150
|
+
const changed = changedCriteria(sa, sb, diff);
|
|
151
|
+
const criteria = [...new Set([...ids(da), ...ids(db)])].flatMap((criterionId) => configs.map((c): CompareCriterion => {
|
|
152
|
+
const x = rate(ca, c.configKey, criterionId, inA), y = rate(cb, c.configKey, criterionId, inB);
|
|
153
|
+
return { criterionId, configKey: c.configKey, a: x, b: y, changed: changed.has(criterionId), moved: x == null || y == null ? x !== y : Math.abs(y - x) >= 1e-9 };
|
|
154
|
+
}));
|
|
155
|
+
|
|
156
|
+
const names = versionNames(store, taskId);
|
|
157
|
+
const sideOf = (s: GradingSetupRecord, v: View): CompareSide => ({
|
|
158
|
+
hash: s.hash, name: names.get(s.hash)?.name ?? s.hash.slice(0, 8), nameParts: names.get(s.hash)?.nameParts ?? [], taskHash: s.taskHash, label: v.tasks[taskId]!.label, judgeLabel: s.judgeLabel, firstSeen: s.firstSeen,
|
|
159
|
+
current: v.tasks[taskId]!.current, def: v.tasks[taskId]!.def,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
taskId, a: sideOf(sa, va), b: sideOf(sb, vb),
|
|
163
|
+
diff,
|
|
164
|
+
sameTask: sa.taskHash === sb.taskHash,
|
|
165
|
+
trials: both.length, onlyA: ta.size - both.length, onlyB: tb.size - both.length, configs, criteria,
|
|
166
|
+
reading: both.length ? reading(criteria, diff) : null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function reading(rows: CompareCriterion[], diff: GradingDiff): CompareReading {
|
|
171
|
+
const moved = new Map<string, { changed: boolean; moved: boolean }>();
|
|
172
|
+
for (const r of rows) {
|
|
173
|
+
const m = moved.get(r.criterionId);
|
|
174
|
+
moved.set(r.criterionId, { changed: r.changed, moved: r.moved || !!m?.moved });
|
|
175
|
+
}
|
|
176
|
+
const list = [...moved.values()];
|
|
177
|
+
const changedMoved = list.filter((c) => c.changed && c.moved).length;
|
|
178
|
+
const changedSteady = list.filter((c) => c.changed && !c.moved).length;
|
|
179
|
+
const unchangedMoved = list.filter((c) => !c.changed && c.moved).length;
|
|
180
|
+
return { changedMoved, changedSteady, unchangedMoved, judgeVariation: changedMoved === 0 && unchangedMoved > 0 && diff.criteriaOnly };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The ids of the gates and criteria that differ between the two versions: each one in the difference, and each
|
|
185
|
+
* command check whose `run` (under either version) names a grader file, by its path in the task folder, that was
|
|
186
|
+
* added, removed or changed.
|
|
187
|
+
*/
|
|
188
|
+
function changedCriteria(sa: GradingSetupRecord, sb: GradingSetupRecord, diff: GradingDiff): Set<string> {
|
|
189
|
+
const out = new Set(diff.criteria.map((c) => c.id));
|
|
190
|
+
const g = diff.files.grader, files = [...g.added, ...g.removed, ...g.changed];
|
|
191
|
+
if (!files.length) return out;
|
|
192
|
+
for (const snap of [sa.snapshot, sb.snapshot] as unknown as Partial<GradingContent>[]) {
|
|
193
|
+
for (const c of [...(snap.gates ?? []), ...(snap.criteria ?? [])]) {
|
|
194
|
+
if (c.check?.type === "command" && files.some((f) => (c.check as { run: string }).run.includes(f))) out.add(c.id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolve(store: Store, prefix: string, which: string): GradingSetupRecord {
|
|
201
|
+
if (!prefix) throw new Error(`${which}: pick a grading version`);
|
|
202
|
+
const p = prefix.toLowerCase();
|
|
203
|
+
const found = store.listGradingSetups().filter((g) => g.hash.startsWith(p));
|
|
204
|
+
if (found.length === 1) return found[0]!;
|
|
205
|
+
throw new Error(found.length ? `${which}: grading ${prefix} matches ${found.length} grading versions; give more characters` : `${which}: no grading version ${prefix}`);
|
|
206
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Agent and human configs: configs/<id>.yaml → AgentConfig, validated and with paths resolved.
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { HARNESSES, type AgentConfig, type Harness, type HumanOutput } from "./types";
|
|
5
|
+
|
|
6
|
+
const expand = (p: string, root: string) => (p.startsWith("~/") ? join(homedir(), p.slice(2)) : isAbsolute(p) ? p : join(root, p));
|
|
7
|
+
|
|
8
|
+
export function normalizeConfig(id: string, raw: any, root: string): AgentConfig {
|
|
9
|
+
const harness = raw?.harness as Harness;
|
|
10
|
+
if (!HARNESSES.includes(harness)) throw new Error(`configs/${id}.yaml: harness must be one of ${HARNESSES.join(", ")}`);
|
|
11
|
+
if ("label" in (raw ?? {})) throw new Error(`configs/${id}.yaml: remove label - it is built from harness, model, effort and profile`);
|
|
12
|
+
if (harness !== "human") {
|
|
13
|
+
if (!raw.model) throw new Error(`configs/${id}.yaml: an agent config needs a model`);
|
|
14
|
+
return { id, ...raw };
|
|
15
|
+
}
|
|
16
|
+
const entries = Object.entries(raw.outputs ?? {});
|
|
17
|
+
if (!entries.length) throw new Error(`configs/${id}.yaml: a human config needs outputs (task id → file)`);
|
|
18
|
+
const outputs: Record<string, HumanOutput> = {};
|
|
19
|
+
for (const [task, v] of entries) {
|
|
20
|
+
const o = typeof v === "string" ? { path: v } : (v as HumanOutput);
|
|
21
|
+
if (!o?.path) throw new Error(`configs/${id}.yaml: outputs.${task} needs a path`);
|
|
22
|
+
outputs[task] = { path: expand(o.path, root), ...(o.note ? { note: o.note } : {}) };
|
|
23
|
+
}
|
|
24
|
+
return { id, harness, model: "by hand", family: "human", outputs };
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Did an agent copy the held-out original (for example by finding the published version on the web)?
|
|
2
|
+
// Share of the output's 8-word runs that also appear in the original. Independent writing on the same
|
|
3
|
+
// topic shares almost none; a copy, even lightly edited, shares most.
|
|
4
|
+
const N = 8;
|
|
5
|
+
const words = (s: string) => s.toLowerCase().replace(/[^a-z0-9'\s]/g, " ").split(/\s+/).filter(Boolean);
|
|
6
|
+
const runs = (ws: string[]) => { const out = new Set<string>(); for (let i = 0; i + N <= ws.length; i++) out.add(ws.slice(i, i + N).join(" ")); return out; };
|
|
7
|
+
|
|
8
|
+
export function copiedShare(output: string, original: string): number {
|
|
9
|
+
const mine = runs(words(output));
|
|
10
|
+
if (!mine.size) return 0;
|
|
11
|
+
const theirs = runs(words(original));
|
|
12
|
+
let shared = 0;
|
|
13
|
+
for (const r of mine) if (theirs.has(r)) shared++;
|
|
14
|
+
return shared / mine.size;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Above this share, the trial is invalid: the output is a copy, not the agent's own work. */
|
|
18
|
+
export const COPY_LIMIT = 0.3;
|