static-fire 0.0.0-stage → 0.54.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +236 -2
- package/bin/static-fire.js +46 -0
- package/package.json +30 -4
- package/pricing.yaml +20 -0
- package/src/adapters/index.ts +153 -0
- package/src/api.ts +250 -0
- package/src/cli.ts +383 -0
- package/src/core/apkit-install.ts +47 -0
- package/src/core/authored.ts +47 -0
- package/src/core/batch-state.ts +25 -0
- package/src/core/batch.ts +450 -0
- package/src/core/batches.ts +221 -0
- package/src/core/checks.ts +63 -0
- package/src/core/commands.ts +43 -0
- package/src/core/compare.ts +206 -0
- package/src/core/config.ts +25 -0
- package/src/core/copying.ts +18 -0
- package/src/core/coverage.ts +84 -0
- package/src/core/db.ts +608 -0
- package/src/core/derived.ts +74 -0
- package/src/core/engine.ts +85 -0
- package/src/core/facets.ts +143 -0
- package/src/core/grade.ts +197 -0
- package/src/core/grading-versions.ts +281 -0
- package/src/core/identity.ts +263 -0
- package/src/core/init.ts +321 -0
- package/src/core/judge-schema.ts +37 -0
- package/src/core/judge.ts +166 -0
- package/src/core/label.ts +43 -0
- package/src/core/lock.ts +40 -0
- package/src/core/migrate.ts +288 -0
- package/src/core/new-task.ts +85 -0
- package/src/core/outside-tools.ts +129 -0
- package/src/core/plan.ts +84 -0
- package/src/core/profiles.ts +42 -0
- package/src/core/project.ts +140 -0
- package/src/core/regrade.ts +242 -0
- package/src/core/score.ts +148 -0
- package/src/core/settings.ts +44 -0
- package/src/core/task-file.ts +95 -0
- package/src/core/task-page.ts +54 -0
- package/src/core/trial-detail.ts +73 -0
- package/src/core/types.ts +195 -0
- package/src/core/view-query.ts +102 -0
- package/src/core/view.ts +576 -0
- package/src/guide.md +624 -0
- package/src/server.ts +28 -0
- package/web/api.ts +6 -0
- package/web/app.tsx +1162 -0
- package/web/brand/offset-tokens.css +126 -0
- package/web/charts.tsx +190 -0
- package/web/compare.tsx +317 -0
- package/web/encoding.tsx +55 -0
- package/web/filter-menu.ts +195 -0
- package/web/how-it-works.tsx +92 -0
- package/web/index-switches.ts +29 -0
- package/web/index.html +17 -0
- package/web/levels.tsx +15 -0
- package/web/notices.ts +74 -0
- package/web/output-view.ts +61 -0
- package/web/pages.ts +42 -0
- package/web/run-status.ts +51 -0
- package/web/styles.css +368 -0
- package/web/task-card.ts +6 -0
- package/web/tasks.tsx +122 -0
- package/web/wording.ts +38 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Grading versions: the difference between two of them, and the name each one gets from what changed since the
|
|
2
|
+
// version before it. Built only from stored snapshots (the grading_setups and task_versions rows); today's task
|
|
3
|
+
// files are never read. The name (facets, view, compare) and the Compare page's "What changed" both come from
|
|
4
|
+
// one difference result, which is plain data.
|
|
5
|
+
import type { GradingSetupRecord, Store, TaskVersionRecord } from "./db";
|
|
6
|
+
import { canonicalJson, engineOf } from "./identity";
|
|
7
|
+
import { engineChangeName, type EngineChange, type EngineParts } from "./engine";
|
|
8
|
+
import type { CriterionContent, GradingContent, TaskContent } from "./identity";
|
|
9
|
+
|
|
10
|
+
/** Files of one folder that differ: by path, as the snapshot keys them. */
|
|
11
|
+
export interface FileChanges { added: string[]; removed: string[]; changed: string[] }
|
|
12
|
+
|
|
13
|
+
/** A gate or measure criterion that differs. `fields` lists each changed field (question, check, evidence…) with both values. */
|
|
14
|
+
export interface CriterionDiff {
|
|
15
|
+
id: string;
|
|
16
|
+
kind: "gate" | "criterion";
|
|
17
|
+
change: "added" | "removed" | "changed";
|
|
18
|
+
fields: { field: string; a: unknown; b: unknown }[];
|
|
19
|
+
/** The criterion under version a and version b; null on the side it is missing from. */
|
|
20
|
+
a: CriterionContent | null;
|
|
21
|
+
b: CriterionContent | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A measure that differs: added, removed, a changed weight, or criteria put in or taken out of it. */
|
|
25
|
+
export interface MeasureDiff {
|
|
26
|
+
id: string;
|
|
27
|
+
change: "added" | "removed" | "changed";
|
|
28
|
+
/** Weight under each version (null on the side the measure is missing from), or null when it did not change. */
|
|
29
|
+
weight: { a: number | null; b: number | null } | null;
|
|
30
|
+
/** Criterion ids in the measure under b and not a, and under a and not b. */
|
|
31
|
+
criteria: { added: string[]; removed: string[] };
|
|
32
|
+
/** True when the only change is criteria added to or removed from the task as a whole (no weight change, no move). */
|
|
33
|
+
byCriteriaOnly: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface JudgeInfo { harness: string; model: string; effort: string | null; label: string }
|
|
37
|
+
|
|
38
|
+
/** Everything that differs between grading versions a and b. Each part is null or empty when that part is the same. */
|
|
39
|
+
export interface GradingDiff {
|
|
40
|
+
/** True when nothing differs. */
|
|
41
|
+
same: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* True when something differs and every difference is in the gates and criteria. A measure whose criteria list
|
|
44
|
+
* changed only because criteria were added or removed counts as a criteria change. Any other difference (a
|
|
45
|
+
* measure or weight, context, judge, engine part, task hash or file) makes it false.
|
|
46
|
+
*/
|
|
47
|
+
criteriaOnly: boolean;
|
|
48
|
+
/** Gates and measure criteria, sorted by kind then id. */
|
|
49
|
+
criteria: CriterionDiff[];
|
|
50
|
+
/** Measures, sorted by id. */
|
|
51
|
+
measures: MeasureDiff[];
|
|
52
|
+
context: { a: string | null; b: string | null } | null;
|
|
53
|
+
judge: { a: JudgeInfo; b: JudgeInfo } | null;
|
|
54
|
+
/**
|
|
55
|
+
* The parts of the engine revision that both versions use and that differ, in a fixed order. A part that only one
|
|
56
|
+
* version uses is not here: the criterion that brought it in or took it out is in `criteria`.
|
|
57
|
+
*/
|
|
58
|
+
engine: EngineChange[];
|
|
59
|
+
/**
|
|
60
|
+
* What the agent was given, when the task hash differs. `prompt` and `output` say whether they changed; the file
|
|
61
|
+
* changes come from the stored task version snapshots (empty when one is missing). `label` is the task file's `version`.
|
|
62
|
+
*/
|
|
63
|
+
task: {
|
|
64
|
+
a: string; b: string; label: { a: number | null; b: number | null };
|
|
65
|
+
prompt: boolean; output: boolean; inputs: FileChanges; project: FileChanges;
|
|
66
|
+
} | null;
|
|
67
|
+
/** Grader-only files: references, sources, the task's grader/ folder, and the held-out original. Only names, never content. */
|
|
68
|
+
files: { references: FileChanges; sources: FileChanges; grader: FileChanges; heldOut: boolean };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One stored grading version as the difference reads it. */
|
|
72
|
+
export interface StoredVersion { setup: GradingSetupRecord; task: TaskVersionRecord | null }
|
|
73
|
+
|
|
74
|
+
const same = (x: unknown, y: unknown) => canonicalJson(x ?? null) === canonicalJson(y ?? null);
|
|
75
|
+
const sorted = (xs: Iterable<string>) => [...xs].sort();
|
|
76
|
+
|
|
77
|
+
function fileChanges(a: Record<string, string> = {}, b: Record<string, string> = {}): FileChanges {
|
|
78
|
+
return {
|
|
79
|
+
added: sorted(Object.keys(b).filter((k) => !Object.hasOwn(a, k))),
|
|
80
|
+
removed: sorted(Object.keys(a).filter((k) => !Object.hasOwn(b, k))),
|
|
81
|
+
changed: sorted(Object.keys(a).filter((k) => Object.hasOwn(b, k) && a[k] !== b[k])),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const anyFile = (f: FileChanges) => f.added.length + f.removed.length + f.changed.length > 0;
|
|
85
|
+
|
|
86
|
+
function criteriaDiff(kind: CriterionDiff["kind"], as: CriterionContent[] = [], bs: CriterionContent[] = []): CriterionDiff[] {
|
|
87
|
+
const ma = new Map(as.map((c) => [c.id, c])), mb = new Map(bs.map((c) => [c.id, c]));
|
|
88
|
+
return sorted(new Set([...ma.keys(), ...mb.keys()])).flatMap((id): CriterionDiff[] => {
|
|
89
|
+
const a = ma.get(id) ?? null, b = mb.get(id) ?? null;
|
|
90
|
+
if (!a) return [{ id, kind, change: "added", fields: [], a: null, b }];
|
|
91
|
+
if (!b) return [{ id, kind, change: "removed", fields: [], a, b: null }];
|
|
92
|
+
const keys = sorted(new Set([...Object.keys(a), ...Object.keys(b)].filter((k) => k !== "id")));
|
|
93
|
+
const fields = keys.filter((k) => !same((a as any)[k], (b as any)[k])).map((field) => ({ field, a: (a as any)[field] ?? null, b: (b as any)[field] ?? null }));
|
|
94
|
+
return fields.length ? [{ id, kind, change: "changed", fields, a, b }] : [];
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function engineDiff(sa: Partial<GradingContent>, sb: Partial<GradingContent>): EngineChange[] {
|
|
99
|
+
const ea = engineOf(sa), eb = engineOf(sb), out: EngineChange[] = [];
|
|
100
|
+
const both = (part: EngineChange["part"], a: number | string | undefined, b: number | string | undefined, name: string | null = null) => {
|
|
101
|
+
if (a !== undefined && b !== undefined && a !== b) out.push({ part, name, a, b });
|
|
102
|
+
};
|
|
103
|
+
both("instructions", ea.instructions, eb.instructions);
|
|
104
|
+
for (const t of sorted(Object.keys(ea.checks))) both("check", ea.checks[t], eb.checks[t], t);
|
|
105
|
+
both("derived", ea.derived, eb.derived);
|
|
106
|
+
// The text for derived evidence holds the shared wording too, so it counts only when the shared wording is the same.
|
|
107
|
+
if (ea.instructions === eb.instructions) both("instructions", ea.derivedInstructions, eb.derivedInstructions, "derived");
|
|
108
|
+
// A judge call revision counts for one harness: with another judge harness, the judge itself is the change.
|
|
109
|
+
if (sa.judge?.harness === sb.judge?.harness) both("judgeCall", ea.judgeCall, eb.judgeCall, sa.judge?.harness ?? null);
|
|
110
|
+
both("judging", ea.judging, eb.judging);
|
|
111
|
+
both("scoring", ea.scoring, eb.scoring);
|
|
112
|
+
// Two snapshots of before the parts read as the same first values, so their one number is compared as it is.
|
|
113
|
+
// Not when only one side has derived evidence: its number is 2 for that reason, and the criterion is the change.
|
|
114
|
+
if (!sa.engine && !sb.engine && (ea.derived === undefined) === (eb.derived === undefined)) both("revision", sa.graderRev, sb.graderRev);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const judgeOf = (s: GradingSetupRecord): JudgeInfo => ({ harness: s.judgeHarness, model: s.judgeModel, effort: s.judgeEffort, label: s.judgeLabel });
|
|
119
|
+
|
|
120
|
+
/** The difference between grading versions a and b, from their stored snapshots only. */
|
|
121
|
+
export function gradingDiff(a: StoredVersion, b: StoredVersion): GradingDiff {
|
|
122
|
+
const sa = a.setup.snapshot as unknown as Partial<GradingContent>, sb = b.setup.snapshot as unknown as Partial<GradingContent>;
|
|
123
|
+
|
|
124
|
+
const criteria = [...criteriaDiff("gate", sa.gates, sb.gates), ...criteriaDiff("criterion", sa.criteria, sb.criteria)];
|
|
125
|
+
const addedOrRemoved = new Set(criteria.filter((c) => c.kind === "criterion" && c.change !== "changed").map((c) => c.id));
|
|
126
|
+
|
|
127
|
+
const ma = new Map((sa.measures ?? []).map((m) => [m.id, m])), mb = new Map((sb.measures ?? []).map((m) => [m.id, m]));
|
|
128
|
+
const measures = sorted(new Set([...ma.keys(), ...mb.keys()])).flatMap((id): MeasureDiff[] => {
|
|
129
|
+
const x = ma.get(id), y = mb.get(id);
|
|
130
|
+
const ca = new Set(x?.criteria ?? []), cb = new Set(y?.criteria ?? []);
|
|
131
|
+
const crit = { added: sorted([...cb].filter((c) => !ca.has(c))), removed: sorted([...ca].filter((c) => !cb.has(c))) };
|
|
132
|
+
const weight = x?.weight === y?.weight ? null : { a: x?.weight ?? null, b: y?.weight ?? null };
|
|
133
|
+
if (x && y && !weight && !crit.added.length && !crit.removed.length) return [];
|
|
134
|
+
const change = !x ? "added" : !y ? "removed" : "changed";
|
|
135
|
+
const byCriteriaOnly = change === "changed" && !weight && [...crit.added, ...crit.removed].every((c) => addedOrRemoved.has(c));
|
|
136
|
+
return [{ id, change, weight, criteria: crit, byCriteriaOnly }];
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const context = same(sa.context, sb.context) ? null : { a: sa.context ?? null, b: sb.context ?? null };
|
|
140
|
+
const ja = judgeOf(a.setup), jb = judgeOf(b.setup);
|
|
141
|
+
const judge = ja.harness === jb.harness && ja.model === jb.model && ja.effort === jb.effort ? null : { a: ja, b: jb };
|
|
142
|
+
const engine = engineDiff(sa, sb);
|
|
143
|
+
|
|
144
|
+
let task: GradingDiff["task"] = null;
|
|
145
|
+
if (a.setup.taskHash !== b.setup.taskHash) {
|
|
146
|
+
const ta = a.task?.snapshot as Partial<TaskContent> | undefined, tb = b.task?.snapshot as Partial<TaskContent> | undefined;
|
|
147
|
+
const both = !!ta && !!tb;
|
|
148
|
+
task = {
|
|
149
|
+
a: a.setup.taskHash, b: b.setup.taskHash, label: { a: a.task?.label ?? null, b: b.task?.label ?? null },
|
|
150
|
+
prompt: both && ta!.prompt !== tb!.prompt, output: both && ta!.output !== tb!.output,
|
|
151
|
+
inputs: both ? fileChanges(ta!.inputs, tb!.inputs) : fileChanges(),
|
|
152
|
+
project: both ? fileChanges(ta!.project, tb!.project) : fileChanges(),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const files = {
|
|
157
|
+
references: fileChanges(sa.references, sb.references),
|
|
158
|
+
sources: fileChanges(sa.sources, sb.sources),
|
|
159
|
+
grader: fileChanges(sa.graderFiles, sb.graderFiles),
|
|
160
|
+
heldOut: (sa.heldOut ?? null) !== (sb.heldOut ?? null),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const otherThanCriteria = measures.some((m) => !m.byCriteriaOnly) || !!context || !!judge || engine.length > 0 || !!task
|
|
164
|
+
|| anyFile(files.references) || anyFile(files.sources) || anyFile(files.grader) || files.heldOut;
|
|
165
|
+
const isSame = !criteria.length && !measures.length && !otherThanCriteria;
|
|
166
|
+
return { same: isSame, criteriaOnly: !isSame && !otherThanCriteria, criteria, measures, context, judge, engine, task, files };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const stored = (store: Store, setup: GradingSetupRecord): StoredVersion => ({ setup, task: store.getTaskVersion(setup.taskHash) });
|
|
170
|
+
|
|
171
|
+
/** The difference between two stored grading versions (full hashes). Throws when either is not stored. */
|
|
172
|
+
export function diffGradingVersions(store: Store, a: string, b: string): GradingDiff {
|
|
173
|
+
const get = (h: string) => store.getGradingSetup(h) ?? (() => { throw new Error(`no grading version ${h}`); })();
|
|
174
|
+
return gradingDiff(stored(store, get(a)), stored(store, get(b)));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- Names -------------------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export const ORIGINAL = "Original grading";
|
|
180
|
+
|
|
181
|
+
const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
|
182
|
+
const fileName = (path: string) => path.replace(/^grader\//, "");
|
|
183
|
+
|
|
184
|
+
/** The parts of a name, most telling first: what changed in `diff`, in short plain words. */
|
|
185
|
+
export function nameParts(diff: GradingDiff): string[] {
|
|
186
|
+
const parts: string[] = [];
|
|
187
|
+
// Criteria and gates: grouped by what happened to them.
|
|
188
|
+
const group = (label: string, ids: string[], one: string, many: string) => {
|
|
189
|
+
if (ids.length) parts.push(`${count(ids.length, one, many)} ${label}${ids.length === 1 ? `: ${ids[0]}` : ""}`);
|
|
190
|
+
};
|
|
191
|
+
for (const kind of ["criterion", "gate"] as const) {
|
|
192
|
+
const [one, many] = kind === "gate" ? ["gate", "gates"] : ["criterion", "criteria"];
|
|
193
|
+
const of = (change: CriterionDiff["change"], only?: string) => diff.criteria
|
|
194
|
+
.filter((c) => c.kind === kind && c.change === change && (only === undefined || (c.fields.length === 1 && c.fields[0]!.field === only)))
|
|
195
|
+
.map((c) => c.id);
|
|
196
|
+
const reworded = of("changed", "question"), checks = of("changed", "check");
|
|
197
|
+
const other = of("changed").filter((id) => !reworded.includes(id) && !checks.includes(id));
|
|
198
|
+
group("reworded", reworded, kind === "gate" ? "gate question" : "question", kind === "gate" ? "gate questions" : "questions");
|
|
199
|
+
group("changed", checks, kind === "gate" ? "gate check" : "check", kind === "gate" ? "gate checks" : "checks");
|
|
200
|
+
group("changed", other, one, many);
|
|
201
|
+
group("added", of("added"), one, many);
|
|
202
|
+
group("removed", of("removed"), one, many);
|
|
203
|
+
}
|
|
204
|
+
// A weight change, where the measure's criteria list changed only through criteria added or removed (named above).
|
|
205
|
+
const addedOrRemoved = new Set(diff.criteria.filter((c) => c.kind === "criterion" && c.change !== "changed").map((c) => c.id));
|
|
206
|
+
const weights = diff.measures.filter((m) => m.change === "changed" && m.weight
|
|
207
|
+
&& [...m.criteria.added, ...m.criteria.removed].every((c) => addedOrRemoved.has(c))).map((m) => m.id);
|
|
208
|
+
group("changed", weights, "weight", "weights");
|
|
209
|
+
if (diff.measures.some((m) => !m.byCriteriaOnly && !weights.includes(m.id))) parts.push("measures changed");
|
|
210
|
+
if (diff.context) parts.push("context changed");
|
|
211
|
+
const g = diff.files.grader, scripts = [...g.added, ...g.removed, ...g.changed];
|
|
212
|
+
if (scripts.length === 1) parts.push(`check script ${g.added.length ? "added" : g.removed.length ? "removed" : "changed"}: ${fileName(scripts[0]!)}`);
|
|
213
|
+
else if (scripts.length > 1) parts.push(`${scripts.length} check scripts changed`);
|
|
214
|
+
if (anyFile(diff.files.references)) parts.push("references changed");
|
|
215
|
+
if (anyFile(diff.files.sources)) parts.push("sources changed");
|
|
216
|
+
if (diff.files.heldOut) parts.push("held-out original changed");
|
|
217
|
+
if (diff.task) parts.push("task files changed");
|
|
218
|
+
if (diff.judge) parts.push("judge changed");
|
|
219
|
+
parts.push(...new Set(diff.engine.map(engineChangeName)));
|
|
220
|
+
return parts;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A name from its parts: at most three parts, else the first two and how many more. Starts with a capital. */
|
|
224
|
+
export function nameFrom(parts: string[]): string {
|
|
225
|
+
const shown = parts.length > 3 ? [...parts.slice(0, 2), `${parts.length - 2} more changes`] : parts;
|
|
226
|
+
const text = shown.join(", ") || "No change";
|
|
227
|
+
return text[0]!.toUpperCase() + text.slice(1);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A grading version's name, with the parts it is built from and the version before it. */
|
|
231
|
+
export interface VersionName {
|
|
232
|
+
name: string;
|
|
233
|
+
/** What changed since `previous`, in short words; empty for the original grading. */
|
|
234
|
+
nameParts: string[];
|
|
235
|
+
/** Full hash of the version before it for the same task and judge, or null when it is the first. */
|
|
236
|
+
previous: string | null;
|
|
237
|
+
/** The difference from `previous` to this version, or null when it is the first. */
|
|
238
|
+
diff: GradingDiff | null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const sameJudge = (x: GradingSetupRecord, y: GradingSetupRecord) =>
|
|
242
|
+
x.judgeHarness === y.judgeHarness && x.judgeModel === y.judgeModel && x.judgeEffort === y.judgeEffort;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The name of every stored grading version of a task, by full hash. The version before one is the one with the latest
|
|
246
|
+
* first_seen strictly earlier than its own, for the same task and judge (harness, model, effort); among versions first
|
|
247
|
+
* seen at that same time, the last in stored order (first_seen, then hash). With none, it is the "Original grading".
|
|
248
|
+
*/
|
|
249
|
+
export function versionNames(store: Store, taskId: string): Map<string, VersionName> {
|
|
250
|
+
const all = store.listGradingSetups(taskId);
|
|
251
|
+
const tasks = new Map<string, TaskVersionRecord | null>();
|
|
252
|
+
const of = (s: GradingSetupRecord): StoredVersion => {
|
|
253
|
+
if (!tasks.has(s.taskHash)) tasks.set(s.taskHash, store.getTaskVersion(s.taskHash));
|
|
254
|
+
return { setup: s, task: tasks.get(s.taskHash)! };
|
|
255
|
+
};
|
|
256
|
+
const out = new Map<string, VersionName>();
|
|
257
|
+
all.forEach((s, i) => {
|
|
258
|
+
const prev = all.slice(0, i).reverse().find((p) => p.firstSeen < s.firstSeen && sameJudge(p, s));
|
|
259
|
+
if (!prev) { out.set(s.hash, { name: ORIGINAL, nameParts: [], previous: null, diff: null }); return; }
|
|
260
|
+
const diff = gradingDiff(of(prev), of(s));
|
|
261
|
+
const parts = nameParts(diff);
|
|
262
|
+
out.set(s.hash, { name: nameFrom(parts), nameParts: parts, previous: prev.hash, diff });
|
|
263
|
+
});
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A stored grading version as the dashboard and CLI list it: its name, the judge and the engine parts it uses, without the snapshot. */
|
|
268
|
+
export type NamedGradingSetup = Pick<GradingSetupRecord, "hash" | "judgeHarness" | "judgeModel" | "judgeEffort" | "judgeLabel" | "firstSeen"> & {
|
|
269
|
+
/** The engine revision parts this version uses (a version of before the parts reads as their first values). */
|
|
270
|
+
engine: EngineParts;
|
|
271
|
+
/** The grading version name, for example "1 question reworded: subject-length". */
|
|
272
|
+
name: string;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** `s` with its name from `names` (versionNames of its task). Throws when `names` has no entry for it. */
|
|
276
|
+
export function namedSetup(s: GradingSetupRecord, names: Map<string, VersionName>): NamedGradingSetup {
|
|
277
|
+
const name = names.get(s.hash)?.name;
|
|
278
|
+
if (name === undefined) throw new Error(`grading version ${s.hash} has no name among its task's grading versions`);
|
|
279
|
+
const { hash, judgeHarness, judgeModel, judgeEffort, judgeLabel, firstSeen } = s;
|
|
280
|
+
return { hash, judgeHarness, judgeModel, judgeEffort, judgeLabel, engine: engineOf(s.snapshot as Partial<GradingContent>), firstSeen, name };
|
|
281
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Content identity: the hashes that decide whether two trials are comparable, and the config key.
|
|
2
|
+
// Task hash = what the agent was given. Grading hash = how an output was graded. Both are SHA-256 over
|
|
3
|
+
// canonical JSON, so only content counts: never a file name, the order of criteria, or the `version` label.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { join, relative, sep } from "node:path";
|
|
7
|
+
import { ENGINE, FIRST_INSTRUCTIONS, FIRST_REVISIONS, type EngineParts, type EngineRevisions } from "./engine";
|
|
8
|
+
import { instructionWording } from "./grade";
|
|
9
|
+
import { ENGINE_ROOT } from "./project";
|
|
10
|
+
import type { AgentConfig, Check, Criterion, Evidence, JudgeConfig, Task } from "./types";
|
|
11
|
+
|
|
12
|
+
export const sha256 = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex");
|
|
13
|
+
|
|
14
|
+
/** JSON with object keys sorted at every level and undefined values left out. */
|
|
15
|
+
export function canonicalJson(value: unknown): string {
|
|
16
|
+
return JSON.stringify(value, (_k, v) =>
|
|
17
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
18
|
+
? Object.fromEntries(Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => [k, v[k]]))
|
|
19
|
+
: v);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type FileHashes = Record<string, string>;
|
|
23
|
+
|
|
24
|
+
const noDotNames = (name: string) => !name.startsWith(".");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every file under `dir` (a file or a folder), keyed by its path relative to `root` with "/" separators,
|
|
28
|
+
* valued by the SHA-256 of its bytes. Names that start with "." (for example .DS_Store) are left out,
|
|
29
|
+
* unless `keep` says otherwise.
|
|
30
|
+
*/
|
|
31
|
+
function fileHashes(dir: string, root = dir, keep = noDotNames): FileHashes {
|
|
32
|
+
const out: FileHashes = {};
|
|
33
|
+
const walk = (p: string) => {
|
|
34
|
+
if (statSync(p).isFile()) { out[relative(root, p).split(sep).join("/")] = sha256(readFileSync(p)); return; }
|
|
35
|
+
for (const f of readdirSync(p).sort()) if (keep(f)) walk(join(p, f));
|
|
36
|
+
};
|
|
37
|
+
if (existsSync(dir)) walk(dir);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One hash over every file in a folder (path → content hash), dot-folders included, because a Profile
|
|
43
|
+
* snapshot keeps its skills in .claude/, .codex/ and .agents/. Only .DS_Store is left out.
|
|
44
|
+
*/
|
|
45
|
+
export const folderHash = (dir: string) => sha256(canonicalJson(fileHashes(dir, dir, (f) => f !== ".DS_Store")));
|
|
46
|
+
|
|
47
|
+
const clean = (p: string) => p.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
48
|
+
|
|
49
|
+
// ---- Task hash ---------------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** What the agent was given. Stored as a task version's snapshot. */
|
|
52
|
+
export interface TaskContent {
|
|
53
|
+
prompt: string;
|
|
54
|
+
output: string;
|
|
55
|
+
inputs: FileHashes; // path as the agent sees it in the workspace (inputs/brief.md) → hash
|
|
56
|
+
project: FileHashes; // path from the workspace root, where the project is copied → hash
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type TaskFields = Pick<Task, "prompt" | "output" | "inputs" | "project" | "dir">;
|
|
60
|
+
|
|
61
|
+
export function taskIdentity(task: TaskFields): { hash: string; snapshot: TaskContent } {
|
|
62
|
+
const snapshot: TaskContent = {
|
|
63
|
+
prompt: task.prompt,
|
|
64
|
+
output: task.output,
|
|
65
|
+
inputs: fileHashes(join(task.dir, task.inputs), task.dir),
|
|
66
|
+
project: task.project ? fileHashes(join(task.dir, task.project)) : {},
|
|
67
|
+
};
|
|
68
|
+
return { hash: sha256(canonicalJson(snapshot)), snapshot };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const taskHash = (task: TaskFields) => taskIdentity(task).hash;
|
|
72
|
+
|
|
73
|
+
// ---- Grading hash ------------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** A criterion as far as it can change a score: no `level`. Defaults are filled in so equal behaviour hashes equal. */
|
|
76
|
+
export interface CriterionContent {
|
|
77
|
+
id: string;
|
|
78
|
+
question: string;
|
|
79
|
+
grader: Criterion["grader"];
|
|
80
|
+
check?: Check;
|
|
81
|
+
evidence: Evidence[]; // sorted; default ["output"]
|
|
82
|
+
isolate: boolean;
|
|
83
|
+
derived?: string; // the derived evidence script, a path under grader/; left out when none, so such a criterion hashes as before
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How an output was graded. Stored as a grading setup's snapshot. */
|
|
87
|
+
export interface GradingContent {
|
|
88
|
+
taskHash: string;
|
|
89
|
+
context?: string;
|
|
90
|
+
gates: CriterionContent[]; // sorted by id
|
|
91
|
+
criteria: CriterionContent[]; // measure criteria, sorted by id
|
|
92
|
+
measures: { id: string; weight: number; criteria: string[] }[]; // sorted by id; no label
|
|
93
|
+
references: FileHashes;
|
|
94
|
+
sources: FileHashes;
|
|
95
|
+
heldOut: string | null;
|
|
96
|
+
graderFiles: FileHashes; // the task's grader/ folder (helper scripts its command checks run), from the task folder
|
|
97
|
+
judge: { harness: string; model: string; effort?: string }; // no id, label or vote count
|
|
98
|
+
/**
|
|
99
|
+
* The engine revision, in one of two forms. `engine`: the parts this task uses. `graderRev`: the form of before
|
|
100
|
+
* the parts, kept while every part the task uses is at its first value (1 without derived evidence, 2 with it),
|
|
101
|
+
* so the gradings stored under it stay current. A snapshot has exactly one of the two.
|
|
102
|
+
*/
|
|
103
|
+
engine?: EngineParts;
|
|
104
|
+
graderRev?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The engine as a grading hash reads it: the hand-raised revisions and the hashes of the judge instruction wording. */
|
|
108
|
+
export interface EngineState { revisions: EngineRevisions; instructions: { shared: string; derived: string } }
|
|
109
|
+
|
|
110
|
+
export function currentEngine(): EngineState {
|
|
111
|
+
const w = instructionWording();
|
|
112
|
+
return { revisions: ENGINE, instructions: { shared: sha256(w.shared), derived: sha256(w.derived) } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The first value of every part (engine.ts). */
|
|
116
|
+
export const FIRST_ENGINE: EngineState = { revisions: FIRST_REVISIONS, instructions: FIRST_INSTRUCTIONS };
|
|
117
|
+
|
|
118
|
+
type Graded = Pick<GradingContent, "gates" | "criteria" | "judge">;
|
|
119
|
+
const usesDerived = (g: Pick<Graded, "gates" | "criteria">) => [...g.gates, ...g.criteria].some((c) => c.derived);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The parts of `engine` that a task with these gates, criteria and judge uses. A check type or judge harness that
|
|
123
|
+
* `engine` has no revision for is an error, unless `stored`: a stored snapshot is read as it is, at revision 1.
|
|
124
|
+
*/
|
|
125
|
+
function engineParts(g: Graded, engine: EngineState, stored = false): EngineParts {
|
|
126
|
+
const all = [...g.gates, ...g.criteria], r = engine.revisions;
|
|
127
|
+
// The output gate that every grading adds is a command check.
|
|
128
|
+
const types = [...new Set(["command", ...all.flatMap((c) => (c.grader === "check" && c.check ? [c.check.type] : []))])].sort();
|
|
129
|
+
const judged = all.some((c) => c.grader === "judge"), derived = usesDerived(g);
|
|
130
|
+
const rev = (of: Record<string, number>, key: string) => {
|
|
131
|
+
if (of[key] === undefined && !stored) throw new Error(`the engine revision has no part for ${JSON.stringify(key)}: add it to ENGINE and FIRST_REVISIONS in src/core/engine.ts`);
|
|
132
|
+
return of[key] ?? 1;
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
checks: Object.fromEntries(types.map((t) => [t, rev(r.checks, t)])),
|
|
136
|
+
scoring: r.scoring,
|
|
137
|
+
...(judged ? { instructions: engine.instructions.shared, judgeCall: rev(r.judgeCall, g.judge.harness), judging: r.judging } : {}),
|
|
138
|
+
...(derived ? { derived: r.derived, derivedInstructions: engine.instructions.derived } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The engine parts a stored snapshot stands for. A snapshot in the `graderRev` form reads as the first value of
|
|
144
|
+
* each part its task uses.
|
|
145
|
+
*/
|
|
146
|
+
export function engineOf(snapshot: Partial<GradingContent>): EngineParts {
|
|
147
|
+
if (snapshot.engine) return snapshot.engine;
|
|
148
|
+
return engineParts({ gates: snapshot.gates ?? [], criteria: snapshot.criteria ?? [], judge: snapshot.judge ?? { harness: "", model: "" } }, FIRST_ENGINE, true);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type GradedTask = TaskFields & Pick<Task, "context" | "gates" | "measures" | "references" | "sources" | "heldOut">;
|
|
152
|
+
|
|
153
|
+
const byId = <T extends { id: string }>(xs: T[]) => [...xs].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
154
|
+
|
|
155
|
+
const criterionContent = (c: Criterion): CriterionContent => ({
|
|
156
|
+
id: c.id, question: c.question, grader: c.grader, check: c.check,
|
|
157
|
+
evidence: [...new Set<Evidence>(c.evidence ?? ["output"])].sort(),
|
|
158
|
+
isolate: c.isolate ?? false,
|
|
159
|
+
...(c.derived ? { derived: c.derived } : {}),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/** `engine` is for tests only; every caller uses the current engine. */
|
|
163
|
+
export function gradingIdentity(task: GradedTask, judge: Pick<JudgeConfig, "harness" | "model" | "effort">, engine: EngineState = currentEngine()): { hash: string; snapshot: GradingContent } {
|
|
164
|
+
const content: Omit<GradingContent, "engine" | "graderRev"> = {
|
|
165
|
+
taskHash: taskHash(task),
|
|
166
|
+
context: task.context,
|
|
167
|
+
gates: byId(task.gates).map(criterionContent),
|
|
168
|
+
criteria: byId(task.measures.flatMap((m) => m.criteria)).map(criterionContent),
|
|
169
|
+
measures: byId(task.measures).map((m) => ({ id: m.id, weight: m.weight ?? 1, criteria: m.criteria.map((c) => c.id).sort() })),
|
|
170
|
+
references: task.references ? fileHashes(join(task.dir, task.references)) : {},
|
|
171
|
+
sources: task.sources ? fileHashes(join(task.dir, task.sources)) : {},
|
|
172
|
+
heldOut: task.heldOut && existsSync(join(task.dir, task.heldOut)) ? sha256(readFileSync(join(task.dir, task.heldOut))) : null,
|
|
173
|
+
graderFiles: fileHashes(join(task.dir, "grader"), task.dir),
|
|
174
|
+
judge: { harness: judge.harness, model: judge.model, effort: judge.effort },
|
|
175
|
+
};
|
|
176
|
+
const parts = engineParts(content, engine);
|
|
177
|
+
const first = canonicalJson(parts) === canonicalJson(engineParts(content, FIRST_ENGINE));
|
|
178
|
+
const snapshot: GradingContent = { ...content, ...(first ? { graderRev: usesDerived(content) ? 2 : 1 } : { engine: parts }) };
|
|
179
|
+
return { hash: sha256(canonicalJson(snapshot)), snapshot };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const gradingHash = (task: GradedTask, judge: Pick<JudgeConfig, "harness" | "model" | "effort">) => gradingIdentity(task, judge).hash;
|
|
183
|
+
|
|
184
|
+
// ---- Config key --------------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A config's identity, from its fields and not its file name, so renaming configs/x.yaml keeps its history.
|
|
188
|
+
* `harness|model|effort|profile|provider` with an empty slot for an unset field; a human config is `human:<id>`.
|
|
189
|
+
*/
|
|
190
|
+
export function configKey(c: Pick<AgentConfig, "id" | "harness" | "model" | "effort" | "profile" | "provider">): string {
|
|
191
|
+
if (c.harness === "human") return `human:${c.id}`;
|
|
192
|
+
return [c.harness, c.model, c.effort, c.profile, c.provider].map((v) => v ?? "").join("|");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- Engine pin --------------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
const CHECKS = "the revision of each check type the change affects (ENGINE.checks)";
|
|
198
|
+
const CALLS = "the judge call revision of each harness the change affects (ENGINE.judgeCall)";
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The files whose code can move a score, each with the revision to raise when its code outside any marked region
|
|
202
|
+
* changes (null: that code is not pinned). tests/identity.test.ts pins their hashes.
|
|
203
|
+
*/
|
|
204
|
+
export const ENGINE_FILES: Record<string, string | null> = {
|
|
205
|
+
"src/adapters/index.ts": null,
|
|
206
|
+
"src/core/checks.ts": CHECKS,
|
|
207
|
+
"src/core/derived.ts": "ENGINE.derived",
|
|
208
|
+
"src/core/grade.ts": "ENGINE.judging (or ENGINE.derived or ENGINE.checks.command, when the change is to how a derived evidence script or the output gate runs)",
|
|
209
|
+
"src/core/judge-schema.ts": CALLS,
|
|
210
|
+
"src/core/judge.ts": CALLS,
|
|
211
|
+
"src/core/score.ts": "ENGINE.scoring",
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Marked regions: the lines between `// engine:<region>:begin` and `// engine:<region>:end` belong to one part.
|
|
216
|
+
* A region can be marked more than once in a file. Each has the revision to raise when its code changes.
|
|
217
|
+
*/
|
|
218
|
+
export const ENGINE_REGIONS: Record<string, string> = {
|
|
219
|
+
"check.regex": "ENGINE.checks.regex",
|
|
220
|
+
"check.words": "ENGINE.checks.words",
|
|
221
|
+
"check.frontmatter": "ENGINE.checks.frontmatter",
|
|
222
|
+
"check.command": "ENGINE.checks.command",
|
|
223
|
+
"judgeCall": CALLS,
|
|
224
|
+
"judgeCall.claude": "ENGINE.judgeCall.claude",
|
|
225
|
+
"judgeCall.codex": "ENGINE.judgeCall.codex",
|
|
226
|
+
"judgeCall.agy": "ENGINE.judgeCall.agy",
|
|
227
|
+
"judging": "ENGINE.judging",
|
|
228
|
+
"instructions": "nothing: the grading hash holds a hash of the instructions, with where the context, assignment, evidence and criteria go",
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const MARKER = /^[ \t]*\/\/ engine:([A-Za-z.]+):(begin|end)\b.*$/;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* One hash per pinned piece of engine code: `<file>#<region>` for a marked region and `<file>` for the rest of a
|
|
235
|
+
* file. `edit` lets a test change a file's text in memory.
|
|
236
|
+
*/
|
|
237
|
+
export function engineSourceHashes(edit: (path: string, text: string) => string = (_p, t) => t): Record<string, string> {
|
|
238
|
+
const out: Record<string, string> = {};
|
|
239
|
+
for (const [file, rest] of Object.entries(ENGINE_FILES)) {
|
|
240
|
+
const regions = new Map<string, string[]>(), outside: string[] = [];
|
|
241
|
+
let open: string | null = null;
|
|
242
|
+
for (const line of edit(file, readFileSync(join(ENGINE_ROOT, file), "utf8")).split("\n")) {
|
|
243
|
+
const m = line.match(MARKER);
|
|
244
|
+
if (!m) { (open ? regions.get(open)! : outside).push(line); continue; }
|
|
245
|
+
const [, region, edge] = m as unknown as [string, string, string];
|
|
246
|
+
if (!(region in ENGINE_REGIONS)) throw new Error(`${file}: unknown engine region ${region}`);
|
|
247
|
+
if (edge === "begin" ? open !== null : open !== region) throw new Error(`${file}: the engine:${region}:${edge} line does not match the one before it`);
|
|
248
|
+
open = edge === "begin" ? region : null;
|
|
249
|
+
if (open && !regions.has(open)) regions.set(open, []);
|
|
250
|
+
}
|
|
251
|
+
if (open) throw new Error(`${file}: the engine:${open}:end line is missing`);
|
|
252
|
+
if (rest === null && !regions.size) throw new Error(`${file}: it has no engine region, so nothing of it is pinned`);
|
|
253
|
+
for (const [region, lines] of regions) out[`${file}#${region}`] = sha256(lines.join("\n"));
|
|
254
|
+
if (rest !== null) out[file] = sha256(outside.join("\n"));
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** What to raise when the pinned piece `key` (of engineSourceHashes) changed in a way that can move a score. */
|
|
260
|
+
export function raiseFor(key: string): string {
|
|
261
|
+
const [file, region] = key.split("#") as [string, string?];
|
|
262
|
+
return (region ? ENGINE_REGIONS[region] : ENGINE_FILES[file]) ?? "(unknown)";
|
|
263
|
+
}
|