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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -2
  3. package/bin/static-fire.js +46 -0
  4. package/package.json +30 -4
  5. package/pricing.yaml +20 -0
  6. package/src/adapters/index.ts +153 -0
  7. package/src/api.ts +250 -0
  8. package/src/cli.ts +383 -0
  9. package/src/core/apkit-install.ts +47 -0
  10. package/src/core/authored.ts +47 -0
  11. package/src/core/batch-state.ts +25 -0
  12. package/src/core/batch.ts +450 -0
  13. package/src/core/batches.ts +221 -0
  14. package/src/core/checks.ts +63 -0
  15. package/src/core/commands.ts +43 -0
  16. package/src/core/compare.ts +206 -0
  17. package/src/core/config.ts +25 -0
  18. package/src/core/copying.ts +18 -0
  19. package/src/core/coverage.ts +84 -0
  20. package/src/core/db.ts +608 -0
  21. package/src/core/derived.ts +74 -0
  22. package/src/core/engine.ts +85 -0
  23. package/src/core/facets.ts +143 -0
  24. package/src/core/grade.ts +197 -0
  25. package/src/core/grading-versions.ts +281 -0
  26. package/src/core/identity.ts +263 -0
  27. package/src/core/init.ts +321 -0
  28. package/src/core/judge-schema.ts +37 -0
  29. package/src/core/judge.ts +166 -0
  30. package/src/core/label.ts +43 -0
  31. package/src/core/lock.ts +40 -0
  32. package/src/core/migrate.ts +288 -0
  33. package/src/core/new-task.ts +85 -0
  34. package/src/core/outside-tools.ts +129 -0
  35. package/src/core/plan.ts +84 -0
  36. package/src/core/profiles.ts +42 -0
  37. package/src/core/project.ts +140 -0
  38. package/src/core/regrade.ts +242 -0
  39. package/src/core/score.ts +148 -0
  40. package/src/core/settings.ts +44 -0
  41. package/src/core/task-file.ts +95 -0
  42. package/src/core/task-page.ts +54 -0
  43. package/src/core/trial-detail.ts +73 -0
  44. package/src/core/types.ts +195 -0
  45. package/src/core/view-query.ts +102 -0
  46. package/src/core/view.ts +576 -0
  47. package/src/guide.md +624 -0
  48. package/src/server.ts +28 -0
  49. package/web/api.ts +6 -0
  50. package/web/app.tsx +1162 -0
  51. package/web/brand/offset-tokens.css +126 -0
  52. package/web/charts.tsx +190 -0
  53. package/web/compare.tsx +317 -0
  54. package/web/encoding.tsx +55 -0
  55. package/web/filter-menu.ts +195 -0
  56. package/web/how-it-works.tsx +92 -0
  57. package/web/index-switches.ts +29 -0
  58. package/web/index.html +17 -0
  59. package/web/levels.tsx +15 -0
  60. package/web/notices.ts +74 -0
  61. package/web/output-view.ts +61 -0
  62. package/web/pages.ts +42 -0
  63. package/web/run-status.ts +51 -0
  64. package/web/styles.css +368 -0
  65. package/web/task-card.ts +6 -0
  66. package/web/tasks.tsx +122 -0
  67. package/web/wording.ts +38 -0
