static-fire 0.0.0-stage → 0.44.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/LICENSE +21 -0
- package/README.md +230 -2
- package/bin/static-fire.js +46 -0
- package/package.json +29 -4
- package/pricing.yaml +20 -0
- package/src/adapters/index.ts +153 -0
- package/src/api.ts +218 -0
- package/src/cli.ts +297 -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 +435 -0
- package/src/core/batches.ts +221 -0
- package/src/core/checks.ts +63 -0
- package/src/core/commands.ts +42 -0
- package/src/core/compare.ts +203 -0
- package/src/core/config.ts +25 -0
- package/src/core/copying.ts +18 -0
- package/src/core/db.ts +572 -0
- package/src/core/derived.ts +74 -0
- package/src/core/engine.ts +85 -0
- package/src/core/facets.ts +128 -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 +231 -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/profiles.ts +42 -0
- package/src/core/project.ts +54 -0
- package/src/core/regrade.ts +233 -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/trial-detail.ts +73 -0
- package/src/core/types.ts +195 -0
- package/src/core/view-query.ts +85 -0
- package/src/core/view.ts +498 -0
- package/src/guide.md +547 -0
- package/src/server.ts +24 -0
- package/web/api.ts +6 -0
- package/web/app.tsx +1019 -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 +164 -0
- package/web/index.html +17 -0
- package/web/pages.ts +16 -0
- package/web/run-status.ts +51 -0
- package/web/styles.css +333 -0
- package/web/suite-notes.ts +20 -0
- package/web/wording.ts +38 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Regrade: grade saved outputs again, with judge calls only. No agent runs, and no trial or artifact is copied.
|
|
2
|
+
//
|
|
3
|
+
// It covers every matching trial that has the current task hash and no valid grading under the current grading
|
|
4
|
+
// hash for the chosen judge. A different judge is just another grading hash on the same trials. A regrade adds a
|
|
5
|
+
// grading row and never changes an earlier one; the latest finished one counts (db.ts currentGrading).
|
|
6
|
+
//
|
|
7
|
+
// A grading left in status `grading` (for example by a crashed process) is not finished, so it is not a valid
|
|
8
|
+
// grading and the trial is graded again. The stale row is left as it is: the store cannot tell it from a grading
|
|
9
|
+
// another process is doing right now.
|
|
10
|
+
import { DEFAULT_VOTES, gradeTrial } from "./batch";
|
|
11
|
+
import type { Store, TrialRecord } from "./db";
|
|
12
|
+
import { gradingIdentity, taskIdentity, configKey } from "./identity";
|
|
13
|
+
import { loadConfigs } from "./authored";
|
|
14
|
+
import type { AgentConfig, JudgeConfig, Task } from "./types";
|
|
15
|
+
import { projectCatalog, type Catalog } from "./view";
|
|
16
|
+
|
|
17
|
+
/** Which trials to look at. Every field is optional; an empty object is every trial in the store. */
|
|
18
|
+
export interface RegradeFilters {
|
|
19
|
+
/** Task ids. */
|
|
20
|
+
tasks?: string[];
|
|
21
|
+
/** Config ids (files in configs/, resolved to their config key) or config keys. Trials match by config key. "all": every config. */
|
|
22
|
+
configs?: string[];
|
|
23
|
+
/** One batch id. */
|
|
24
|
+
batch?: string;
|
|
25
|
+
/** Trial ids. A view's regrade passes the ids it counts as gradable (view.ts LeftOut.gradable). */
|
|
26
|
+
trials?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RegradeOptions {
|
|
30
|
+
/** The store's database path: trial folders are beside it. */
|
|
31
|
+
dbPath: string;
|
|
32
|
+
/** Today's task and judge definitions. Default: the YAML files in the repo. */
|
|
33
|
+
catalog?: Catalog;
|
|
34
|
+
/** Today's config files, to resolve config ids in `filters.configs`. Default: configs/ in the repo. */
|
|
35
|
+
configs?: AgentConfig[];
|
|
36
|
+
/** Judge id. Default: the first in judges.yaml. */
|
|
37
|
+
judge?: string;
|
|
38
|
+
/** Votes per judge criterion. Default DEFAULT_VOTES. */
|
|
39
|
+
votes?: number;
|
|
40
|
+
/** Only trials whose latest finished grading under the current grading hash is invalid. */
|
|
41
|
+
onlyInvalid?: boolean;
|
|
42
|
+
/** Gradings at once. Default 4. */
|
|
43
|
+
concurrency?: number;
|
|
44
|
+
onEvent?: (msg: string) => void;
|
|
45
|
+
/** Called once per grading added, with how it ended. The server counts a running regrade's progress with it. */
|
|
46
|
+
onGraded?: (trialId: string, status: "done" | "invalid") => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Matching trials that are not graded, by reason. */
|
|
50
|
+
export interface RegradeSkipped {
|
|
51
|
+
/** Ran on another task hash: a different job. Run the task again instead. */
|
|
52
|
+
otherTask: number;
|
|
53
|
+
/** Did not end done (failed, copied the held-out original, or not ended): there is no output to grade. */
|
|
54
|
+
notDone: number;
|
|
55
|
+
/** Already has a valid (done) grading under the current grading hash for the judge. */
|
|
56
|
+
alreadyGraded: number;
|
|
57
|
+
/** With onlyInvalid: its latest finished grading under the current grading hash is not invalid (or there is none). */
|
|
58
|
+
notInvalid: number;
|
|
59
|
+
/** Its task no longer loads from today's files. */
|
|
60
|
+
unknownTask: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What a regrade would do. Plain data: no write happens to make it. */
|
|
64
|
+
export interface RegradePlan {
|
|
65
|
+
judge: JudgeConfig;
|
|
66
|
+
votes: number;
|
|
67
|
+
/** Trials that passed the filters. */
|
|
68
|
+
matched: number;
|
|
69
|
+
/** The trials to grade, in id order, with the grading hash each gets. */
|
|
70
|
+
todo: { trialId: string; taskId: string; gradingHash: string }[];
|
|
71
|
+
skipped: RegradeSkipped;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RegradeCounts {
|
|
75
|
+
matched: number;
|
|
76
|
+
/** Gradings added, and how they ended. */
|
|
77
|
+
graded: number;
|
|
78
|
+
done: number;
|
|
79
|
+
invalid: number;
|
|
80
|
+
/** Trials that could not be graded (a store error); the other trials were graded. */
|
|
81
|
+
failed: number;
|
|
82
|
+
failures: { trialId: string; error: string }[];
|
|
83
|
+
skipped: RegradeSkipped;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The failures of a regrade as one message, or null when every trial was graded. */
|
|
87
|
+
export function failureMessage(c: Pick<RegradeCounts, "failed" | "failures">): string | null {
|
|
88
|
+
if (!c.failed) return null;
|
|
89
|
+
return `${c.failed} trial${c.failed === 1 ? "" : "s"} could not be graded: ${c.failures.map((f) => `${f.trialId}: ${f.error}`).join("; ")}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const positiveInt = (n: number, what: string) => {
|
|
93
|
+
if (!Number.isInteger(n) || n < 1) throw new Error(`${what} must be a whole number of 1 or more (got ${n})`);
|
|
94
|
+
return n;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
interface Resolved { plan: RegradePlan; trials: Map<string, TrialRecord>; tasks: Map<string, Task> }
|
|
98
|
+
|
|
99
|
+
function resolve(store: Store, f: RegradeFilters, o: RegradeOptions): Resolved {
|
|
100
|
+
const catalog = o.catalog ?? projectCatalog();
|
|
101
|
+
const judge = o.judge ? catalog.judges.find((j) => j.id === o.judge) : catalog.judges[0];
|
|
102
|
+
if (!judge) throw new Error(`unknown judge ${o.judge ?? "(judges.yaml lists none)"}`);
|
|
103
|
+
const votes = positiveInt(o.votes ?? DEFAULT_VOTES, "votes");
|
|
104
|
+
if (f.batch !== undefined && !store.getBatch(f.batch)) throw new Error(`unknown batch ${f.batch}`);
|
|
105
|
+
|
|
106
|
+
let trials = store.listTrials(f.batch === undefined ? {} : { batchId: f.batch });
|
|
107
|
+
if (f.tasks) {
|
|
108
|
+
const want = new Set(f.tasks);
|
|
109
|
+
const known = new Set(store.listTrials().map((t) => t.taskId));
|
|
110
|
+
const unknown = [...want].filter((id) => !known.has(id) && !canLoad(catalog, id));
|
|
111
|
+
if (!want.size || unknown.length) throw new Error(`unknown or no task(s): ${unknown.join(", ") || "none given"}`);
|
|
112
|
+
trials = trials.filter((t) => want.has(t.taskId));
|
|
113
|
+
}
|
|
114
|
+
if (f.trials) {
|
|
115
|
+
const want = new Set(f.trials);
|
|
116
|
+
const known = new Set(store.listTrials().map((t) => t.id));
|
|
117
|
+
const unknown = [...want].filter((id) => !known.has(id));
|
|
118
|
+
if (!want.size || unknown.length) throw new Error(`unknown or no trial(s): ${unknown.join(", ") || "none given"}`);
|
|
119
|
+
trials = trials.filter((t) => want.has(t.id));
|
|
120
|
+
}
|
|
121
|
+
if (f.configs && !f.configs.includes("all")) {
|
|
122
|
+
const byId = new Map((o.configs ?? loadConfigs()).map((c) => [c.id, configKey(c)]));
|
|
123
|
+
const seenKeys = new Set(store.listTrials().map((t) => t.configKey));
|
|
124
|
+
const keys = new Set<string>(), unknown: string[] = [];
|
|
125
|
+
for (const v of f.configs) {
|
|
126
|
+
const key = byId.get(v) ?? (seenKeys.has(v) ? v : undefined);
|
|
127
|
+
if (key) keys.add(key); else unknown.push(v);
|
|
128
|
+
}
|
|
129
|
+
if (!f.configs.length || unknown.length) throw new Error(`unknown or no config(s): ${unknown.join(", ") || "none given"}`);
|
|
130
|
+
trials = trials.filter((t) => keys.has(t.configKey));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const skipped: RegradeSkipped = { otherTask: 0, notDone: 0, alreadyGraded: 0, notInvalid: 0, unknownTask: 0 };
|
|
134
|
+
const current = new Map<string, { task: Task; taskHash: string; gradingHash: string } | null>();
|
|
135
|
+
const hashesOf = (taskId: string) => {
|
|
136
|
+
if (!current.has(taskId)) {
|
|
137
|
+
try {
|
|
138
|
+
const task = catalog.loadTask(taskId);
|
|
139
|
+
current.set(taskId, { task, taskHash: taskIdentity(task).hash, gradingHash: gradingIdentity(task, judge).hash });
|
|
140
|
+
} catch {
|
|
141
|
+
current.set(taskId, null);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return current.get(taskId)!;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const todo: RegradePlan["todo"] = [];
|
|
148
|
+
for (const t of trials) {
|
|
149
|
+
const h = hashesOf(t.taskId);
|
|
150
|
+
if (!h) { skipped.unknownTask++; continue; }
|
|
151
|
+
if (t.taskHash !== h.taskHash) { skipped.otherTask++; continue; }
|
|
152
|
+
if (t.status !== "done") { skipped.notDone++; continue; }
|
|
153
|
+
const latest = store.currentGrading(t.id, h.gradingHash);
|
|
154
|
+
if (o.onlyInvalid) {
|
|
155
|
+
if (latest?.status !== "invalid") { skipped.notInvalid++; continue; }
|
|
156
|
+
} else if (latest?.status === "done") { skipped.alreadyGraded++; continue; }
|
|
157
|
+
todo.push({ trialId: t.id, taskId: t.taskId, gradingHash: h.gradingHash });
|
|
158
|
+
}
|
|
159
|
+
const tasks = new Map([...current].flatMap(([id, h]) => (h ? [[id, h.task] as const] : [])));
|
|
160
|
+
return { plan: { judge, votes, matched: trials.length, todo, skipped }, trials: new Map(trials.map((t) => [t.id, t])), tasks };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function canLoad(catalog: Catalog, id: string) {
|
|
164
|
+
try { catalog.loadTask(id); return true; } catch { return false; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Which trials a regrade would grade, and why the others are skipped. Writes nothing. Throws on an unknown
|
|
169
|
+
* task, config, batch or judge, or votes below 1.
|
|
170
|
+
*/
|
|
171
|
+
export function planRegrade(store: Store, filters: RegradeFilters, opts: RegradeOptions): RegradePlan {
|
|
172
|
+
return resolve(store, filters, opts).plan;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Grades every trial planRegrade selects with gradeTrial (no agent run), and returns the counts. Records the
|
|
177
|
+
* task version and grading setup first when they are new (an edited criterion, or another judge). A trial that
|
|
178
|
+
* cannot be graded (a store error) does not stop the others: it is counted in `failed` with its error.
|
|
179
|
+
*/
|
|
180
|
+
export async function regrade(store: Store, filters: RegradeFilters, opts: RegradeOptions): Promise<RegradeCounts> {
|
|
181
|
+
const log = opts.onEvent ?? (() => {});
|
|
182
|
+
const { plan, trials, tasks } = resolve(store, filters, opts);
|
|
183
|
+
const counts: RegradeCounts = { matched: plan.matched, graded: 0, done: 0, invalid: 0, failed: 0, failures: [], skipped: plan.skipped };
|
|
184
|
+
const at = new Date().toISOString();
|
|
185
|
+
store.transaction(() => {
|
|
186
|
+
for (const taskId of new Set(plan.todo.map((x) => x.taskId))) {
|
|
187
|
+
const task = tasks.get(taskId)!;
|
|
188
|
+
const t = taskIdentity(task), g = gradingIdentity(task, plan.judge);
|
|
189
|
+
store.putTaskVersion({ hash: t.hash, taskId, label: task.version ?? null, snapshot: t.snapshot as unknown as Record<string, unknown>, firstSeen: at });
|
|
190
|
+
store.putGradingSetup({
|
|
191
|
+
hash: g.hash, taskId, taskHash: t.hash,
|
|
192
|
+
judgeHarness: plan.judge.harness, judgeModel: plan.judge.model, judgeEffort: plan.judge.effort ?? null, judgeLabel: plan.judge.label,
|
|
193
|
+
snapshot: g.snapshot as unknown as Record<string, unknown>, firstSeen: at,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const queue = [...plan.todo];
|
|
199
|
+
const worker = async () => {
|
|
200
|
+
for (let x = queue.shift(); x; x = queue.shift()) {
|
|
201
|
+
try {
|
|
202
|
+
const g = await gradeTrial(store, opts.dbPath, trials.get(x.trialId)!, { task: tasks.get(x.taskId)!, judge: plan.judge, votes: plan.votes, gradingHash: x.gradingHash });
|
|
203
|
+
counts.graded++;
|
|
204
|
+
if (g.status === "done") counts.done++; else counts.invalid++;
|
|
205
|
+
opts.onGraded?.(x.trialId, g.status === "done" ? "done" : "invalid");
|
|
206
|
+
log(`${g.status === "done" ? "✓" : "!"} ${x.trialId} score ${g.score?.toFixed(1) ?? "—"}${g.error ? ` (${g.error})` : ""}`);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// A store error: the other trials go on, and the failure is returned with the counts.
|
|
209
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
210
|
+
counts.failed++;
|
|
211
|
+
counts.failures.push({ trialId: x.trialId, error });
|
|
212
|
+
log(`✗ ${x.trialId}: ${error}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
await Promise.all(Array.from({ length: Math.max(1, opts.concurrency ?? 4) }, worker));
|
|
217
|
+
return counts;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** What the CLI prints: one line of what was graded and skipped, by reason, then one line per trial that could not be graded. */
|
|
221
|
+
export function formatCounts(c: RegradeCounts): string {
|
|
222
|
+
const s = c.skipped;
|
|
223
|
+
const parts = [
|
|
224
|
+
s.otherTask && `${s.otherTask} on another task version`,
|
|
225
|
+
s.alreadyGraded && `${s.alreadyGraded} already graded`,
|
|
226
|
+
s.notInvalid && `${s.notInvalid} not invalid`,
|
|
227
|
+
s.notDone && `${s.notDone} not done`,
|
|
228
|
+
s.unknownTask && `${s.unknownTask} with an unknown task`,
|
|
229
|
+
].filter(Boolean);
|
|
230
|
+
const failed = c.failed ? ` · ${c.failed} could not be graded` : "";
|
|
231
|
+
return `regraded ${c.graded} trial(s) (${c.done} done, ${c.invalid} invalid) of ${c.matched} matching${failed} · skipped: ${parts.join(", ") || "none"}`
|
|
232
|
+
+ c.failures.map((f) => `\n ${f.trialId}: ${f.error}`).join("");
|
|
233
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Pure scoring (ADR-0001). Verdicts → measure % → task score → Suite Index → Workflow Index.
|
|
2
|
+
// No I/O here, so every rule is covered by tests/score.test.ts.
|
|
3
|
+
import { configLabel, EFFORTS } from "./label";
|
|
4
|
+
import type { ConfigSummary, Experiment, ExperimentSummary, Grades, Stat, Task, TaskSummary, Trial, Verdict, Vote } from "./types";
|
|
5
|
+
|
|
6
|
+
// engine:judging:begin
|
|
7
|
+
export function verdictFromVotes(votes: Vote[], error?: string): Verdict {
|
|
8
|
+
if (!votes.length) return { score: null, votes, error: error ?? "no votes" };
|
|
9
|
+
return { score: votes.filter((x) => x.pass).length / votes.length, votes };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Adaptive votes: run the smallest majority first; run the rest only if it did not settle every criterion. */
|
|
13
|
+
export const firstRoundVotes = (planned: number) => Math.floor(planned / 2) + 1;
|
|
14
|
+
|
|
15
|
+
export function needsMoreVotes(votes: Record<string, Vote[]>, planned: number): boolean {
|
|
16
|
+
const need = firstRoundVotes(planned);
|
|
17
|
+
if (need >= planned) return false;
|
|
18
|
+
return Object.values(votes).some((vs) => vs.length < need || vs.some((v) => v.pass !== vs[0].pass));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// engine:judging:end
|
|
22
|
+
|
|
23
|
+
const passed = (v: Verdict | undefined) => v?.score != null && v.score > 0.5;
|
|
24
|
+
|
|
25
|
+
export function scoreTrial(task: Task, verdicts: Record<string, Verdict>, judgeCostUsd = 0): Grades {
|
|
26
|
+
const errors: string[] = [];
|
|
27
|
+
const val = (id: string) => {
|
|
28
|
+
const v = verdicts[id];
|
|
29
|
+
if (!v || v.score == null) { errors.push(id); return null; }
|
|
30
|
+
return v.score;
|
|
31
|
+
};
|
|
32
|
+
const gatesPassed = task.gates.every((g) => { val(g.id); return passed(verdicts[g.id]); });
|
|
33
|
+
|
|
34
|
+
const measures: Record<string, number> = {};
|
|
35
|
+
let sum = 0, wsum = 0;
|
|
36
|
+
for (const m of task.measures) {
|
|
37
|
+
const scores = m.criteria.map((c) => val(c.id)).filter((s): s is number => s != null);
|
|
38
|
+
if (!scores.length) continue;
|
|
39
|
+
measures[m.id] = (scores.reduce((a, b) => a + b, 0) / scores.length) * 100;
|
|
40
|
+
const w = m.weight ?? 1;
|
|
41
|
+
sum += measures[m.id] * w;
|
|
42
|
+
wsum += w;
|
|
43
|
+
}
|
|
44
|
+
const score = gatesPassed && wsum ? sum / wsum : 0;
|
|
45
|
+
return { verdicts, gatesPassed, measures, score, errors: [...new Set(errors)], judgeCostUsd };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- statistics ----
|
|
49
|
+
|
|
50
|
+
const T95: Record<number, number> = { 1: 12.706, 2: 4.303, 3: 3.182, 4: 2.776, 5: 2.571, 6: 2.447, 7: 2.365, 8: 2.306, 9: 2.262, 10: 2.228, 15: 2.131, 20: 2.086, 30: 2.042 };
|
|
51
|
+
const tCrit = (df: number) => {
|
|
52
|
+
if (T95[df]) return T95[df];
|
|
53
|
+
const keys = Object.keys(T95).map(Number).filter((k) => k <= df);
|
|
54
|
+
return df > 30 ? 1.96 : T95[Math.max(...keys)];
|
|
55
|
+
};
|
|
56
|
+
const mean = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
57
|
+
|
|
58
|
+
/** Half-width of the 95% confidence interval of the mean. */
|
|
59
|
+
export function ci95(xs: number[]): number | null {
|
|
60
|
+
if (xs.length < 2) return null;
|
|
61
|
+
const m = mean(xs)!;
|
|
62
|
+
const sd = Math.sqrt(xs.reduce((a, x) => a + (x - m) ** 2, 0) / (xs.length - 1));
|
|
63
|
+
return (tCrit(xs.length - 1) * sd) / Math.sqrt(xs.length);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Mean of independent means; the half-width combines in quadrature. */
|
|
67
|
+
function combine(stats: Stat[], weights?: number[]): Stat {
|
|
68
|
+
if (!stats.length || stats.some((s) => s.mean == null)) return { mean: null, ci: null, n: 0 };
|
|
69
|
+
const w = weights ?? stats.map(() => 1);
|
|
70
|
+
const W = w.reduce((a, b) => a + b, 0);
|
|
71
|
+
const m = stats.reduce((a, s, i) => a + s.mean! * w[i], 0) / W;
|
|
72
|
+
const ci = stats.every((s) => s.ci != null) ? Math.sqrt(stats.reduce((a, s, i) => a + (s.ci! * w[i]) ** 2, 0)) / W : null;
|
|
73
|
+
return { mean: m, ci, n: Math.min(...stats.map((s) => s.n)) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- summaries ----
|
|
77
|
+
|
|
78
|
+
export function familyOf(model: string) {
|
|
79
|
+
const parts = model.toLowerCase().split(/[-_/:]/).filter((p) => p && !/\d/.test(p) && !EFFORTS.has(p));
|
|
80
|
+
return parts.slice(0, 2).join("-") || model;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function taskSummary(trials: Trial[]): TaskSummary {
|
|
84
|
+
const valid = trials.filter((t) => t.status === "done" && t.grades);
|
|
85
|
+
const scores = valid.map((t) => t.grades!.score);
|
|
86
|
+
const measures: Record<string, number> = {};
|
|
87
|
+
const criteria: Record<string, number> = {};
|
|
88
|
+
for (const t of valid) {
|
|
89
|
+
for (const [k, s] of Object.entries(t.grades!.measures)) measures[k] = (measures[k] ?? 0) + s / valid.length;
|
|
90
|
+
for (const [k, v] of Object.entries(t.grades!.verdicts)) if (v.score != null) criteria[k] = (criteria[k] ?? 0) + v.score / valid.length;
|
|
91
|
+
}
|
|
92
|
+
const costs = valid.map((t) => t.result?.costUsd).filter((x): x is number => x != null);
|
|
93
|
+
return {
|
|
94
|
+
score: { mean: mean(scores), ci: ci95(scores), n: scores.length },
|
|
95
|
+
measures, criteria,
|
|
96
|
+
costUsd: mean(costs),
|
|
97
|
+
costSource: valid.find((t) => t.result)?.result?.costSource ?? "unknown",
|
|
98
|
+
durationMs: mean(valid.map((t) => t.durationMs).filter((x): x is number => x != null)),
|
|
99
|
+
valid: valid.length,
|
|
100
|
+
invalid: trials.filter((t) => t.status === "invalid").length,
|
|
101
|
+
failed: trials.filter((t) => t.status === "failed").length,
|
|
102
|
+
total: trials.length,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function summarize(exp: Experiment, trials: Trial[], tasks: Task[] = []): ExperimentSummary {
|
|
107
|
+
const taskIds = Object.keys(exp.tasks);
|
|
108
|
+
const configs: ConfigSummary[] = exp.configIds.map((configId) => {
|
|
109
|
+
const c = exp.configs[configId];
|
|
110
|
+
const per: Record<string, TaskSummary> = {};
|
|
111
|
+
for (const tid of taskIds) per[tid] = taskSummary(trials.filter((t) => t.configId === configId && t.taskId === tid));
|
|
112
|
+
const suites: Record<string, Stat> = {};
|
|
113
|
+
for (const sid of exp.suiteIds) {
|
|
114
|
+
const s = exp.suites[sid];
|
|
115
|
+
suites[sid] = combine(s.tasks.map((tid) => per[tid]?.score ?? { mean: null, ci: null, n: 0 }), s.tasks.map((tid) => s.weights?.[tid] ?? 1));
|
|
116
|
+
}
|
|
117
|
+
const costs = taskIds.map((t) => per[t].costUsd).filter((x): x is number => x != null);
|
|
118
|
+
const times = taskIds.filter((t) => per[t].valid > 0).map((t) => per[t].durationMs).filter((x): x is number => x != null);
|
|
119
|
+
return {
|
|
120
|
+
configId, label: configLabel(c), harness: c.harness, model: c.model, effort: c.effort, profile: c.profile,
|
|
121
|
+
family: c.family ?? familyOf(c.model),
|
|
122
|
+
tasks: per, suites,
|
|
123
|
+
index: combine(exp.suiteIds.map((s) => suites[s])),
|
|
124
|
+
costUsd: mean(costs),
|
|
125
|
+
durationMs: mean(times),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
return { configs, deadCriteria: deadCriteria(tasks, exp.configIds, trials) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Criteria that carry no signal between these configs: a strong or ceiling criterion every config
|
|
133
|
+
* passed (pass rate ≥ 0.95), or any criterion every config failed (≤ 0.05). A floor criterion that
|
|
134
|
+
* every config passes is a guard working as designed, so it is not flagged.
|
|
135
|
+
*/
|
|
136
|
+
export function deadCriteria(tasks: Task[], configIds: string[], trials: Trial[]): Record<string, string[]> {
|
|
137
|
+
const out: Record<string, string[]> = {};
|
|
138
|
+
for (const task of tasks) {
|
|
139
|
+
const crits = task.measures.flatMap((m) => m.criteria);
|
|
140
|
+
const rates = configIds.map((cid) => taskSummary(trials.filter((t) => t.taskId === task.id && t.configId === cid)))
|
|
141
|
+
.filter((s) => s.valid > 0).map((s) => s.criteria);
|
|
142
|
+
if (rates.length < 2) continue;
|
|
143
|
+
const all = (id: string, ok: (r: number) => boolean) => rates.every((r) => r[id] != null && ok(r[id]));
|
|
144
|
+
const dead = crits.filter((c) => (c.level !== "floor" && all(c.id, (r) => r >= 0.95)) || all(c.id, (r) => r <= 0.05)).map((c) => c.id);
|
|
145
|
+
if (dead.length) out[task.id] = dead;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// static-fire.yaml: the user's own settings for trials, and a record of the user-level agent setup.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { projectRoot } from "./project";
|
|
7
|
+
import type { AgentHarness } from "./types";
|
|
8
|
+
|
|
9
|
+
export interface Settings {
|
|
10
|
+
blockedTools: Partial<Record<AgentHarness, string[]>>; // claude: tool rules; codex: MCP server names
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function loadSettings(root = projectRoot()): Settings {
|
|
14
|
+
const p = join(root, "static-fire.yaml");
|
|
15
|
+
const raw = existsSync(p) ? (Bun.YAML.parse(readFileSync(p, "utf8")) as any) : {};
|
|
16
|
+
return { blockedTools: raw?.trials?.blockedTools ?? {} };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const hash = (p: string) => createHash("sha256").update(readFileSync(p)).digest("hex").slice(0, 12);
|
|
20
|
+
|
|
21
|
+
/** Path → short content hash for each file (folders are walked), so a result can be traced to the setup it ran with. */
|
|
22
|
+
export function snapshotFiles(paths: string[]): Record<string, string> {
|
|
23
|
+
const out: Record<string, string> = {};
|
|
24
|
+
const walk = (p: string) => {
|
|
25
|
+
if (!existsSync(p)) return;
|
|
26
|
+
if (statSync(p).isDirectory()) {
|
|
27
|
+
for (const f of readdirSync(p).sort()) if (f !== ".DS_Store") walk(join(p, f));
|
|
28
|
+
} else {
|
|
29
|
+
try { out[p] = hash(p); } catch { /* changed while we read it (a tool updating its files): skip */ }
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
for (const p of paths) walk(p);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The user-level files each harness reads (instructions, settings, skills). Login secrets are not included. */
|
|
37
|
+
export function userSetupFiles() {
|
|
38
|
+
const h = homedir();
|
|
39
|
+
return snapshotFiles([
|
|
40
|
+
join(h, ".claude", "CLAUDE.md"), join(h, ".claude", "settings.json"), join(h, ".claude", "skills"),
|
|
41
|
+
join(h, ".codex", "AGENTS.md"), join(h, ".codex", "config.toml"), join(h, ".codex", "skills"),
|
|
42
|
+
join(h, ".gemini", "GEMINI.md"), join(h, ".gemini", "settings.json"),
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Task file validation. Errors stop a task from running; warnings are the authoring guide's
|
|
2
|
+
// advice (enough criteria, a mix of levels) and are shown wherever the task is listed.
|
|
3
|
+
import { existsSync, statSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
5
|
+
import { DERIVED_RUNNERS, derivedExt } from "./derived";
|
|
6
|
+
import type { Criterion } from "./types";
|
|
7
|
+
|
|
8
|
+
const LEVELS = ["floor", "strong", "ceiling"];
|
|
9
|
+
const GRADERS = ["check", "judge", "human"];
|
|
10
|
+
const EVIDENCE = ["output", "inputs", "references", "project", "sources"];
|
|
11
|
+
export const MIN_CRITERIA = 15;
|
|
12
|
+
export const MIN_PER_MEASURE = 3;
|
|
13
|
+
|
|
14
|
+
const filled = (v: unknown) => typeof v === "string" && v.trim() !== "";
|
|
15
|
+
|
|
16
|
+
/** What to fill in for each required top-level part, for the error when it is empty (`static-fire tasks`). */
|
|
17
|
+
const REQUIRED: Record<string, string> = {
|
|
18
|
+
id: "the task's folder name under evals/",
|
|
19
|
+
title: "a short name for the task",
|
|
20
|
+
prompt: "the instructions the agent gets, as the user would give them",
|
|
21
|
+
inputs: "the folder of files copied into the agent's workspace, for example ./inputs",
|
|
22
|
+
output: "the path the agent writes, relative to its workspace, for example ./out/answer.md",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function checkCriterion(c: any, where: string, errors: string[], task: any, dir?: string) {
|
|
26
|
+
const id = filled(c?.id) ? c.id : where;
|
|
27
|
+
if (c?.id == null || c.id === "") errors.push(`${where}: id is required: a kebab-case name, unique in the task`);
|
|
28
|
+
else if (typeof c.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(c.id)) errors.push(`${where}: criterion id must be kebab-case`);
|
|
29
|
+
if (!filled(c?.question)) errors.push(`${id}: question is required: a yes or no question, where yes means pass`);
|
|
30
|
+
if (!LEVELS.includes(c?.level)) errors.push(`${id}: level must be floor, strong or ceiling`);
|
|
31
|
+
if (!GRADERS.includes(c?.grader)) errors.push(`${id}: grader must be check, judge or human`);
|
|
32
|
+
if (c?.grader === "check") {
|
|
33
|
+
const k = c.check;
|
|
34
|
+
if (!k) errors.push(`${id}: a check criterion needs \`check\``);
|
|
35
|
+
else if (k.type === "regex") { try { new RegExp(k.pattern, k.flags); } catch { errors.push(`${id}: invalid regex`); } }
|
|
36
|
+
else if (k.type === "frontmatter") { if (!k.field) errors.push(`${id}: frontmatter check needs \`field\``); }
|
|
37
|
+
else if (k.type === "command") { if (!k.run) errors.push(`${id}: command check needs \`run\``); }
|
|
38
|
+
else if (k.type !== "words") errors.push(`${id}: unknown check type ${JSON.stringify(k.type)}`);
|
|
39
|
+
}
|
|
40
|
+
if (c?.evidence != null && (!Array.isArray(c.evidence) || c.evidence.some((e: string) => !EVIDENCE.includes(e))))
|
|
41
|
+
errors.push(`${id}: evidence must be a list of ${EVIDENCE.join(", ")}`);
|
|
42
|
+
else for (const k of ["references", "project", "sources"]) if (c?.evidence?.includes(k) && !task[k]) errors.push(`${id}: evidence ${k} needs \`${k}\` on the task`);
|
|
43
|
+
if (c?.derived != null) checkDerived(c, id, errors, dir);
|
|
44
|
+
else if (c?.grader === "judge" && Array.isArray(c?.evidence) && !c.evidence.length) errors.push(`${id}: evidence is empty, so the judge would see nothing; name evidence or \`derived\``);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `derived`: a judge criterion's script, a path under the task's grader/ folder (README, "Derived evidence"). */
|
|
48
|
+
function checkDerived(c: any, id: string, errors: string[], dir?: string) {
|
|
49
|
+
const d = c.derived;
|
|
50
|
+
if (c.grader !== "judge") { errors.push(`${id}: only a judge criterion can have \`derived\` evidence`); return; }
|
|
51
|
+
if (typeof d !== "string" || !d.trim()) { errors.push(`${id}: derived must be a path under grader/`); return; }
|
|
52
|
+
if (isAbsolute(d) || normalize(d).split(/[\\/]/)[0] === "..") { errors.push(`${id}: derived ${d} must be a path inside grader/`); return; }
|
|
53
|
+
if (!derivedExt(d)) { errors.push(`${id}: derived ${d} must end in ${Object.keys(DERIVED_RUNNERS).join(", ")}`); return; }
|
|
54
|
+
if (dir) {
|
|
55
|
+
const p = join(dir, "grader", d);
|
|
56
|
+
if (!existsSync(p) || !statSync(p).isFile()) errors.push(`${id}: derived script grader/${d} does not exist`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `dir` is the task folder; when given, a `derived` script must exist in its grader/ folder. */
|
|
61
|
+
export function validateTask(raw: any, dir?: string): { errors: string[]; warnings: string[] } {
|
|
62
|
+
const errors: string[] = [], warnings: string[] = [];
|
|
63
|
+
for (const [k, what] of Object.entries(REQUIRED)) if (!filled(raw?.[k])) errors.push(`${k} is required: ${what}`);
|
|
64
|
+
// `version` is a readable label only, in neither hash (identity.ts). A task file can leave it out.
|
|
65
|
+
if (raw?.version != null && !Number.isInteger(raw.version)) errors.push("version, when given, must be an integer");
|
|
66
|
+
if (raw?.baseline) errors.push("`baseline` was replaced by human configs: list the file under `outputs` in configs/<you>.yaml");
|
|
67
|
+
const gates: Criterion[] = raw?.gates ?? [];
|
|
68
|
+
const measures: any[] = raw?.measures ?? [];
|
|
69
|
+
if (!Array.isArray(measures) || !measures.length) errors.push("at least one measure is required");
|
|
70
|
+
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
const note = (c: any) => {
|
|
73
|
+
if (c?.id === "output-exists") errors.push("output-exists is reserved (it is always added as a gate)");
|
|
74
|
+
else if (filled(c?.id) && seen.has(c.id)) errors.push(`duplicate criterion id ${c.id}`);
|
|
75
|
+
seen.add(c?.id);
|
|
76
|
+
};
|
|
77
|
+
gates.forEach((g, i) => { checkCriterion(g, `gates[${i}]`, errors, raw, dir); note(g); });
|
|
78
|
+
const mids = new Set<string>();
|
|
79
|
+
measures.forEach((m, i) => {
|
|
80
|
+
const mid = filled(m?.id) ? m.id : `measures[${i}]`;
|
|
81
|
+
if (!filled(m?.id)) errors.push(`measures[${i}]: id is required: a kebab-case name for the measure`);
|
|
82
|
+
else if (mids.has(m.id)) errors.push(`duplicate measure id ${m.id}`);
|
|
83
|
+
mids.add(m?.id);
|
|
84
|
+
if (!filled(m?.label)) errors.push(`${mid}: label is required: the measure's name on the dashboard`);
|
|
85
|
+
if (m?.weight != null && !(m.weight > 0)) errors.push(`${mid}: weight must be positive`);
|
|
86
|
+
if (!Array.isArray(m?.criteria) || !m.criteria.length) { errors.push(`${mid}: a measure needs criteria`); return; }
|
|
87
|
+
m.criteria.forEach((c: any, j: number) => { checkCriterion(c, `${mid}.criteria[${j}]`, errors, raw, dir); note(c); });
|
|
88
|
+
if (m.criteria.length < MIN_PER_MEASURE) warnings.push(`measure ${mid} has ${m.criteria.length} ${m.criteria.length === 1 ? "criterion" : "criteria"}; the guide asks for at least ${MIN_PER_MEASURE}`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const crits = measures.flatMap((m) => (Array.isArray(m?.criteria) ? m.criteria : []));
|
|
92
|
+
if (crits.length < MIN_CRITERIA) warnings.push(`${crits.length} criteria in total; the guide asks for at least ${MIN_CRITERIA} so one criterion moves the score by ≤ ${Math.round(100 / MIN_CRITERIA)} points`);
|
|
93
|
+
for (const lvl of LEVELS) if (crits.length && !crits.some((c: any) => c.level === lvl)) warnings.push(`no ${lvl} criteria; mix floor, strong and ceiling so scores spread out`);
|
|
94
|
+
return { errors, warnings };
|
|
95
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// One trial as the trial drawer reads it (GET /api/trials/:id and its output): the trial row with its run
|
|
2
|
+
// conditions, and every grading it has, each with its grading setup and verdicts. Read-only.
|
|
3
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { extname, isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
+
import { trialDir } from "./db";
|
|
6
|
+
import type { GradingRecord, Store, TrialRecord, UserSetupRecord, VerdictRecord } from "./db";
|
|
7
|
+
import { namedSetup, versionNames } from "./grading-versions";
|
|
8
|
+
import type { NamedGradingSetup } from "./grading-versions";
|
|
9
|
+
import { IMAGE_MEDIA } from "./judge";
|
|
10
|
+
|
|
11
|
+
export interface TrialGrading extends GradingRecord {
|
|
12
|
+
/** The grading version of its grading hash, with its name, or null when the store has no row for it. */
|
|
13
|
+
setup: NamedGradingSetup | null;
|
|
14
|
+
verdicts: VerdictRecord[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TrialDetail {
|
|
18
|
+
/** The trial row as stored, run conditions included (setupHash, profileHash, blockedTools, harnessVersion, startedAt). */
|
|
19
|
+
trial: TrialRecord;
|
|
20
|
+
/** The user setup of its setup hash (path → content hash of each file), or null. */
|
|
21
|
+
userSetup: UserSetupRecord | null;
|
|
22
|
+
/** Every grading of the trial, oldest first, finished or not. Which one a view counts is the view's ViewTrial.gradingId. */
|
|
23
|
+
gradings: TrialGrading[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The trial and every grading it has, or null for an unknown id. */
|
|
27
|
+
export function trialDetail(store: Store, trialId: string): TrialDetail | null {
|
|
28
|
+
const trial = store.getTrial(trialId);
|
|
29
|
+
if (!trial) return null;
|
|
30
|
+
const setups = new Map<string, NamedGradingSetup | null>();
|
|
31
|
+
let names: ReturnType<typeof versionNames> | undefined; // built once, on the first grading that has a setup
|
|
32
|
+
const setupOf = (hash: string) => {
|
|
33
|
+
if (!setups.has(hash)) {
|
|
34
|
+
const s = store.getGradingSetup(hash);
|
|
35
|
+
// The task's grading versions include every one its trials were graded under, so each stored setup has a name.
|
|
36
|
+
setups.set(hash, s && namedSetup(s, names ??= versionNames(store, trial.taskId)));
|
|
37
|
+
}
|
|
38
|
+
return setups.get(hash)!;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
trial,
|
|
42
|
+
userSetup: trial.setupHash ? store.getUserSetup(trial.setupHash) : null,
|
|
43
|
+
gradings: store.listGradings(trialId).map((g) => ({ ...g, setup: setupOf(g.gradingHash), verdicts: store.listVerdicts(g.id) })),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The trial's output file as the drawer shows it: an image (by the output path's extension) or text. */
|
|
48
|
+
export type TrialOutputFile = { image: Uint8Array<ArrayBuffer>; mediaType: string } | { text: string };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The trial's output file: the task's `output` path, as recorded with the task version the trial ran on, inside
|
|
52
|
+
* the trial's saved workspace. An image type the judges take (IMAGE_MEDIA) is read as bytes with its media
|
|
53
|
+
* type; any other file as UTF-8 text. Otherwise a plain reason: the trial is unknown, its task version has no
|
|
54
|
+
* recorded output path (never guessed from today's task file), the file is missing, or the path (after
|
|
55
|
+
* symlinks) leaves the workspace.
|
|
56
|
+
*/
|
|
57
|
+
export function readTrialOutputFile(store: Store, dbPath: string, trialId: string): TrialOutputFile | { missing: string } {
|
|
58
|
+
const trial = store.getTrial(trialId);
|
|
59
|
+
if (!trial) return { missing: `no trial ${trialId}` };
|
|
60
|
+
const snap = store.getTaskVersion(trial.taskHash)?.snapshot as { output?: unknown } | undefined;
|
|
61
|
+
const output = typeof snap?.output === "string" && snap.output ? snap.output : null;
|
|
62
|
+
if (!output) return { missing: `trial ${trialId}: its task version ${trial.taskHash.slice(0, 8)} has no recorded output path` };
|
|
63
|
+
const none = { missing: `trial ${trialId} has no output file at ${output} in its saved workspace` };
|
|
64
|
+
const workspace = join(trialDir(dbPath, trial.id), "workspace");
|
|
65
|
+
const file = join(workspace, output);
|
|
66
|
+
if (!existsSync(file) || !existsSync(workspace)) return none;
|
|
67
|
+
let real: string;
|
|
68
|
+
try { real = realpathSync(file); } catch { return none; }
|
|
69
|
+
const rel = relative(realpathSync(workspace), real);
|
|
70
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return { missing: `trial ${trialId}: its output path ${output} leaves the workspace` };
|
|
71
|
+
const mediaType = IMAGE_MEDIA[extname(output).toLowerCase()];
|
|
72
|
+
try { return mediaType ? { image: readFileSync(real), mediaType } : { text: readFileSync(real, "utf8") }; } catch { return none; } // a folder, or unreadable
|
|
73
|
+
}
|