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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +230 -2
  3. package/bin/static-fire.js +46 -0
  4. package/package.json +29 -4
  5. package/pricing.yaml +20 -0
  6. package/src/adapters/index.ts +153 -0
  7. package/src/api.ts +218 -0
  8. package/src/cli.ts +297 -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 +435 -0
  13. package/src/core/batches.ts +221 -0
  14. package/src/core/checks.ts +63 -0
  15. package/src/core/commands.ts +42 -0
  16. package/src/core/compare.ts +203 -0
  17. package/src/core/config.ts +25 -0
  18. package/src/core/copying.ts +18 -0
  19. package/src/core/db.ts +572 -0
  20. package/src/core/derived.ts +74 -0
  21. package/src/core/engine.ts +85 -0
  22. package/src/core/facets.ts +128 -0
  23. package/src/core/grade.ts +197 -0
  24. package/src/core/grading-versions.ts +281 -0
  25. package/src/core/identity.ts +263 -0
  26. package/src/core/init.ts +231 -0
  27. package/src/core/judge-schema.ts +37 -0
  28. package/src/core/judge.ts +166 -0
  29. package/src/core/label.ts +43 -0
  30. package/src/core/lock.ts +40 -0
  31. package/src/core/migrate.ts +288 -0
  32. package/src/core/new-task.ts +85 -0
  33. package/src/core/outside-tools.ts +129 -0
  34. package/src/core/profiles.ts +42 -0
  35. package/src/core/project.ts +54 -0
  36. package/src/core/regrade.ts +233 -0
  37. package/src/core/score.ts +148 -0
  38. package/src/core/settings.ts +44 -0
  39. package/src/core/task-file.ts +95 -0
  40. package/src/core/trial-detail.ts +73 -0
  41. package/src/core/types.ts +195 -0
  42. package/src/core/view-query.ts +85 -0
  43. package/src/core/view.ts +498 -0
  44. package/src/guide.md +547 -0
  45. package/src/server.ts +24 -0
  46. package/web/api.ts +6 -0
  47. package/web/app.tsx +1019 -0
  48. package/web/brand/offset-tokens.css +126 -0
  49. package/web/charts.tsx +190 -0
  50. package/web/compare.tsx +317 -0
  51. package/web/encoding.tsx +55 -0
  52. package/web/filter-menu.ts +164 -0
  53. package/web/index.html +17 -0
  54. package/web/pages.ts +16 -0
  55. package/web/run-status.ts +51 -0
  56. package/web/styles.css +333 -0
  57. package/web/suite-notes.ts +20 -0
  58. package/web/wording.ts +38 -0