@@ -0,0 +1,140 @@
1
+ // The two roots. The engine root holds the engine's own files: its source, the dashboard and pricing.yaml. It
2
+ // comes from this file's location, never from the current folder, so it holds when the engine runs from
3
+ // node_modules. The project root is the user's folder: static-fire.yaml, configs/, judges.yaml, suites/,
4
+ // evals/, the record store and trial artifacts in runs/, and the Profile cache in .cache/. A command uses the
5
+ // nearest project at or above the current folder; outside any project it uses the home, the one project saved in
6
+ // the user's config folder (ADR-0010). Nothing of a project is written to the engine root.
7
+ //
8
+ // This module is the one place that finds the project a command uses and that reads and writes the saved home.
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { dirname, isAbsolute, join, resolve } from "node:path";
12
+
13
+ export const ENGINE_ROOT = resolve(import.meta.dir, "../..");
14
+ export const PROJECT_FILE = "static-fire.yaml";
15
+
16
+ /** A command that needs a project was run where no static-fire.yaml is in the folder or any folder above it. */
17
+ export class NoProjectError extends Error {
18
+ constructor(from: string) {
19
+ super(`no Static Fire project here: there is no ${PROJECT_FILE} in ${from} or in a folder above it. Run \`static-fire init\` to make a project.`);
20
+ this.name = "NoProjectError";
21
+ }
22
+ }
23
+
24
+ /** The saved home is not a project any more: its folder is gone, or has no static-fire.yaml. */
25
+ export class HomeNotProjectError extends Error {
26
+ constructor(readonly home: string) {
27
+ super(`the home, ${home}, is not a Static Fire project any more: there is no ${PROJECT_FILE} in it. Run \`static-fire home <folder>\` with a project folder to set the home again.`);
28
+ this.name = "HomeNotProjectError";
29
+ }
30
+ }
31
+
32
+ /** A folder given as the home holds no static-fire.yaml. */
33
+ export class NotAProjectError extends Error {
34
+ constructor(folder: string) {
35
+ super(`${folder} is not a Static Fire project: there is no ${PROJECT_FILE} in it. The home is not changed.`);
36
+ this.name = "NotAProjectError";
37
+ }
38
+ }
39
+
40
+ export type Env = Record<string, string | undefined>;
41
+
42
+ /**
43
+ * The file that holds the saved home: XDG_CONFIG_HOME/static-fire/home when XDG_CONFIG_HOME is an absolute path,
44
+ * else HOME/.config/static-fire/home. It holds one line, the home's absolute folder, and nothing else. HOME is read
45
+ * from `env`, because Bun's os.homedir() does not see a HOME changed while the process runs (tests move it).
46
+ */
47
+ export function homeFile(env: Env = process.env): string {
48
+ const xdg = env.XDG_CONFIG_HOME;
49
+ const config = xdg && isAbsolute(xdg) ? xdg : join(userFolder(env), ".config");
50
+ return join(config, "static-fire", "home");
51
+ }
52
+
53
+ /** The user's own folder: HOME from `env`, for the reason homeFile gives. */
54
+ function userFolder(env: Env): string {
55
+ return env.HOME || homedir();
56
+ }
57
+
58
+ /** Where the first `init` outside any project makes the home: HOME/static-fire. XDG_CONFIG_HOME does not move it. */
59
+ export function defaultHomeFolder(env: Env = process.env): string {
60
+ return join(userFolder(env), "static-fire");
61
+ }
62
+
63
+ /** The saved home's folder, or null when none is saved (no file, or an empty one). */
64
+ export function readHome(env: Env = process.env): string | null {
65
+ const file = homeFile(env);
66
+ if (!existsSync(file)) return null;
67
+ return readFileSync(file, "utf8").trim() || null;
68
+ }
69
+
70
+ /** Saves `folder` as the home when it holds static-fire.yaml; else throws NotAProjectError and changes nothing. */
71
+ export function saveHome(folder: string, env: Env = process.env): string {
72
+ const root = resolve(folder);
73
+ if (!existsSync(join(root, PROJECT_FILE))) throw new NotAProjectError(root);
74
+ const file = homeFile(env);
75
+ mkdirSync(dirname(file), { recursive: true });
76
+ const tmp = `${file}.${process.pid}.tmp`; // a reader never sees half a file
77
+ writeFileSync(tmp, `${root}\n`);
78
+ renameSync(tmp, file);
79
+ return root;
80
+ }
81
+
82
+ /** The project a command uses: `home` is true when it is the saved home, used from outside any project. */
83
+ export interface ResolvedProject { root: string; home: boolean }
84
+
85
+ /**
86
+ * The nearest project from `from` upward; outside any project, the saved home. Throws NoProjectError when there
87
+ * is neither, and HomeNotProjectError when the saved home is not a project any more. Reads only.
88
+ */
89
+ export function resolveProject(from = process.cwd(), env: Env = process.env): ResolvedProject {
90
+ const near = findProject(from);
91
+ if (near) return { root: near, home: false };
92
+ const home = readHome(env);
93
+ if (!home) throw new NoProjectError(resolve(from));
94
+ if (!existsSync(join(home, PROJECT_FILE))) throw new HomeNotProjectError(home);
95
+ return { root: home, home: true };
96
+ }
97
+
98
+ /** The project resolveProject finds, or null where it would throw. */
99
+ export function currentProject(from = process.cwd(), env: Env = process.env): string | null {
100
+ try {
101
+ return resolveProject(from, env).root;
102
+ } catch (e) {
103
+ if (e instanceof NoProjectError || e instanceof HomeNotProjectError) return null;
104
+ throw e;
105
+ }
106
+ }
107
+
108
+ /** The first folder from `from` upward that holds static-fire.yaml, or null. Does not read the home. */
109
+ export function findProject(from = process.cwd()): string | null {
110
+ for (let dir = resolve(from); ; dir = dirname(dir)) {
111
+ if (existsSync(join(dir, PROJECT_FILE))) return dir;
112
+ if (dirname(dir) === dir) return null;
113
+ }
114
+ }
115
+
116
+ /** The root of the project resolveProject finds from `from`. Throws as resolveProject does. */
117
+ export function projectRoot(from = process.cwd()): string {
118
+ return resolveProject(from).root;
119
+ }
120
+
121
+ export interface ProjectPaths {
122
+ root: string;
123
+ /** The record store's default folder and the trial artifacts' folder (runs/trials/<trial-id>). */
124
+ runs: string;
125
+ /** The Profile cache and its lock file. */
126
+ cache: string;
127
+ /** The record store: STATIC_FIRE_DB when set, else runs/static-fire.db. Trial artifacts go next to it. */
128
+ dbPath: string;
129
+ }
130
+
131
+ export function projectPaths(root: string): ProjectPaths {
132
+ const runs = join(root, "runs");
133
+ return { root, runs, cache: join(root, ".cache"), dbPath: process.env.STATIC_FIRE_DB || join(runs, "static-fire.db") };
134
+ }
135
+
136
+ /** The pricing file: the project's own pricing.yaml when it has one, else the engine's. */
137
+ export function pricingPath(project: string | null): string {
138
+ const own = project ? join(project, "pricing.yaml") : null;
139
+ return own && existsSync(own) ? own : join(ENGINE_ROOT, "pricing.yaml");
140
+ }
@@ -0,0 +1,242 @@
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 { judgeCallRange } from "./plan";
12
+ import type { Store, TrialRecord } from "./db";
13
+ import { gradingIdentity, taskIdentity, configKey } from "./identity";
14
+ import { loadConfigs } from "./authored";
15
+ import type { AgentConfig, JudgeConfig, Task } from "./types";
16
+ import { projectCatalog, type Catalog } from "./view";
17
+
18
+ /** Which trials to look at. Every field is optional; an empty object is every trial in the store. */
19
+ export interface RegradeFilters {
20
+ /** Task ids. */
21
+ tasks?: string[];
22
+ /** Config ids (files in configs/, resolved to their config key) or config keys. Trials match by config key. "all": every config. */
23
+ configs?: string[];
24
+ /** One batch id. */
25
+ batch?: string;
26
+ /** Trial ids. A view's regrade passes the ids it counts as gradable (view.ts LeftOut.gradable). */
27
+ trials?: string[];
28
+ }
29
+
30
+ export interface RegradeOptions {
31
+ /** The store's database path: trial folders are beside it. */
32
+ dbPath: string;
33
+ /** Today's task and judge definitions. Default: the YAML files in the repo. */
34
+ catalog?: Catalog;
35
+ /** Today's config files, to resolve config ids in `filters.configs`. Default: configs/ in the repo. */
36
+ configs?: AgentConfig[];
37
+ /** Judge id. Default: the first in judges.yaml. */
38
+ judge?: string;
39
+ /** Votes per judge criterion. Default DEFAULT_VOTES. */
40
+ votes?: number;
41
+ /** Only trials whose latest finished grading under the current grading hash is invalid. */
42
+ onlyInvalid?: boolean;
43
+ /** Gradings at once. Default 4. */
44
+ concurrency?: number;
45
+ onEvent?: (msg: string) => void;
46
+ /** Called once per grading added, with how it ended. The server counts a running regrade's progress with it. */
47
+ onGraded?: (trialId: string, status: "done" | "invalid") => void;
48
+ }
49
+
50
+ /** Matching trials that are not graded, by reason. */
51
+ export interface RegradeSkipped {
52
+ /** Ran on another task hash: a different job. Run the task again instead. */
53
+ otherTask: number;
54
+ /** Did not end done (failed, copied the held-out original, or not ended): there is no output to grade. */
55
+ notDone: number;
56
+ /** Already has a valid (done) grading under the current grading hash for the judge. */
57
+ alreadyGraded: number;
58
+ /** With onlyInvalid: its latest finished grading under the current grading hash is not invalid (or there is none). */
59
+ notInvalid: number;
60
+ /** Its task no longer loads from today's files. */
61
+ unknownTask: number;
62
+ }
63
+
64
+ /** What a regrade would do. Plain data: no write happens to make it. */
65
+ export interface RegradePlan {
66
+ judge: JudgeConfig;
67
+ votes: number;
68
+ /** Trials that passed the filters. */
69
+ matched: number;
70
+ /** The trials to grade, in id order, with the grading hash each gets. */
71
+ todo: { trialId: string; taskId: string; gradingHash: string }[];
72
+ skipped: RegradeSkipped;
73
+ /** The judge calls grading `todo` makes, fewest and most (plan.ts judgeCallRange). */
74
+ judgeCalls: { min: number; max: number };
75
+ }
76
+
77
+ export interface RegradeCounts {
78
+ matched: number;
79
+ /** Gradings added, and how they ended. */
80
+ graded: number;
81
+ done: number;
82
+ invalid: number;
83
+ /** Trials that could not be graded (a store error); the other trials were graded. */
84
+ failed: number;
85
+ failures: { trialId: string; error: string }[];
86
+ skipped: RegradeSkipped;
87
+ }
88
+
89
+ /** The failures of a regrade as one message, or null when every trial was graded. */
90
+ export function failureMessage(c: Pick<RegradeCounts, "failed" | "failures">): string | null {
91
+ if (!c.failed) return null;
92
+ return `${c.failed} trial${c.failed === 1 ? "" : "s"} could not be graded: ${c.failures.map((f) => `${f.trialId}: ${f.error}`).join("; ")}`;
93
+ }
94
+
95
+ const positiveInt = (n: number, what: string) => {
96
+ if (!Number.isInteger(n) || n < 1) throw new Error(`${what} must be a whole number of 1 or more (got ${n})`);
97
+ return n;
98
+ };
99
+
100
+ interface Resolved { plan: RegradePlan; trials: Map<string, TrialRecord>; tasks: Map<string, Task> }
101
+
102
+ function resolve(store: Store, f: RegradeFilters, o: RegradeOptions): Resolved {
103
+ const catalog = o.catalog ?? projectCatalog();
104
+ const judge = o.judge ? catalog.judges.find((j) => j.id === o.judge) : catalog.judges[0];
105
+ if (!judge) throw new Error(`unknown judge ${o.judge ?? "(judges.yaml lists none)"}`);
106
+ const votes = positiveInt(o.votes ?? DEFAULT_VOTES, "votes");
107
+ if (f.batch !== undefined && !store.getBatch(f.batch)) throw new Error(`unknown batch ${f.batch}`);
108
+
109
+ let trials = store.listTrials(f.batch === undefined ? {} : { batchId: f.batch });
110
+ if (f.tasks) {
111
+ const want = new Set(f.tasks);
112
+ const known = new Set(store.listTrials().map((t) => t.taskId));
113
+ const unknown = [...want].filter((id) => !known.has(id) && !canLoad(catalog, id));
114
+ if (!want.size || unknown.length) throw new Error(`unknown or no task(s): ${unknown.join(", ") || "none given"}`);
115
+ trials = trials.filter((t) => want.has(t.taskId));
116
+ }
117
+ if (f.trials) {
118
+ const want = new Set(f.trials);
119
+ const known = new Set(store.listTrials().map((t) => t.id));
120
+ const unknown = [...want].filter((id) => !known.has(id));
121
+ if (!want.size || unknown.length) throw new Error(`unknown or no trial(s): ${unknown.join(", ") || "none given"}`);
122
+ trials = trials.filter((t) => want.has(t.id));
123
+ }
124
+ if (f.configs && !f.configs.includes("all")) {
125
+ const byId = new Map((o.configs ?? loadConfigs()).map((c) => [c.id, configKey(c)]));
126
+ const seenKeys = new Set(store.listTrials().map((t) => t.configKey));
127
+ const keys = new Set<string>(), unknown: string[] = [];
128
+ for (const v of f.configs) {
129
+ const key = byId.get(v) ?? (seenKeys.has(v) ? v : undefined);
130
+ if (key) keys.add(key); else unknown.push(v);
131
+ }
132
+ if (!f.configs.length || unknown.length) throw new Error(`unknown or no config(s): ${unknown.join(", ") || "none given"}`);
133
+ trials = trials.filter((t) => keys.has(t.configKey));
134
+ }
135
+
136
+ const skipped: RegradeSkipped = { otherTask: 0, notDone: 0, alreadyGraded: 0, notInvalid: 0, unknownTask: 0 };
137
+ const current = new Map<string, { task: Task; taskHash: string; gradingHash: string } | null>();
138
+ const hashesOf = (taskId: string) => {
139
+ if (!current.has(taskId)) {
140
+ try {
141
+ const task = catalog.loadTask(taskId);
142
+ current.set(taskId, { task, taskHash: taskIdentity(task).hash, gradingHash: gradingIdentity(task, judge).hash });
143
+ } catch {
144
+ current.set(taskId, null);
145
+ }
146
+ }
147
+ return current.get(taskId)!;
148
+ };
149
+
150
+ const todo: RegradePlan["todo"] = [];
151
+ for (const t of trials) {
152
+ const h = hashesOf(t.taskId);
153
+ if (!h) { skipped.unknownTask++; continue; }
154
+ if (t.taskHash !== h.taskHash) { skipped.otherTask++; continue; }
155
+ if (t.status !== "done") { skipped.notDone++; continue; }
156
+ const latest = store.currentGrading(t.id, h.gradingHash);
157
+ if (o.onlyInvalid) {
158
+ if (latest?.status !== "invalid") { skipped.notInvalid++; continue; }
159
+ } else if (latest?.status === "done") { skipped.alreadyGraded++; continue; }
160
+ todo.push({ trialId: t.id, taskId: t.taskId, gradingHash: h.gradingHash });
161
+ }
162
+ const tasks = new Map([...current].flatMap(([id, h]) => (h ? [[id, h.task] as const] : [])));
163
+ const judgeCalls = { min: 0, max: 0 };
164
+ for (const x of todo) {
165
+ const r = judgeCallRange(tasks.get(x.taskId)!, votes);
166
+ judgeCalls.min += r.min;
167
+ judgeCalls.max += r.max;
168
+ }
169
+ return { plan: { judge, votes, matched: trials.length, todo, skipped, judgeCalls }, trials: new Map(trials.map((t) => [t.id, t])), tasks };
170
+ }
171
+
172
+ function canLoad(catalog: Catalog, id: string) {
173
+ try { catalog.loadTask(id); return true; } catch { return false; }
174
+ }
175
+
176
+ /**
177
+ * Which trials a regrade would grade, and why the others are skipped. Writes nothing. Throws on an unknown
178
+ * task, config, batch or judge, or votes below 1.
179
+ */
180
+ export function planRegrade(store: Store, filters: RegradeFilters, opts: RegradeOptions): RegradePlan {
181
+ return resolve(store, filters, opts).plan;
182
+ }
183
+
184
+ /**
185
+ * Grades every trial planRegrade selects with gradeTrial (no agent run), and returns the counts. Records the
186
+ * task version and grading setup first when they are new (an edited criterion, or another judge). A trial that
187
+ * cannot be graded (a store error) does not stop the others: it is counted in `failed` with its error.
188
+ */
189
+ export async function regrade(store: Store, filters: RegradeFilters, opts: RegradeOptions): Promise<RegradeCounts> {
190
+ const log = opts.onEvent ?? (() => {});
191
+ const { plan, trials, tasks } = resolve(store, filters, opts);
192
+ const counts: RegradeCounts = { matched: plan.matched, graded: 0, done: 0, invalid: 0, failed: 0, failures: [], skipped: plan.skipped };
193
+ const at = new Date().toISOString();
194
+ store.transaction(() => {
195
+ for (const taskId of new Set(plan.todo.map((x) => x.taskId))) {
196
+ const task = tasks.get(taskId)!;
197
+ const t = taskIdentity(task), g = gradingIdentity(task, plan.judge);
198
+ store.putTaskVersion({ hash: t.hash, taskId, label: task.version ?? null, snapshot: t.snapshot as unknown as Record<string, unknown>, firstSeen: at });
199
+ store.putGradingSetup({
200
+ hash: g.hash, taskId, taskHash: t.hash,
201
+ judgeHarness: plan.judge.harness, judgeModel: plan.judge.model, judgeEffort: plan.judge.effort ?? null, judgeLabel: plan.judge.label,
202
+ snapshot: g.snapshot as unknown as Record<string, unknown>, firstSeen: at,
203
+ });
204
+ }
205
+ });
206
+
207
+ const queue = [...plan.todo];
208
+ const worker = async () => {
209
+ for (let x = queue.shift(); x; x = queue.shift()) {
210
+ try {
211
+ 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 });
212
+ counts.graded++;
213
+ if (g.status === "done") counts.done++; else counts.invalid++;
214
+ opts.onGraded?.(x.trialId, g.status === "done" ? "done" : "invalid");
215
+ log(`${g.status === "done" ? "✓" : "!"} ${x.trialId} score ${g.score?.toFixed(1) ?? "—"}${g.error ? ` (${g.error})` : ""}`);
216
+ } catch (e) {
217
+ // A store error: the other trials go on, and the failure is returned with the counts.
218
+ const error = e instanceof Error ? e.message : String(e);
219
+ counts.failed++;
220
+ counts.failures.push({ trialId: x.trialId, error });
221
+ log(`✗ ${x.trialId}: ${error}`);
222
+ }
223
+ }
224
+ };
225
+ await Promise.all(Array.from({ length: Math.max(1, opts.concurrency ?? 4) }, worker));
226
+ return counts;
227
+ }
228
+
229
+ /** What the CLI prints: one line of what was graded and skipped, by reason, then one line per trial that could not be graded. */
230
+ export function formatCounts(c: RegradeCounts): string {
231
+ const s = c.skipped;
232
+ const parts = [
233
+ s.otherTask && `${s.otherTask} on another task version`,
234
+ s.alreadyGraded && `${s.alreadyGraded} already graded`,
235
+ s.notInvalid && `${s.notInvalid} not invalid`,
236
+ s.notDone && `${s.notDone} not done`,
237
+ s.unknownTask && `${s.unknownTask} with an unknown task`,
238
+ ].filter(Boolean);
239
+ const failed = c.failed ? ` · ${c.failed} could not be graded` : "";
240
+ return `regraded ${c.graded} trial(s) (${c.done} done, ${c.invalid} invalid) of ${c.matched} matching${failed} · skipped: ${parts.join(", ") || "none"}`
241
+ + c.failures.map((f) => `\n ${f.trialId}: ${f.error}`).join("");
242
+ }
@@ -0,0 +1,148 @@
1
+ // Pure scoring (ADR-0001). Verdicts → measure % → task score → Suite Index → Agent Performance 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
+ }