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,74 @@
1
+ // Derived evidence: a judge criterion's script (`derived: <path under grader/>`) makes an image from the
2
+ // output, and the judge sees that image. The contract for a script is in the README ("Derived evidence").
3
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { extname, join } from "node:path";
5
+
6
+ /**
7
+ * How a script is started, by its extension. `.ts` and `.js` use the Bun that runs the engine, not a `bun` on the
8
+ * PATH, so they run when the package brought Bun and the machine has none. `.py` uses `uv run --script`, so a
9
+ * script can declare its packages inline (PEP 723).
10
+ */
11
+ export const DERIVED_RUNNERS: Record<string, (script: string) => string[]> = {
12
+ ".ts": (s) => [process.execPath, s],
13
+ ".js": (s) => [process.execPath, s],
14
+ ".py": (s) => ["uv", "run", "--quiet", "--script", s],
15
+ ".sh": (s) => ["sh", s],
16
+ };
17
+
18
+ export const derivedExt = (path: string) => {
19
+ const e = extname(path).toLowerCase();
20
+ return e in DERIVED_RUNNERS ? e : null;
21
+ };
22
+
23
+ /** The command for a script (a path under grader/), with the output file and the image path as its two arguments. */
24
+ export function derivedCommand(taskDir: string, script: string, outputPath: string, imagePath: string): string[] {
25
+ const ext = derivedExt(script);
26
+ if (!ext) throw new Error(`no runner for ${script}`);
27
+ return [...DERIVED_RUNNERS[ext]!(join(taskDir, "grader", script)), outputPath, imagePath];
28
+ }
29
+
30
+ /**
31
+ * A name as one safe file or folder name, one to one: a character outside `A-Z a-z 0-9 + = . _ -` becomes
32
+ * `%` and its UTF-8 bytes in hex (so `/` is `%2F` and `%` is `%25`). Two different names never give the same result.
33
+ */
34
+ export const safeName = (name: string) => name.replace(/[^A-Za-z0-9+=._-]/gu, (ch) => [...Buffer.from(ch)].map((b) => `%${b.toString(16).toUpperCase().padStart(2, "0")}`).join(""));
35
+
36
+ /**
37
+ * Where a grading keeps the image a script made: `<grading dir>/_derived/<safeName(script)>.png` (for example
38
+ * `sub/overlay.ts` gives `sub%2Foverlay.ts.png`), with the script's output next to it as `.log`. The folder name
39
+ * starts with "_", which no judge batch folder does, so the two never meet.
40
+ */
41
+ export const derivedImagePath = (gradingDir: string, script: string) => join(gradingDir, "_derived", `${safeName(script)}.png`);
42
+
43
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
44
+
45
+ export interface DerivedContext { taskDir: string; outputPath: string; workspace: string }
46
+
47
+ /**
48
+ * Runs one script in the workspace, as command checks run (same environment, plus DERIVED, the image path).
49
+ * Resolves to the image path, or to an error when the script fails, times out, or writes no PNG.
50
+ */
51
+ export async function runDerived(script: string, ctx: DerivedContext, gradingDir: string, timeoutSec = 60): Promise<{ image: string } | { error: string }> {
52
+ const image = derivedImagePath(gradingDir, script);
53
+ mkdirSync(join(gradingDir, "_derived"), { recursive: true });
54
+ const fail = (why: string) => ({ error: `derived evidence grader/${script}: ${why}` });
55
+ try {
56
+ const proc = Bun.spawn(derivedCommand(ctx.taskDir, script, ctx.outputPath, image), {
57
+ cwd: ctx.workspace, stdin: "ignore", stdout: "pipe", stderr: "pipe",
58
+ env: { ...process.env, OUTPUT: ctx.outputPath, WORKSPACE: ctx.workspace, TASK_DIR: ctx.taskDir, DERIVED: image },
59
+ });
60
+ let timedOut = false;
61
+ const timer = setTimeout(() => { timedOut = true; proc.kill(); }, timeoutSec * 1000);
62
+ const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
63
+ clearTimeout(timer);
64
+ writeFileSync(image.replace(/\.png$/, ".log"), `${out}${err ? `\n--- stderr ---\n${err}` : ""}`);
65
+ const said = (err.trim() || out.trim()).split("\n").slice(-1)[0]?.slice(0, 300);
66
+ if (timedOut) return fail(`timed out after ${timeoutSec}s`);
67
+ if (code !== 0) return fail(`exit ${code}${said ? `: ${said}` : ""}`);
68
+ if (!existsSync(image) || !statSync(image).size) return fail("wrote no image");
69
+ if (!readFileSync(image).subarray(0, 8).equals(PNG)) return fail("the image is not a PNG");
70
+ return { image };
71
+ } catch (e) {
72
+ return fail(e instanceof Error ? e.message : String(e));
73
+ }
74
+ }
@@ -0,0 +1,85 @@
1
+ // The engine revision: the parts of the grading code that can move a score. A grading hash holds a part only
2
+ // when the task uses it, so a change to one part gives a new grading version only to the tasks that use it
3
+ // (ADR-0007). Each revision here is raised by hand; the pin in tests/identity.test.ts fails when the code of a
4
+ // part changes and names the revision to raise. The judge instructions are not here: the code hashes their wording.
5
+ // The dashboard imports this file, so it must stay free of Node code.
6
+
7
+ import { HARNESS_NAME } from "./label";
8
+ import type { Check, JudgeConfig } from "./types";
9
+
10
+ type CheckType = Check["type"];
11
+ type JudgeHarness = JudgeConfig["harness"];
12
+
13
+ export interface EngineRevisions {
14
+ /** How each check type decides pass or fail. In a grading hash when a gate or criterion uses the type; `command` always, because the output gate is one. */
15
+ checks: Record<CheckType, number>;
16
+ /** How a derived evidence script runs and how its image reaches the judge. In a grading hash when a criterion names a script. */
17
+ derived: number;
18
+ /** How a judge of each harness is called: the command, its flags, the retry, the answer schema. In a grading hash with a judge criterion, for its own judge's harness only. */
19
+ judgeCall: Record<JudgeHarness, number>;
20
+ /** How evidence is shown to a judge, how criteria are grouped into calls, how votes are counted. In a grading hash with a judge criterion. */
21
+ judging: number;
22
+ /** How verdicts become a score. In every grading hash. */
23
+ scoring: number;
24
+ }
25
+
26
+ export const ENGINE: EngineRevisions = {
27
+ checks: { regex: 1, words: 1, frontmatter: 1, command: 1 },
28
+ derived: 1,
29
+ judgeCall: { claude: 1, codex: 1, agy: 1 },
30
+ judging: 1,
31
+ scoring: 1,
32
+ };
33
+
34
+ /** The engine parts one task uses, as its grading hash holds them. A part the task does not use is left out. */
35
+ export interface EngineParts {
36
+ instructions?: string; // hash of the judge instruction wording every judged task gets
37
+ checks: Record<string, number>; // check type → revision
38
+ derived?: number;
39
+ derivedInstructions?: string; // hash of the instructions as a batch with derived evidence gets them
40
+ judgeCall?: number; // of the judge's own harness
41
+ judging?: number;
42
+ scoring: number;
43
+ }
44
+
45
+ /**
46
+ * The first value of every part: all revisions at 1, and the instruction wording of 0.37.0. A task whose parts are
47
+ * all at these values keeps the snapshot form of before the parts (`graderRev`), so its stored gradings stay current.
48
+ * Never change these: a part that changes gets a new current value, not a new first value.
49
+ */
50
+ export const FIRST_REVISIONS: EngineRevisions = {
51
+ checks: { regex: 1, words: 1, frontmatter: 1, command: 1 },
52
+ derived: 1,
53
+ judgeCall: { claude: 1, codex: 1, agy: 1 },
54
+ judging: 1,
55
+ scoring: 1,
56
+ };
57
+ export const FIRST_INSTRUCTIONS = {
58
+ shared: "804b007a7160526b61fa7e56504aa9162344c48a964c520ec1a909398aa76145",
59
+ derived: "2697afc24b3201dd1b18a0ee5f1866bb2435cebce0328f8e2055dec79a0c61f8",
60
+ };
61
+
62
+ /**
63
+ * One changed part of the engine revision. `name` is the check type of a `check` part, the judge harness of a
64
+ * `judgeCall` part, and "derived" for the instructions as a batch with derived evidence gets them. `revision` is two snapshots of before the parts whose one number differs. The values are
65
+ * revisions, or hashes of the wording for `instructions`.
66
+ */
67
+ export interface EngineChange {
68
+ part: "instructions" | "check" | "derived" | "judgeCall" | "judging" | "scoring" | "revision";
69
+ name: string | null;
70
+ a: number | string;
71
+ b: number | string;
72
+ }
73
+
74
+ /** One changed engine part in short plain words, for a name and for the Compare page. */
75
+ export function engineChangeName(c: EngineChange): string {
76
+ switch (c.part) {
77
+ case "instructions": return "judge instructions changed";
78
+ case "check": return `check changed: ${c.name}`;
79
+ case "derived": return "derived evidence changed";
80
+ case "judgeCall": return `judge call changed: ${HARNESS_NAME[c.name as keyof typeof HARNESS_NAME] ?? c.name}`;
81
+ case "judging": return "judging changed";
82
+ case "scoring": return "scoring changed";
83
+ case "revision": return `engine revision ${c.a} to ${c.b}`;
84
+ }
85
+ }
@@ -0,0 +1,143 @@
1
+ // The values each filter of the view can take, with trial counts: what GET /api/facets returns and the
2
+ // dashboard's filter bar lists. Every count comes from buildView, so a count is exactly the number of trials
3
+ // the view would list (failed and invalid included) if that value were chosen. A count keeps every other
4
+ // filter and drops the facet's own one (the usual faceted-search rule), so the other values of a facet show
5
+ // what choosing them would give. Values are only those found in stored records: an empty store gives empty lists.
6
+ import type { GradingSetupRecord, Store, TrialRecord } from "./db";
7
+ import { versionNames } from "./grading-versions";
8
+ import type { VersionName } from "./grading-versions";
9
+ import { gradingHash as currentGradingHash } from "./identity";
10
+ import { buildView, CONFIG_FACETS, facetValue, isSetupJudge, projectCatalog } from "./view";
11
+ import type { Catalog, ConfigFacet, ViewFilters } from "./view";
12
+
13
+ /** One value of a filter and the trials the view would list with it. */
14
+ export interface FacetValue { value: string; count: number; /** A readable name, where the value is an id (judges). */ label?: string }
15
+
16
+ /**
17
+ * One grading version of a task, as the grading version filter lists it: its name (built from what changed since
18
+ * the version before it for the same task and judge), with that version and the difference to it.
19
+ */
20
+ export interface GradingOption extends VersionName {
21
+ /** Full grading hash: the value of the `grading` filter. */
22
+ hash: string;
23
+ /** The task hash it grades: a setup of an earlier task version reaches that version. */
24
+ taskHash: string;
25
+ /** The task file's `version` stored with that task hash, or null if the store has none or the task has no version. */
26
+ label: number | null;
27
+ /** The judges.yaml id of the judge that grades under this version, or null when no judge there matches it. */
28
+ judgeId: string | null;
29
+ judgeLabel: string;
30
+ /** When the version was first recorded (ISO time). */
31
+ firstSeen: string;
32
+ /** True for today's grading hash of the task for this version's own judge, whichever judge is in view. */
33
+ current: boolean;
34
+ /** Trials of the task the view would list with this version chosen. */
35
+ count: number;
36
+ }
37
+
38
+ export interface Facets {
39
+ /** The Agent Performance Index and every suite and task with a stored trial. A suite's count is the sum over its tasks. */
40
+ scope: { index: number; suites: FacetValue[]; tasks: FacetValue[] };
41
+ /** The judges in judges.yaml that have graded a task in scope. */
42
+ judge: FacetValue[];
43
+ /** Per task in scope, every grading version that graded a stored trial of that task, oldest first. */
44
+ grading: { taskId: string; setups: GradingOption[] }[];
45
+ harness: FacetValue[];
46
+ model: FacetValue[];
47
+ effort: FacetValue[];
48
+ profile: FacetValue[];
49
+ /** First and last start time of the trials the view would list without a date range. */
50
+ dates: { first: string | null; last: string | null };
51
+ }
52
+
53
+ const ENDED = new Set(["done", "failed", "invalid"]);
54
+ const without = (f: ViewFilters, ...keys: (keyof ViewFilters)[]): ViewFilters => {
55
+ const g = { ...f };
56
+ for (const k of keys) delete g[k];
57
+ return g;
58
+ };
59
+ const tally = (values: string[]): Map<string, number> => {
60
+ const m = new Map<string, number>();
61
+ for (const v of values) m.set(v, (m.get(v) ?? 0) + 1);
62
+ return m;
63
+ };
64
+
65
+ /** The facets for `filters`. Throws on the same bad filters as buildView. */
66
+ export function buildFacets(store: Store, given: ViewFilters = {}, catalog: Catalog = projectCatalog()): Facets {
67
+ const base = buildView(store, given, catalog); // validates the filters and resolves scope, judge and grading
68
+ // The counts are over the suites the view resolved: without a pick, the default set moves with the filters,
69
+ // and a count without one filter must not count other suites than the view it is offered in.
70
+ const filters: ViewFilters = given.tasks?.length || !base.filters.suiteIds.length ? given : { ...given, suites: base.filters.suiteIds };
71
+ const { taskIds } = base.filters;
72
+ const picked = base.filters.grading ? store.getGradingSetup(base.filters.grading) : null;
73
+ const pickedTasks = picked ? store.gradingSetupTaskIds(picked.hash) : [];
74
+ const all = store.listTrials().filter((r) => ENDED.has(r.status));
75
+ const byId = new Map(all.map((r) => [r.id, r]));
76
+ const inScope = all.filter((r) => taskIds.includes(r.taskId));
77
+ const listed = (f: ViewFilters): TrialRecord[] => buildView(store, f, catalog).trials.map((t) => byId.get(t.id)!);
78
+
79
+ // Scope. A count is the number of trials the view would list with that value chosen (#80). A task: the view of
80
+ // that task, with the picked grading setup only when it is of that task (the page drops it for another task), so
81
+ // a task outside the default set of suites counts its own trials. A suite: the sum over its tasks. The index:
82
+ // the default set, or, with a picked grading setup, the suite of its task (the page picks it, web/filter-menu.ts
83
+ // withGradingPick).
84
+ const unscoped = without(filters, "suites", "tasks", "grading");
85
+ const stored = new Set(all.map((r) => r.taskId));
86
+ const loads = (t: string) => { try { catalog.loadTask(t); return true; } catch { return false; } };
87
+ const storedTasks = [...stored].filter(loads).sort();
88
+ const others = storedTasks.filter((t) => !pickedTasks.includes(t));
89
+ const ofTasks = [
90
+ ...(others.length ? listed({ ...unscoped, tasks: others }) : []),
91
+ ...(picked ? listed({ ...unscoped, tasks: pickedTasks, grading: picked.hash }) : []),
92
+ ];
93
+ const perTask = tally(ofTasks.map((r) => r.taskId));
94
+ const home = picked ? catalog.suites.find((s) => s.tasks.some((t) => pickedTasks.includes(t))) : undefined;
95
+ const index = home ? listed({ ...unscoped, suites: [home.id], grading: picked!.hash }) : listed(unscoped);
96
+ const scope = {
97
+ index: index.length,
98
+ suites: catalog.suites.filter((s) => s.tasks.some((t) => stored.has(t)))
99
+ .map((s) => ({ value: s.id, count: s.tasks.reduce((n, t) => n + (perTask.get(t) ?? 0), 0) })),
100
+ tasks: storedTasks.map((t) => ({ value: t, count: perTask.get(t) ?? 0 })),
101
+ };
102
+
103
+ // A grading setup is offered for a task only when a stored trial of that task carries a grading by it.
104
+ const setupsOf = (taskId: string) => store.listGradingSetups(taskId)
105
+ .filter((s: GradingSetupRecord) => all.some((r) => r.taskId === taskId && store.listGradings(r.id, s.hash).length > 0));
106
+
107
+ // Judge: the judges with a graded grading setup for a task in scope. A picked setup sets the judge, so another
108
+ // judge is counted over its own view without that setup.
109
+ const setups = taskIds.flatMap(setupsOf);
110
+ const judgeFacet = catalog.judges
111
+ .filter((j) => setups.some((s) => isSetupJudge(j, s)))
112
+ .map((j) => ({ value: j.id, label: j.label, count: listed({ ...(picked && !isSetupJudge(j, picked) ? without(filters, "grading") : filters), judge: j.id }).length }));
113
+
114
+ // Grading setup: per task in scope, each stored setup counted over that task alone, with its own judge.
115
+ const rest = without(filters, "suites", "tasks", "grading", "judge");
116
+ const grading = taskIds.map((taskId) => {
117
+ const task = catalog.loadTask(taskId);
118
+ const now = new Map(catalog.judges.map((j) => [j.id, currentGradingHash(task, j)]));
119
+ const names = versionNames(store, taskId);
120
+ return {
121
+ taskId,
122
+ setups: setupsOf(taskId).map((s): GradingOption => {
123
+ const judgeId = catalog.judges.find((j) => isSetupJudge(j, s))?.id ?? null;
124
+ return {
125
+ ...names.get(s.hash)!,
126
+ hash: s.hash, taskHash: s.taskHash, label: store.getTaskVersion(s.taskHash)?.label ?? null, judgeId, judgeLabel: s.judgeLabel,
127
+ firstSeen: s.firstSeen, current: !!judgeId && s.hash === now.get(judgeId),
128
+ count: listed({ ...rest, tasks: [taskId], grading: s.hash }).length,
129
+ };
130
+ }),
131
+ };
132
+ });
133
+
134
+ // Config facets: values from the stored trials of the tasks in scope, counts without the facet's own filter.
135
+ const config = Object.fromEntries(CONFIG_FACETS.map((k: ConfigFacet) => {
136
+ const counts = tally(listed(without(filters, k)).map((r) => facetValue(r, k)));
137
+ const values = [...new Set(inScope.map((r) => facetValue(r, k)))].sort();
138
+ return [k, values.map((value) => ({ value, count: counts.get(value) ?? 0 }))];
139
+ })) as Record<ConfigFacet, FacetValue[]>;
140
+
141
+ const starts = listed(without(filters, "since", "until")).map((r) => r.startedAt).filter((x): x is string => !!x).sort();
142
+ return { scope, judge: judgeFacet, grading, ...config, dates: { first: starts[0] ?? null, last: starts.at(-1) ?? null } };
143
+ }
@@ -0,0 +1,197 @@
1
+ // Grade one output against a Task: gates + checks + batched judge votes → Grades (ADR-0001).
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { basename, dirname, extname, join, relative } from "node:path";
4
+ import { runCheck, type CheckContext } from "./checks";
5
+ import { runDerived, safeName } from "./derived";
6
+ import { attachesImages, isImage, judgeVote, type JudgeFiles } from "./judge";
7
+ import { firstRoundVotes, needsMoreVotes, scoreTrial, verdictFromVotes } from "./score";
8
+ import type { Criterion, Evidence, JudgeConfig, Task, Verdict, Vote } from "./types";
9
+
10
+ export const OUTPUT_GATE: Criterion = { id: "output-exists", question: "Did the agent write the output file?", level: "floor", grader: "check", check: { type: "command", run: 'test -s "$OUTPUT"' } };
11
+
12
+ const TEXT = new Set([".md", ".mdx", ".txt", ".yaml", ".yml", ".json", ".html", ".css", ".ts", ".tsx", ".js", ".py", ".csv", ".svg"]);
13
+ const files = (dir: string): string[] =>
14
+ !existsSync(dir) ? [] : statSync(dir).isFile() ? [dir]
15
+ : readdirSync(dir).flatMap((f) => files(join(dir, f))).filter((f) => !basename(f).startsWith("."));
16
+
17
+
18
+ /**
19
+ * Text files are quoted inline. Images are attached to the judge's message when its Host can take them
20
+ * (their number in `files.images` is how the prompt names them), so seeing them is not left to the judge.
21
+ * Other files, and images for a Host that cannot attach them, are copied to `files.readDir` and named by
22
+ * path; the judge must open each one (judge.ts checks its log).
23
+ */
24
+ export function evidenceBlock(tag: string, paths: string[], root: string, files: JudgeFiles) {
25
+ const parts: string[] = [];
26
+ for (const p of paths) {
27
+ const rel = relative(root, p) || basename(p);
28
+ if (TEXT.has(extname(p).toLowerCase())) parts.push(`<file path="${rel}">\n${readFileSync(p, "utf8")}\n</file>`);
29
+ else if (files.images && isImage(p)) {
30
+ files.images.push({ label: rel, path: p });
31
+ parts.push(`<file path="${rel}">Image ${files.images.length}, attached to this message.</file>`);
32
+ } else {
33
+ // The evidence kind is part of the name, so project/x.png and sources/x.png do not overwrite each other.
34
+ const local = join(files.readDir, `${tag}__${rel.replace(/[\\/]/g, "__")}`);
35
+ mkdirSync(files.readDir, { recursive: true });
36
+ copyFileSync(p, local);
37
+ parts.push(`<file path="${rel}">Binary file. Read it with the Read tool at: ${local}</file>`);
38
+ files.toRead.push(local);
39
+ }
40
+ }
41
+ return `<${tag}>\n${parts.join("\n\n")}\n</${tag}>`;
42
+ }
43
+
44
+ // engine:instructions:begin
45
+ /** The instruction line about derived evidence. Its wording is hashed apart from the rest (instructionWording). */
46
+ const derivedRule = (script: string) => `- The image in <derived> was made from the work by the grader script grader/${script}. It shows the work as a grader needs to see it, for example a smaller copy or a copy with marks drawn on it. It is part of the evidence.\n`;
47
+
48
+ /** `derived` is the block of the batch's derived image and the script that made it, when it has one. */
49
+ function judgePrompt(task: Pick<Task, "context" | "prompt">, criteria: Criterion[], evidence: Record<Evidence, string>, files: JudgeFiles, derived?: { block: string; script: string }) {
50
+ const attached = !!files.images?.length, needsRead = files.toRead.length > 0;
51
+ return `You are grading one piece of work against yes/no criteria.
52
+ ${task.context ? `\n${task.context.trim()}\n` : ""}
53
+ Rules:
54
+ - Judge each criterion on its own, as if it were the only question.
55
+ - pass = true only when the work clearly meets the criterion. When it is unclear or only partly met, pass = false.
56
+ ${derived ? derivedRule(derived.script) : ""}${attached ? "- Files below marked \"Image N\" are attached to this message as images, in that order. Look at every one before you answer: they are part of the evidence.\n" : ""}${needsRead ? "- Some files below are images or other binary files. Open every one of them with the Read tool before you answer. Never answer that you could not see a file.\n" : ""}- For each criterion, write the reason first: one or two sentences that point to the specific evidence (quote it when you can). Then set pass so that it agrees with the reason.
57
+
58
+ <assignment>
59
+ ${task.prompt.trim()}
60
+ </assignment>
61
+
62
+ ${[evidence.inputs, evidence.project, evidence.sources, evidence.references, evidence.output, derived?.block].filter(Boolean).join("\n\n")}
63
+
64
+ <criteria>
65
+ ${criteria.map((c) => `- id: ${c.id}\n question: ${c.question}`).join("\n")}
66
+ </criteria>
67
+
68
+ Return one verdict for every criterion id above, exactly once each.`;
69
+ }
70
+
71
+ /**
72
+ * The fixed wording of the judge instructions, for the grading hash (identity.ts hashes it, so a change here
73
+ * needs no revision raised by hand). `shared` is what every judged task gets: the instructions with a fixed
74
+ * placeholder for the context, the assignment, each kind of evidence and one criterion, so the hash also covers
75
+ * where each of them goes and how a criterion is written. Both image lines are in it, because the files are not
76
+ * known before an output exists. `derived` is the same text as a batch with derived evidence gets it: with
77
+ * the line about the derived image, and with its block among the evidence. Only a task that uses derived evidence
78
+ * holds its hash, so a change to that line or to where the block goes moves only those tasks.
79
+ */
80
+ export function instructionWording(): { shared: string; derived: string } {
81
+ const evidence: Record<Evidence, string> = { output: "<output>", inputs: "<inputs>", references: "<references>", project: "<project>", sources: "<sources>" };
82
+ const files = (): JudgeFiles => ({ images: [{ label: "", path: "" }], toRead: [""], readDir: "" });
83
+ const render = (derived?: { block: string; script: string }) =>
84
+ judgePrompt({ context: "<context>", prompt: "<assignment>" }, [{ id: "<id>", question: "<question>" } as Criterion], evidence, files(), derived);
85
+ return { shared: render(), derived: render({ block: "<derived>", script: "<script>" }) };
86
+ }
87
+ // engine:instructions:end
88
+
89
+ const shuffle = <T>(xs: T[]) => { const a = [...xs]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
90
+
91
+ export interface GradeArgs { task: Task; outputPath: string; workspace: string; judge: JudgeConfig & { votes: number }; dir: string; batching?: Batching }
92
+
93
+ /**
94
+ * How judge criteria share calls. "evidence" (default): every criterion that needs the same evidence shares
95
+ * one call, across measures, so the call count does not grow with the number of measures. "measure": one
96
+ * call per measure. Either way, judged gates share a call and `isolate: true` criteria get their own.
97
+ * Criteria share a call only when they also name the same derived evidence script (or none).
98
+ */
99
+ export type Batching = "evidence" | "measure";
100
+ const EVIDENCE_ORDER: Evidence[] = ["output", "inputs", "references", "project", "sources"];
101
+
102
+ export function planBatches(task: Pick<Task, "gates" | "measures">, mode: Batching = "evidence") {
103
+ const batches: { key: string; criteria: Criterion[]; derived?: string }[] = [];
104
+ const judged = (cs: Criterion[]) => cs.filter((c) => c.grader === "judge");
105
+ // One batch per derived script among `cs` (none first), its key marked with the script.
106
+ const push = (key: string, cs: Criterion[]) => {
107
+ const scripts = [...new Set(cs.flatMap((c) => (c.derived ? [c.derived] : [])))].sort();
108
+ for (const d of [undefined, ...scripts]) {
109
+ const part = cs.filter((c) => c.derived === d);
110
+ if (part.length) batches.push(d ? { key: [key, `derived=${d}`].filter(Boolean).join("+"), criteria: part, derived: d } : { key, criteria: part });
111
+ }
112
+ };
113
+ push("gates", judged(task.gates));
114
+ const shared = task.measures.map((m) => ({ id: m.id, cs: judged(m.criteria).filter((c) => !c.isolate) }));
115
+ if (mode === "measure") for (const m of shared) push(m.id, m.cs);
116
+ else {
117
+ const groups = new Map<string, Criterion[]>();
118
+ for (const c of shared.flatMap((m) => m.cs)) {
119
+ const kinds = new Set(c.evidence ?? ["output"]);
120
+ const key = EVIDENCE_ORDER.filter((k) => kinds.has(k)).join("+");
121
+ groups.set(key, [...(groups.get(key) ?? []), c]);
122
+ }
123
+ for (const [key, cs] of groups) push(key, cs);
124
+ }
125
+ for (const c of task.measures.flatMap((m) => judged(m.criteria)).filter((c) => c.isolate)) push(c.id, [c]);
126
+ return batches;
127
+ }
128
+
129
+ /** A batch's folder in the grading folder: its key as a safe name, so two keys never share a folder. */
130
+ const batchFolder = (key: string) => safeName(key) || "_";
131
+
132
+ export async function grade({ task, outputPath, workspace, judge, dir, batching = "evidence" }: GradeArgs) {
133
+ const output = existsSync(outputPath) ? readFileSync(outputPath, "utf8") : "";
134
+ const ctx: CheckContext = { output, outputPath, workspace, taskDir: task.dir };
135
+ const verdicts: Record<string, Verdict> = {};
136
+ const all = [OUTPUT_GATE, ...task.gates, ...task.measures.flatMap((m) => m.criteria)];
137
+
138
+ verdicts[OUTPUT_GATE.id] = await runCheck(OUTPUT_GATE.check!, ctx);
139
+ if (verdicts[OUTPUT_GATE.id].score !== 1) {
140
+ // Nothing to grade: everything fails for the same reason, and no judge is paid for.
141
+ for (const c of all.slice(1)) verdicts[c.id] = { score: 0, votes: [{ pass: false, reason: "no output" }] };
142
+ return scoreTrial({ ...task, gates: [OUTPUT_GATE, ...task.gates] }, verdicts, 0);
143
+ }
144
+
145
+ await Promise.all(all.slice(1).filter((c) => c.grader === "check").map(async (c) => { verdicts[c.id] = await runCheck(c.check!, ctx); }));
146
+ for (const c of all) if (c.grader === "human") verdicts[c.id] = { score: null, votes: [], error: "human grading is not available yet" };
147
+
148
+ const batches = planBatches(task, batching);
149
+ // Each derived evidence script runs once per grading; its image stays in <dir>/_derived/.
150
+ const derived = new Map([...new Set(batches.flatMap((b) => (b.derived ? [b.derived] : [])))]
151
+ .map((s) => [s, runDerived(s, { taskDir: task.dir, outputPath, workspace }, dir)] as const));
152
+
153
+ let judgeCost = 0;
154
+ await Promise.all(batches.map(async (b) => {
155
+ const made = b.derived ? await derived.get(b.derived)! : null;
156
+ if (made && "error" in made) {
157
+ // No image, no judge call: an error, never a silent pass or fail.
158
+ for (const c of b.criteria) verdicts[c.id] = { score: null, votes: [], error: made.error };
159
+ return;
160
+ }
161
+ const kinds = new Set(b.criteria.flatMap((c) => c.evidence ?? ["output"]));
162
+ const ev: Record<Evidence, string> = { output: "", inputs: "", references: "", project: "", sources: "" };
163
+ const batchDir = join(dir, batchFolder(b.key));
164
+ const jf: JudgeFiles = { images: attachesImages(judge.harness) ? [] : null, toRead: [], readDir: join(batchDir, "evidence") };
165
+ const put = (kind: Evidence, tag: string, paths: string[], root: string) => {
166
+ if (kinds.has(kind)) ev[kind] = evidenceBlock(tag, paths, root, jf);
167
+ };
168
+ put("output", "work", [outputPath], workspace);
169
+ put("inputs", "inputs", files(join(workspace, task.inputs)), workspace);
170
+ if (task.references) put("references", "references", files(join(task.dir, task.references)), join(task.dir, task.references));
171
+ // What the agent found in the project (for example past issues), and saved copies of the linked sources.
172
+ if (task.project) put("project", "project", files(join(task.dir, task.project)), join(task.dir, task.project));
173
+ if (task.sources) put("sources", "sources", files(join(task.dir, task.sources)), join(task.dir, task.sources));
174
+ const shown = made ? { block: evidenceBlock("derived", [made.image], dirname(made.image), jf), script: b.derived! } : undefined;
175
+
176
+ const ids = b.criteria.map((c) => c.id);
177
+ const votes: Record<string, Vote[]> = Object.fromEntries(ids.map((id) => [id, []]));
178
+ const errors: string[] = [];
179
+ const vote = async (k: number) => {
180
+ const order = shuffle(b.criteria);
181
+ const r = await judgeVote(judge, judgePrompt(task, order, ev, jf, shown), order.map((c) => c.id), join(batchDir, `vote-${k + 1}`), jf);
182
+ judgeCost += r.cost;
183
+ if (r.parsed.ok) for (const id of ids) votes[id].push(r.parsed.verdicts[id]);
184
+ else errors.push(r.parsed.error);
185
+ };
186
+ // Adaptive votes: a unanimous first-round majority already decides the outcome of all planned votes.
187
+ const first = firstRoundVotes(judge.votes);
188
+ await Promise.all(Array.from({ length: first }, (_, k) => vote(k)));
189
+ if (needsMoreVotes(votes, judge.votes)) await Promise.all(Array.from({ length: judge.votes - first }, (_, k) => vote(first + k)));
190
+ // A criterion is decided when a majority of the planned votes came back valid.
191
+ for (const id of ids) {
192
+ verdicts[id] = votes[id].length * 2 > judge.votes ? verdictFromVotes(votes[id]) : { score: null, votes: votes[id], error: errors[0] ?? "too few valid votes" };
193
+ }
194
+ }));
195
+
196
+ return scoreTrial({ ...task, gates: [OUTPUT_GATE, ...task.gates] }, verdicts, judgeCost);
197
+ }