@@ -0,0 +1,498 @@
1
+ // The view: the comparable trials for a set of filters, pooled across batches, with their summary.
2
+ // `static-fire show`, GET /api/view and the dashboard all read it through buildView().
3
+ //
4
+ // Comparability is computed from content (spec #1, "Model"): a trial is in the view when it ran on the task
5
+ // hash in view and its latest finished grading under the grading hash in view is that grading. Trials from
6
+ // every batch pool by config key. SQL (through the store) only selects rows; score.ts computes the means,
7
+ // confidence intervals, indexes and dead criteria (DEC-2). Run conditions never decide comparability (DEC-3).
8
+ import type { GradingRecord, GradingSetupRecord, Store, TrialRecord } from "./db";
9
+ import { namedSetup, versionNames } from "./grading-versions";
10
+ import type { NamedGradingSetup, VersionName } from "./grading-versions";
11
+ import { gradingHash as currentGradingHash, taskHash as currentTaskHash } from "./identity";
12
+ import type { CriterionContent, GradingContent } from "./identity";
13
+ import { summarize } from "./score";
14
+ import { CONFIG_FACETS, valueSet } from "./view-query";
15
+ import type { ConfigFacet } from "./view-query";
16
+ import { loadJudges, loadSuites, loadTask } from "./authored";
17
+ import { projectRoot } from "./project";
18
+ import type { AgentConfig, ConfigSummary, Criterion, Experiment, Grades, JudgeConfig, Measure, Suite, Task, TaskSnapshot, Trial, Verdict } from "./types";
19
+
20
+ // ---- Inputs ------------------------------------------------------------------------------------
21
+
22
+ /** What the caller asks for. Every field is optional; an empty object is the default view. */
23
+ export interface ViewFilters {
24
+ /** Suite ids. Their tasks are in scope and the Workflow Index is the mean over these suites. Default: every suite. */
25
+ suites?: string[];
26
+ /** Task ids. When set, only these tasks are in scope and no suite is (so no Suite or Workflow Index). Wins over `suites`. */
27
+ tasks?: string[];
28
+ /** Judge id from judges.yaml. Default: the first one (the default judge). */
29
+ judge?: string;
30
+ /**
31
+ * A grading hash, in full or a unique prefix. It replaces the current grading hash, and the task hash, of
32
+ * the tasks that grading setup belongs to (db.ts gradingSetupTaskIds); this is how an earlier task version is
33
+ * reached. One of those tasks must be in scope. Its judge is the judge of the whole view: `judge`, when also
34
+ * given, must name that judge.
35
+ */
36
+ grading?: string;
37
+ /**
38
+ * Config facets: only trials whose config has any of these harnesses, models, efforts or Profiles; a trial
39
+ * must match every facet that has a value. `none` matches a config without an effort or a Profile. An empty
40
+ * list is no filter. They narrow which configs are in the view (summary, dead criteria and left-out counts
41
+ * included) and never change the task hash or grading hash in view.
42
+ */
43
+ harness?: string[];
44
+ model?: string[];
45
+ effort?: string[];
46
+ profile?: string[];
47
+ /** Trials that started on or after this time. A date ("2026-11-06") means 00:00 UTC that day. */
48
+ since?: string;
49
+ /** Trials that started on or before this time. A date means to the end of that day (UTC). */
50
+ until?: string;
51
+ }
52
+
53
+ /** The authored definitions the view reads. Default: the YAML files of the project. Tests pass their own. */
54
+ export interface Catalog {
55
+ suites: Suite[];
56
+ judges: JudgeConfig[];
57
+ loadTask(id: string): Task;
58
+ }
59
+
60
+ export const projectCatalog = (root = projectRoot()): Catalog => ({ suites: loadSuites(root), judges: loadJudges(root), loadTask: (id) => loadTask(id, root) });
61
+
62
+ // ---- Output ------------------------------------------------------------------------------------
63
+
64
+ /** One task in scope. Hashes are always full (64 hex characters); short forms are for display only. */
65
+ export interface ViewTask {
66
+ /** The task hash in view: the current one, or the grading setup's when `grading` picked an earlier one. */
67
+ taskHash: string;
68
+ /** The grading hash in view: the current one for the judge, or the one `grading` picked. */
69
+ gradingHash: string;
70
+ /** The grading version name of that grading hash (versionNames), or null when the store has no row for it yet. */
71
+ gradingName: string | null;
72
+ /**
73
+ * The task file's `version` stored with that task hash (a readable label only), else the file's current one.
74
+ * Null when the task has none: it is then shown by its short task hash alone.
75
+ */
76
+ label: number | null;
77
+ /** True when both hashes are the ones today's files give for the judge in view. */
78
+ current: boolean;
79
+ /** The task's criteria as that grading setup used them (see taskDefinition). */
80
+ def: TaskSnapshot;
81
+ }
82
+
83
+ /**
84
+ * One trial in the view, in today's `Trial` shape so the dashboard components keep working, plus its record
85
+ * fields. `configId` is the config key (what trials pool by); the config file name is `configFile`.
86
+ * `status` is "done" (valid: counts in the means), "invalid" (its grading in view is invalid, or it copied the
87
+ * held-out original) or "failed" (the harness could not run). `experimentId` is the batch id.
88
+ */
89
+ export interface ViewTrial extends Trial {
90
+ batchId: string;
91
+ configKey: string;
92
+ configFile: string;
93
+ taskHash: string;
94
+ /** The grading that counts for this trial in the view, or null for a failed or held-out-copy trial. */
95
+ gradingId: number | null;
96
+ gradedAt: string | null;
97
+ }
98
+
99
+ /** The trials of one config on one task that the view pools. */
100
+ export interface Pooled {
101
+ /** Valid trials in the mean. */
102
+ n: number;
103
+ /** Earliest and latest start time of those trials (null when n is 0). */
104
+ from: string | null;
105
+ to: string | null;
106
+ /** The batches they came from. */
107
+ batches: string[];
108
+ /** How many distinct user setups and Profile versions (Profile hashes) those trials ran under. Information only (DEC-3). */
109
+ setups: number;
110
+ profiles: number;
111
+ }
112
+
113
+ /** A config's row: score.ts's ConfigSummary (configId = config key), plus pooling and partial information. */
114
+ export interface ViewConfigSummary extends ConfigSummary {
115
+ configKey: string;
116
+ /** The config file names its trials in view were run under (a renamed file keeps its history). */
117
+ configFiles: string[];
118
+ /** Task id → the trials pooled for that task. */
119
+ pooled: Record<string, Pooled>;
120
+ /**
121
+ * Suite id → null when the config has a valid score on every task of the suite (it has a Suite Index), else
122
+ * how many it has: the row reads "partial (have of of tasks)" and `suites[id].mean` is null.
123
+ */
124
+ partial: Record<string, { have: number; of: number } | null>;
125
+ /**
126
+ * How many distinct user setups and Profile versions all the config's pooled trials in the view ran under.
127
+ * Above 1 the row says so ("2 setups, 2 Profile versions"). Information only: never part of comparability (DEC-3).
128
+ */
129
+ conditions: { setups: number; profiles: number };
130
+ }
131
+
132
+ /** A count of left-out trials and of the distinct configs (config keys) they belong to. */
133
+ export interface LeftOutCount { trials: number; configs: number }
134
+
135
+ /** A grading version of an earlier task version, with how many of that version's trials it holds (see OtherTaskVersion). */
136
+ export type CountedGradingSetup = NamedGradingSetup & { trials: number };
137
+
138
+ /** One earlier (or other) task version that some trials ran on. */
139
+ export interface OtherTaskVersion {
140
+ taskId: string;
141
+ /** Full task hash. */
142
+ taskHash: string;
143
+ /** The task file's `version` stored with that hash, or null if the store has no record of it. */
144
+ label: number | null;
145
+ /**
146
+ * Trials in scope and the date range that ran on this task hash, every ended one (failed ones and a human
147
+ * config's earlier trials too). A grading version's `trials` counts fewer: only the ones the view would show.
148
+ */
149
+ trials: number;
150
+ /**
151
+ * Every grading version stored for this task hash, oldest first, with its name and `trials`: how many of this
152
+ * version's trials (counted as the view counts them) have a latest grading under it that is valid.
153
+ * Set one as `grading` to view this version.
154
+ */
155
+ gradingSetups: CountedGradingSetup[];
156
+ /**
157
+ * The grading version a link to this task version opens (see linkTarget), or null when none is stored.
158
+ */
159
+ link: string | null;
160
+ }
161
+
162
+ /**
163
+ * Trials in scope and in the date range that the view leaves out, by reason. Trials outside the scope or the
164
+ * date range are not counted anywhere. A human config's earlier trials (only its latest counts) and trials
165
+ * that have not ended are not counted either.
166
+ */
167
+ export interface LeftOut {
168
+ /** Ran on the task hash in view, but graded only under another grading hash (other criteria or another judge). Regrading brings them in. */
169
+ otherGrading: LeftOutCount;
170
+ /** Ran on another task hash: they did a different job. Reached only by choosing one of its grading setups. */
171
+ otherTask: LeftOutCount & { versions: OtherTaskVersion[] };
172
+ /** Ran on the task hash in view and ended, but have no finished grading under any grading hash. */
173
+ ungraded: LeftOutCount;
174
+ /**
175
+ * The ids of the trials counted under `otherGrading` and `ungraded`, sorted: what a regrade of this view
176
+ * grades (POST /api/regrade), so it matches the view's date range, facets and human-config rule exactly.
177
+ */
178
+ gradable: string[];
179
+ }
180
+
181
+ export interface View {
182
+ /** The filters as resolved: ids, the judge, and the date bounds actually applied (ISO times). */
183
+ filters: {
184
+ suiteIds: string[]; taskIds: string[]; judge: JudgeConfig; grading: string | null; since: string | null; until: string | null;
185
+ /** The values of each config facet applied, sorted (absent when not set). */
186
+ harness?: string[]; model?: string[]; effort?: string[]; profile?: string[];
187
+ };
188
+ /** Task id → the task in scope. */
189
+ tasks: Record<string, ViewTask>;
190
+ /** The trials in the view, by task, config key, then id. */
191
+ trials: ViewTrial[];
192
+ /** One row per config key with a trial in the view, and the dead criteria among those configs. */
193
+ summary: { configs: ViewConfigSummary[]; deadCriteria: Record<string, string[]> };
194
+ leftOut: LeftOut;
195
+ }
196
+
197
+ // ---- The view ----------------------------------------------------------------------------------
198
+
199
+ /** Selects and summarises the comparable trials for `filters`. Throws on an unknown id or an unusable filter. */
200
+ export function buildView(store: Store, filters: ViewFilters = {}, catalog: Catalog = projectCatalog()): View {
201
+ const { suites, taskIds } = scope(filters, catalog);
202
+ const since = bound(filters.since, "since"), until = bound(filters.until, "until");
203
+ const picked = filters.grading ? pickGrading(store, filters.grading) : null;
204
+ // A view shows one judge: a picked grading setup's judge, for every task in scope.
205
+ const judge = picked ? setupJudge(picked, filters.judge, catalog.judges) : pickJudge(filters.judge, catalog.judges);
206
+ const pickedTasks = new Set(picked ? store.gradingSetupTaskIds(picked.hash) : []);
207
+ if (picked && !taskIds.some((t) => pickedTasks.has(t))) {
208
+ throw new Error(`grading version ${picked.hash} is for task ${[...pickedTasks].join(", ")}, which is not in scope (${taskIds.join(", ")})`);
209
+ }
210
+
211
+ const tasks: Record<string, ViewTask> = {};
212
+ const inView: ViewTrial[] = [];
213
+ const records = new Map<string, TrialRecord>(); // trial id → its row, for the config fields
214
+ const left = { otherGrading: new Counter(), otherTask: new Counter(), ungraded: new Counter() };
215
+ const otherVersions = new Map<string, { version: OtherTaskVersion; rows: TrialRecord[] }>(); // with its trials, to count per grading version
216
+ const gradable: string[] = [];
217
+
218
+ for (const taskId of taskIds) {
219
+ const task = catalog.loadTask(taskId);
220
+ const nowTask = currentTaskHash(task), nowGrading = currentGradingHash(task, judge);
221
+ const names = versionNames(store, taskId);
222
+ const tHash = pickedTasks.has(taskId) ? picked!.taskHash : nowTask;
223
+ const gHash = pickedTasks.has(taskId) ? picked!.hash : nowGrading;
224
+ const stored = store.getTaskVersion(tHash);
225
+ const label = stored ? stored.label : task.version ?? null; // a stored task version keeps its label, even none
226
+ tasks[taskId] = {
227
+ taskHash: tHash, gradingHash: gHash, gradingName: names.get(gHash)?.name ?? null, // null: today's grading version is not stored yet
228
+ label,
229
+ current: tHash === nowTask && gHash === nowGrading,
230
+ def: gHash === nowGrading ? snapshotOf(task) : taskDefinition(task, picked!, label),
231
+ };
232
+
233
+ const rows = store.listTrials({ taskId }).filter((r) => ENDED.has(r.status) && inRange(r.startedAt, since, until) && matchesConfig(r, filters));
234
+ const same: TrialRecord[] = [];
235
+ for (const r of rows) {
236
+ if (r.taskHash === tHash) { same.push(r); continue; }
237
+ left.otherTask.add(r.configKey);
238
+ const key = `${taskId}\0${r.taskHash}`; // two tasks can share an assignment (task hash): each keeps its own entry
239
+ const v = otherVersions.get(key) ?? otherVersions.set(key, { version: otherVersion(store, taskId, r.taskHash, names), rows: [] }).get(key)!;
240
+ v.version.trials++;
241
+ v.rows.push(r);
242
+ }
243
+ for (const r of latestHumanOnly(same)) {
244
+ records.set(r.id, r);
245
+ if (r.status !== "done") { inView.push(viewTrial(r, null, [])); continue; } // failed, or copied the held-out original
246
+ const g = store.currentGrading(r.id, gHash);
247
+ if (g) { inView.push(viewTrial(r, g, store.listVerdicts(g.id))); continue; }
248
+ const other = store.listGradings(r.id).some((x) => x.status !== "grading");
249
+ (other ? left.otherGrading : left.ungraded).add(r.configKey);
250
+ gradable.push(r.id);
251
+ }
252
+ }
253
+
254
+ for (const { version: v, rows } of otherVersions.values()) {
255
+ const shown = latestHumanOnly(rows).filter((r) => r.status === "done");
256
+ for (const g of v.gradingSetups) g.trials = shown.filter((r) => store.currentGrading(r.id, g.hash)?.status === "done").length;
257
+ v.link = linkTarget(v.gradingSetups, judge);
258
+ }
259
+
260
+ inView.sort((a, b) => cmp(a.taskId, b.taskId) || cmp(a.configKey, b.configKey) || cmp(a.id, b.id));
261
+ return {
262
+ filters: {
263
+ suiteIds: suites.map((s) => s.id), taskIds, judge, grading: picked?.hash ?? null, since, until,
264
+ ...Object.fromEntries(CONFIG_FACETS.map((k) => [k, valueSet(filters[k] ?? [])] as const).filter(([, v]) => v.length)),
265
+ },
266
+ tasks,
267
+ trials: inView,
268
+ summary: summarizeTrials(inView, records, tasks, suites),
269
+ leftOut: {
270
+ otherGrading: left.otherGrading.count(),
271
+ otherTask: { ...left.otherTask.count(), versions: [...otherVersions.values()].map((x) => x.version) },
272
+ ungraded: left.ungraded.count(),
273
+ gradable: gradable.sort(),
274
+ },
275
+ };
276
+ }
277
+
278
+ // ---- Selection ---------------------------------------------------------------------------------
279
+
280
+ const ENDED = new Set(["done", "failed", "invalid"]);
281
+ const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
282
+
283
+ function scope(f: ViewFilters, catalog: Catalog): { suites: Suite[]; taskIds: string[] } {
284
+ if (f.tasks?.length) return { suites: [], taskIds: [...new Set(f.tasks)] };
285
+ const byId = new Map(catalog.suites.map((s) => [s.id, s]));
286
+ const suites = f.suites?.length
287
+ ? [...new Set(f.suites)].map((id) => byId.get(id) ?? fail(`no suite ${id} (suites: ${[...byId.keys()].join(", ") || "none"})`))
288
+ : catalog.suites;
289
+ return { suites, taskIds: [...new Set(suites.flatMap((s) => s.tasks))] };
290
+ }
291
+
292
+ function pickJudge(id: string | undefined, judges: JudgeConfig[]): JudgeConfig {
293
+ if (!judges.length) fail("judges.yaml lists no judge");
294
+ if (id === undefined) return judges[0]!;
295
+ return judges.find((j) => j.id === id) ?? fail(`no judge ${id} (judges: ${judges.map((j) => j.id).join(", ")})`);
296
+ }
297
+
298
+ /** True when a judge config is the judge a grading setup recorded (harness, model, effort). */
299
+ export const isSetupJudge = (j: Pick<JudgeConfig, "harness" | "model" | "effort">, s: Pick<GradingSetupRecord, "judgeHarness" | "judgeModel" | "judgeEffort">) =>
300
+ j.harness === s.judgeHarness && j.model === s.judgeModel && (j.effort ?? null) === s.judgeEffort;
301
+
302
+ /**
303
+ * The judge of a view on a picked grading setup: the setup's judge, from judges.yaml when it is there, else built
304
+ * from the setup's own fields. A judge id given with it must name that judge.
305
+ */
306
+ function setupJudge(s: GradingSetupRecord, id: string | undefined, judges: JudgeConfig[]): JudgeConfig {
307
+ if (id !== undefined) {
308
+ const named = pickJudge(id, judges);
309
+ if (!isSetupJudge(named, s)) {
310
+ fail(`grading version ${s.hash.slice(0, 12)} was graded by judge ${s.judgeLabel}; it cannot be shown with judge ${id}. Leave out the judge, or pick a grading version of that judge.`);
311
+ }
312
+ return named;
313
+ }
314
+ return judges.find((j) => isSetupJudge(j, s)) ?? {
315
+ id: [s.judgeHarness, s.judgeModel, s.judgeEffort].filter(Boolean).join("/"), label: s.judgeLabel,
316
+ harness: s.judgeHarness as JudgeConfig["harness"], model: s.judgeModel, ...(s.judgeEffort ? { effort: s.judgeEffort } : {}),
317
+ };
318
+ }
319
+
320
+ function pickGrading(store: Store, prefix: string): GradingSetupRecord {
321
+ const p = prefix.toLowerCase();
322
+ const found = store.listGradingSetups().filter((g) => g.hash.startsWith(p));
323
+ if (found.length === 1) return found[0]!;
324
+ return fail(found.length ? `grading ${prefix} matches ${found.length} grading versions; give more characters` : `no grading version ${prefix}`);
325
+ }
326
+
327
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
328
+
329
+ /** A filter's date bound as an ISO time: since → start of the day, until → last millisecond of the day. */
330
+ function bound(value: string | undefined, which: "since" | "until"): string | null {
331
+ if (value === undefined || value === "") return null;
332
+ const iso = DATE_ONLY.test(value) ? `${value}T${which === "since" ? "00:00:00.000" : "23:59:59.999"}Z` : value;
333
+ const t = new Date(iso);
334
+ if (Number.isNaN(t.getTime())) fail(`--${which} ${value} is not a date (use 2026-11-06 or an ISO time)`);
335
+ return t.toISOString();
336
+ }
337
+
338
+ function inRange(startedAt: string | null, since: string | null, until: string | null): boolean {
339
+ if (!since && !until) return true;
340
+ if (!startedAt) return false;
341
+ const t = new Date(startedAt).getTime();
342
+ return (!since || t >= new Date(since).getTime()) && (!until || t <= new Date(until).getTime());
343
+ }
344
+
345
+ export { CONFIG_FACETS };
346
+ export type { ConfigFacet };
347
+ /** The value used for a config without an effort or a Profile. */
348
+ export const NONE = "none";
349
+
350
+ /** A trial's value for a config facet: its field, or `none` when the config has no effort or Profile. */
351
+ export const facetValue = (r: TrialRecord, k: ConfigFacet): string => r[k] || NONE;
352
+
353
+ /** True when, for every config facet with a value, the trial's config has one of its values. Empty values are no pick. */
354
+ const matchesConfig = (r: TrialRecord, f: ViewFilters) => CONFIG_FACETS.every((k) => { const v = valueSet(f[k] ?? []); return !v.length || v.includes(facetValue(r, k)); });
355
+
356
+ /** A human config counts only its latest trial per task: each run grades the newest draft. Rows are of one task. */
357
+ function latestHumanOnly(rows: TrialRecord[]): TrialRecord[] {
358
+ const latest = new Map<string, TrialRecord>();
359
+ for (const r of rows) {
360
+ if (!r.configKey.startsWith("human:")) continue;
361
+ const cur = latest.get(r.configKey);
362
+ if (!cur || cmp(`${r.startedAt ?? ""}\u0000${r.id}`, `${cur.startedAt ?? ""}\u0000${cur.id}`) > 0) latest.set(r.configKey, r);
363
+ }
364
+ return rows.filter((r) => !r.configKey.startsWith("human:") || latest.get(r.configKey) === r);
365
+ }
366
+
367
+ class Counter {
368
+ private trials = 0;
369
+ private configs = new Set<string>();
370
+ add(configKey: string) { this.trials++; this.configs.add(configKey); }
371
+ count(): LeftOutCount { return { trials: this.trials, configs: this.configs.size }; }
372
+ }
373
+
374
+ function otherVersion(store: Store, taskId: string, taskHash: string, names: Map<string, VersionName>): OtherTaskVersion {
375
+ return {
376
+ taskId, taskHash, label: store.getTaskVersion(taskHash)?.label ?? null, trials: 0,
377
+ gradingSetups: store.listGradingSetups(taskId).filter((g) => g.taskHash === taskHash).map((s) => ({ ...namedSetup(s, names), trials: 0 })),
378
+ link: null,
379
+ };
380
+ }
381
+
382
+ /**
383
+ * The grading version a link to an earlier task version opens, so the reader sees that version's trials: one by
384
+ * the judge in view when there is one (else any judge), then the one holding the most trials, then the newest.
385
+ * `setups` is oldest first. Null when there is none.
386
+ */
387
+ function linkTarget(setups: CountedGradingSetup[], judge: JudgeConfig): string | null {
388
+ const byJudge = setups.filter((g) => g.judgeHarness === judge.harness && g.judgeModel === judge.model && (g.judgeEffort ?? null) === (judge.effort ?? null));
389
+ const pool = byJudge.length ? byJudge : setups;
390
+ let best: CountedGradingSetup | undefined;
391
+ for (const g of pool) if (!best || g.trials >= best.trials) best = g; // >=: on a tie the later (newer) one wins
392
+ return best?.hash ?? null;
393
+ }
394
+
395
+ // ---- Task definitions --------------------------------------------------------------------------
396
+
397
+ const snapshotOf = (t: Task): TaskSnapshot => ({ id: t.id, title: t.title, description: t.description, version: t.version, gates: t.gates, measures: t.measures });
398
+
399
+ /**
400
+ * The criteria an earlier grading setup graded with, from its snapshot. The snapshot leaves out what cannot
401
+ * change a score, so `level` and measure labels come from today's task file where the id still exists
402
+ * (else "strong" and the measure id).
403
+ */
404
+ function taskDefinition(task: Task, setup: GradingSetupRecord, version: number | null): TaskSnapshot {
405
+ const snap = setup.snapshot as unknown as GradingContent;
406
+ const today = new Map<string, Criterion>([...task.gates, ...task.measures.flatMap((m) => m.criteria)].map((c) => [c.id, c]));
407
+ const labels = new Map(task.measures.map((m) => [m.id, m.label]));
408
+ const crit = (c: CriterionContent): Criterion => ({ ...c, level: today.get(c.id)?.level ?? "strong" });
409
+ const byId = new Map(snap.criteria.map((c) => [c.id, c]));
410
+ const measures: Measure[] = snap.measures.map((m) => ({
411
+ id: m.id, label: labels.get(m.id) ?? m.id, weight: m.weight,
412
+ criteria: m.criteria.map((id) => byId.get(id)).filter((c): c is CriterionContent => !!c).map(crit),
413
+ }));
414
+ return { id: task.id, title: task.title, description: task.description, ...(version != null ? { version } : {}), gates: snap.gates.map(crit), measures };
415
+ }
416
+
417
+ // ---- Trials ------------------------------------------------------------------------------------
418
+
419
+ function viewTrial(r: TrialRecord, g: GradingRecord | null, verdicts: ReturnType<Store["listVerdicts"]>): ViewTrial {
420
+ const byCriterion: Record<string, Verdict> = {};
421
+ for (const v of verdicts) byCriterion[v.criterionId] = { score: v.score, votes: v.votes, ...(v.error ? { error: v.error } : {}) };
422
+ const grades: Grades | undefined = g ? {
423
+ verdicts: byCriterion,
424
+ gatesPassed: g.gatesPassed ?? false,
425
+ measures: g.measures ?? {},
426
+ score: g.score ?? 0,
427
+ errors: verdicts.filter((v) => v.score == null).map((v) => v.criterionId),
428
+ judgeCostUsd: g.judgeCostUsd ?? 0,
429
+ } : undefined;
430
+ const status = r.status === "done" && g?.status === "invalid" ? "invalid" : (r.status as "done" | "failed" | "invalid");
431
+ return {
432
+ id: r.id, experimentId: r.batchId, batchId: r.batchId, taskId: r.taskId, taskHash: r.taskHash,
433
+ configId: r.configKey, configKey: r.configKey, configFile: r.configId, index: r.n, status,
434
+ startedAt: r.startedAt ?? undefined, endedAt: r.endedAt ?? undefined, durationMs: r.durationMs ?? undefined,
435
+ result: {
436
+ ok: r.status !== "failed", ...(r.error ? { error: r.error } : {}),
437
+ usage: { inputTokens: r.inputTokens ?? 0, outputTokens: r.outputTokens ?? 0, cacheReadTokens: r.cacheReadTokens ?? 0, cacheWriteTokens: r.cacheWriteTokens ?? 0 },
438
+ costUsd: r.costUsd, costSource: (r.costSource ?? "unknown") as "reported" | "estimated" | "unknown",
439
+ ...(r.turns != null ? { turns: r.turns } : {}),
440
+ },
441
+ grades,
442
+ ...(r.error ? { error: r.error } : g?.error ? { error: g.error } : {}),
443
+ gradingId: g?.id ?? null, gradedAt: g?.gradedAt ?? null,
444
+ };
445
+ }
446
+
447
+ // ---- Summary (score.ts does the numbers) -------------------------------------------------------
448
+
449
+ /** The view's summary over `trials` (each with its row in `records`), for the tasks and suites given. Also used by compareGradings. */
450
+ export function summarizeTrials(trials: ViewTrial[], records: Map<string, TrialRecord>, tasks: Record<string, ViewTask>, suites: Suite[]): View["summary"] {
451
+ const keys = [...new Set(trials.map((t) => t.configKey))].sort();
452
+ const configs: Record<string, AgentConfig> = {};
453
+ const files = new Map<string, Set<string>>();
454
+ for (const t of trials) {
455
+ // Every trial of a key has the same config fields (they make the key); the latest one's family wins.
456
+ configs[t.configKey] = configOf(records.get(t.id)!);
457
+ (files.get(t.configKey) ?? files.set(t.configKey, new Set()).get(t.configKey)!).add(t.configFile);
458
+ }
459
+ const exp: Experiment = {
460
+ schema: 2, id: "view", suiteIds: suites.map((s) => s.id),
461
+ tasks: Object.fromEntries(Object.entries(tasks).map(([id, t]) => [id, t.label])),
462
+ configIds: keys, trialsPerConfig: 0, judge: { id: "", label: "", harness: "claude", model: "", votes: 0 },
463
+ createdAt: "", status: "done", configs, suites: Object.fromEntries(suites.map((s) => [s.id, s])),
464
+ };
465
+ const defs = Object.values(tasks).map((t) => t.def as Task);
466
+ const s = summarize(exp, trials, defs);
467
+ return {
468
+ deadCriteria: s.deadCriteria,
469
+ configs: s.configs.map((c): ViewConfigSummary => {
470
+ const pooled: Record<string, Pooled> = {};
471
+ const conditions = (valid: ViewTrial[]) => {
472
+ const distinct = (pick: (r: TrialRecord) => string | null) => new Set(valid.map((t) => pick(records.get(t.id)!)).filter((x) => x != null)).size;
473
+ return { setups: distinct((r) => r.setupHash), profiles: distinct((r) => r.profileHash) };
474
+ };
475
+ const all: ViewTrial[] = [];
476
+ for (const taskId of Object.keys(tasks)) {
477
+ const valid = trials.filter((t) => t.configKey === c.configId && t.taskId === taskId && t.status === "done" && t.grades);
478
+ all.push(...valid);
479
+ const starts = valid.map((t) => t.startedAt).filter((x): x is string => !!x).sort();
480
+ pooled[taskId] = { n: valid.length, from: starts[0] ?? null, to: starts.at(-1) ?? null, batches: [...new Set(valid.map((t) => t.batchId))].sort(), ...conditions(valid) };
481
+ }
482
+ const partial: ViewConfigSummary["partial"] = {};
483
+ for (const suite of suites) {
484
+ const have = suite.tasks.filter((t) => c.tasks[t]?.score.mean != null).length;
485
+ partial[suite.id] = have === suite.tasks.length ? null : { have, of: suite.tasks.length };
486
+ }
487
+ return { ...c, configKey: c.configId, configFiles: [...files.get(c.configId)!].sort(), pooled, partial, conditions: conditions(all) };
488
+ }),
489
+ };
490
+ }
491
+
492
+ const configOf = (r: TrialRecord): AgentConfig => ({
493
+ id: r.configKey, harness: r.harness as AgentConfig["harness"], model: r.model,
494
+ ...(r.effort ? { effort: r.effort } : {}), ...(r.profile ? { profile: r.profile } : {}),
495
+ ...(r.provider ? { provider: r.provider } : {}), ...(r.family ? { family: r.family } : {}),
496
+ });
497
+
498
+ function fail(message: string): never { throw new Error(message); }