static-fire 0.0.0-stage → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +230 -2
- package/bin/static-fire.js +46 -0
- package/package.json +29 -4
- package/pricing.yaml +20 -0
- package/src/adapters/index.ts +153 -0
- package/src/api.ts +218 -0
- package/src/cli.ts +297 -0
- package/src/core/apkit-install.ts +47 -0
- package/src/core/authored.ts +47 -0
- package/src/core/batch-state.ts +25 -0
- package/src/core/batch.ts +435 -0
- package/src/core/batches.ts +221 -0
- package/src/core/checks.ts +63 -0
- package/src/core/commands.ts +42 -0
- package/src/core/compare.ts +203 -0
- package/src/core/config.ts +25 -0
- package/src/core/copying.ts +18 -0
- package/src/core/db.ts +572 -0
- package/src/core/derived.ts +74 -0
- package/src/core/engine.ts +85 -0
- package/src/core/facets.ts +128 -0
- package/src/core/grade.ts +197 -0
- package/src/core/grading-versions.ts +281 -0
- package/src/core/identity.ts +263 -0
- package/src/core/init.ts +231 -0
- package/src/core/judge-schema.ts +37 -0
- package/src/core/judge.ts +166 -0
- package/src/core/label.ts +43 -0
- package/src/core/lock.ts +40 -0
- package/src/core/migrate.ts +288 -0
- package/src/core/new-task.ts +85 -0
- package/src/core/outside-tools.ts +129 -0
- package/src/core/profiles.ts +42 -0
- package/src/core/project.ts +54 -0
- package/src/core/regrade.ts +233 -0
- package/src/core/score.ts +148 -0
- package/src/core/settings.ts +44 -0
- package/src/core/task-file.ts +95 -0
- package/src/core/trial-detail.ts +73 -0
- package/src/core/types.ts +195 -0
- package/src/core/view-query.ts +85 -0
- package/src/core/view.ts +498 -0
- package/src/guide.md +547 -0
- package/src/server.ts +24 -0
- package/web/api.ts +6 -0
- package/web/app.tsx +1019 -0
- package/web/brand/offset-tokens.css +126 -0
- package/web/charts.tsx +190 -0
- package/web/compare.tsx +317 -0
- package/web/encoding.tsx +55 -0
- package/web/filter-menu.ts +164 -0
- package/web/index.html +17 -0
- package/web/pages.ts +16 -0
- package/web/run-status.ts +51 -0
- package/web/styles.css +333 -0
- package/web/suite-notes.ts +20 -0
- package/web/wording.ts +38 -0
|
@@ -0,0 +1,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,128 @@
|
|
|
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 Workflow 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, filters: ViewFilters = {}, catalog: Catalog = projectCatalog()): Facets {
|
|
67
|
+
const base = buildView(store, filters, catalog); // validates the filters and resolves scope, judge and grading
|
|
68
|
+
const { taskIds } = base.filters;
|
|
69
|
+
const picked = base.filters.grading ? store.getGradingSetup(base.filters.grading) : null;
|
|
70
|
+
const pickedTasks = picked ? store.gradingSetupTaskIds(picked.hash) : [];
|
|
71
|
+
const all = store.listTrials().filter((r) => ENDED.has(r.status));
|
|
72
|
+
const byId = new Map(all.map((r) => [r.id, r]));
|
|
73
|
+
const inScope = all.filter((r) => taskIds.includes(r.taskId));
|
|
74
|
+
const listed = (f: ViewFilters): TrialRecord[] => buildView(store, f, catalog).trials.map((t) => byId.get(t.id)!);
|
|
75
|
+
|
|
76
|
+
// Scope: one view over every suite; the picked grading setup stays only when its task is in that scope.
|
|
77
|
+
const suiteTasks = new Set(catalog.suites.flatMap((s) => s.tasks));
|
|
78
|
+
const index = listed({ ...without(filters, "suites", "tasks", "grading"), ...(picked && pickedTasks.some((t) => suiteTasks.has(t)) ? { grading: picked.hash } : {}) });
|
|
79
|
+
const perTask = tally(index.map((r) => r.taskId));
|
|
80
|
+
const stored = new Set(all.map((r) => r.taskId));
|
|
81
|
+
const scope = {
|
|
82
|
+
index: index.length,
|
|
83
|
+
suites: catalog.suites.filter((s) => s.tasks.some((t) => stored.has(t)))
|
|
84
|
+
.map((s) => ({ value: s.id, count: s.tasks.reduce((n, t) => n + (perTask.get(t) ?? 0), 0) })),
|
|
85
|
+
tasks: [...suiteTasks].filter((t) => stored.has(t)).sort().map((t) => ({ value: t, count: perTask.get(t) ?? 0 })),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// A grading setup is offered for a task only when a stored trial of that task carries a grading by it.
|
|
89
|
+
const setupsOf = (taskId: string) => store.listGradingSetups(taskId)
|
|
90
|
+
.filter((s: GradingSetupRecord) => all.some((r) => r.taskId === taskId && store.listGradings(r.id, s.hash).length > 0));
|
|
91
|
+
|
|
92
|
+
// Judge: the judges with a graded grading setup for a task in scope. A picked setup sets the judge, so another
|
|
93
|
+
// judge is counted over its own view without that setup.
|
|
94
|
+
const setups = taskIds.flatMap(setupsOf);
|
|
95
|
+
const judgeFacet = catalog.judges
|
|
96
|
+
.filter((j) => setups.some((s) => isSetupJudge(j, s)))
|
|
97
|
+
.map((j) => ({ value: j.id, label: j.label, count: listed({ ...(picked && !isSetupJudge(j, picked) ? without(filters, "grading") : filters), judge: j.id }).length }));
|
|
98
|
+
|
|
99
|
+
// Grading setup: per task in scope, each stored setup counted over that task alone, with its own judge.
|
|
100
|
+
const rest = without(filters, "suites", "tasks", "grading", "judge");
|
|
101
|
+
const grading = taskIds.map((taskId) => {
|
|
102
|
+
const task = catalog.loadTask(taskId);
|
|
103
|
+
const now = new Map(catalog.judges.map((j) => [j.id, currentGradingHash(task, j)]));
|
|
104
|
+
const names = versionNames(store, taskId);
|
|
105
|
+
return {
|
|
106
|
+
taskId,
|
|
107
|
+
setups: setupsOf(taskId).map((s): GradingOption => {
|
|
108
|
+
const judgeId = catalog.judges.find((j) => isSetupJudge(j, s))?.id ?? null;
|
|
109
|
+
return {
|
|
110
|
+
...names.get(s.hash)!,
|
|
111
|
+
hash: s.hash, taskHash: s.taskHash, label: store.getTaskVersion(s.taskHash)?.label ?? null, judgeId, judgeLabel: s.judgeLabel,
|
|
112
|
+
firstSeen: s.firstSeen, current: !!judgeId && s.hash === now.get(judgeId),
|
|
113
|
+
count: listed({ ...rest, tasks: [taskId], grading: s.hash }).length,
|
|
114
|
+
};
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Config facets: values from the stored trials of the tasks in scope, counts without the facet's own filter.
|
|
120
|
+
const config = Object.fromEntries(CONFIG_FACETS.map((k: ConfigFacet) => {
|
|
121
|
+
const counts = tally(listed(without(filters, k)).map((r) => facetValue(r, k)));
|
|
122
|
+
const values = [...new Set(inScope.map((r) => facetValue(r, k)))].sort();
|
|
123
|
+
return [k, values.map((value) => ({ value, count: counts.get(value) ?? 0 }))];
|
|
124
|
+
})) as Record<ConfigFacet, FacetValue[]>;
|
|
125
|
+
|
|
126
|
+
const starts = listed(without(filters, "since", "until")).map((r) => r.startedAt).filter((x): x is string => !!x).sort();
|
|
127
|
+
return { scope, judge: judgeFacet, grading, ...config, dates: { first: starts[0] ?? null, last: starts.at(-1) ?? null } };
|
|
128
|
+
}
|
|
@@ -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
|
+
}
|