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,63 @@
|
|
|
1
|
+
// Deterministic checks. Each returns a Verdict with one vote (pass/fail) or an error.
|
|
2
|
+
import type { Check, Verdict } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface CheckContext { output: string; outputPath: string; workspace: string; taskDir: string }
|
|
5
|
+
|
|
6
|
+
export function splitFrontmatter(text: string): { fields: Record<string, unknown>; body: string } {
|
|
7
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
8
|
+
if (!m) return { fields: {}, body: text };
|
|
9
|
+
let fields: Record<string, unknown> = {};
|
|
10
|
+
try { fields = (Bun.YAML.parse(m[1]) as Record<string, unknown>) ?? {}; } catch { /* treat as none */ }
|
|
11
|
+
return { fields, body: text.slice(m[0].length).replace(/^\s+/, "") };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const one = (pass: boolean, reason: string): Verdict => ({ score: pass ? 1 : 0, votes: [{ pass, reason }] });
|
|
15
|
+
const err = (error: string): Verdict => ({ score: null, votes: [], error });
|
|
16
|
+
|
|
17
|
+
const within = (n: number, min?: number, max?: number) => (min == null || n >= min) && (max == null || n <= max);
|
|
18
|
+
const bounds = (min?: number, max?: number) => [min != null && `≥ ${min}`, max != null && `≤ ${max}`].filter(Boolean).join(" and ");
|
|
19
|
+
|
|
20
|
+
export async function runCheck(check: Check, ctx: CheckContext): Promise<Verdict> {
|
|
21
|
+
try {
|
|
22
|
+
switch (check.type) {
|
|
23
|
+
// engine:check.regex:begin
|
|
24
|
+
case "regex": {
|
|
25
|
+
const flags = check.flags?.includes("g") ? check.flags : `${check.flags ?? ""}g`;
|
|
26
|
+
const n = [...ctx.output.matchAll(new RegExp(check.pattern, flags))].length;
|
|
27
|
+
const min = check.min ?? (check.max == null ? 1 : undefined);
|
|
28
|
+
return one(within(n, min, check.max), `${n} match${n === 1 ? "" : "es"} of /${check.pattern}/ (needs ${bounds(min, check.max)})`);
|
|
29
|
+
}
|
|
30
|
+
// engine:check.regex:end
|
|
31
|
+
// engine:check.words:begin
|
|
32
|
+
case "words": {
|
|
33
|
+
const n = splitFrontmatter(ctx.output).body.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;
|
|
34
|
+
return one(within(n, check.min, check.max), `${n} words (needs ${bounds(check.min, check.max)})`);
|
|
35
|
+
}
|
|
36
|
+
// engine:check.words:end
|
|
37
|
+
// engine:check.frontmatter:begin
|
|
38
|
+
case "frontmatter": {
|
|
39
|
+
const v = splitFrontmatter(ctx.output).fields[check.field];
|
|
40
|
+
if (typeof v !== "string" || !v.trim()) return one(false, `frontmatter field "${check.field}" is missing`);
|
|
41
|
+
return one(within(v.length, check.minChars, check.maxChars), `"${check.field}" is ${v.length} characters (needs ${bounds(check.minChars, check.maxChars)})`);
|
|
42
|
+
}
|
|
43
|
+
// engine:check.frontmatter:end
|
|
44
|
+
// engine:check.command:begin
|
|
45
|
+
case "command": {
|
|
46
|
+
const proc = Bun.spawn(["sh", "-c", check.run], {
|
|
47
|
+
cwd: ctx.workspace, stdin: "ignore", stdout: "pipe", stderr: "pipe",
|
|
48
|
+
env: { ...process.env, OUTPUT: ctx.outputPath, WORKSPACE: ctx.workspace, TASK_DIR: ctx.taskDir },
|
|
49
|
+
});
|
|
50
|
+
let timedOut = false;
|
|
51
|
+
const timer = setTimeout(() => { timedOut = true; proc.kill(); }, (check.timeoutSec ?? 60) * 1000);
|
|
52
|
+
const [out, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
if (timedOut) return err(`command timed out after ${check.timeoutSec ?? 60}s`);
|
|
55
|
+
const said = (out.trim() || stderr.trim()).split("\n").slice(-1)[0]?.slice(0, 300);
|
|
56
|
+
return one(code === 0, said || `exit ${code}`);
|
|
57
|
+
}
|
|
58
|
+
// engine:check.command:end
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return err(e instanceof Error ? e.message : String(e));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The commands of `static-fire` and the flags each one takes: the one list that the CLI (src/cli.ts) parses with,
|
|
2
|
+
// rejects unknown flags by and prints as its usage. The guide for agents (src/guide.md) is checked against it
|
|
3
|
+
// (tests/guide.test.ts), so the guide cannot name a command or a flag that the CLI does not have.
|
|
4
|
+
|
|
5
|
+
/** Every flag, as `parseArgs` takes it. A default here is only used when the command takes the flag. */
|
|
6
|
+
export const OPTIONS = {
|
|
7
|
+
suites: { type: "string" }, tasks: { type: "string" }, grading: { type: "string" }, since: { type: "string" }, until: { type: "string" },
|
|
8
|
+
configs: { type: "string" }, trials: { type: "string", default: "3" },
|
|
9
|
+
judge: { type: "string" }, votes: { type: "string" }, "only-invalid": { type: "boolean" },
|
|
10
|
+
concurrency: { type: "string", default: "4" }, port: { type: "string", default: "4477" }, json: { type: "boolean" },
|
|
11
|
+
from: { type: "string" }, batch: { type: "string" },
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export type Flag = keyof typeof OPTIONS;
|
|
15
|
+
|
|
16
|
+
/** Each command: the flags its code reads, and its arguments for the usage text. */
|
|
17
|
+
export const COMMANDS: Record<string, { flags: readonly Flag[]; args?: string }> = {
|
|
18
|
+
init: { flags: ["json"] },
|
|
19
|
+
guide: { flags: [] },
|
|
20
|
+
serve: { flags: ["port"] },
|
|
21
|
+
configs: { flags: [] },
|
|
22
|
+
judges: { flags: [] },
|
|
23
|
+
suites: { flags: [] },
|
|
24
|
+
tasks: { flags: [] },
|
|
25
|
+
new: { flags: [], args: "task <id>" },
|
|
26
|
+
run: { flags: ["suites", "tasks", "configs", "trials", "judge", "votes", "concurrency"] },
|
|
27
|
+
regrade: { flags: ["tasks", "configs", "batch", "judge", "votes", "only-invalid", "concurrency"] },
|
|
28
|
+
rerun: { flags: ["concurrency"], args: "<batch>" },
|
|
29
|
+
list: { flags: [] },
|
|
30
|
+
show: { flags: ["suites", "tasks", "judge", "grading", "since", "until", "json"] },
|
|
31
|
+
export: { flags: [], args: "[path]" },
|
|
32
|
+
migrate: { flags: ["from"] },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The flags given on the command line that `command` does not take, as written (`--json`). */
|
|
36
|
+
export function unknownFlags(command: string, given: string[]): string[] {
|
|
37
|
+
const takes = new Set<string>(COMMANDS[command]?.flags ?? []);
|
|
38
|
+
return [...new Set(given)].filter((f) => !takes.has(f)).map((f) => `--${f}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The usage line the CLI prints for no command or an unknown one. */
|
|
42
|
+
export const USAGE = `usage: static-fire <${Object.keys(COMMANDS).join("|")}> (static-fire --version for the version)`;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Compare gradings: two grading setups of one task, over the trials that have a valid grading under both.
|
|
2
|
+
// GET /api/compare and the dashboard's comparison card read it through compareGradings().
|
|
3
|
+
//
|
|
4
|
+
// Each side is the view (buildView) with that grading setup picked, so the scope, config facets, date range and
|
|
5
|
+
// the human rule are the view's own. Only the trials valid on both sides are kept, and each side is summarised
|
|
6
|
+
// over exactly those trials by the view's summary, so score.ts computes every number (DEC-2). Gradings are never
|
|
7
|
+
// averaged together: each side reads only its own grading hash.
|
|
8
|
+
import type { GradingSetupRecord, Store, TrialRecord } from "./db";
|
|
9
|
+
import { gradingDiff, versionNames } from "./grading-versions";
|
|
10
|
+
import type { GradingDiff } from "./grading-versions";
|
|
11
|
+
import type { GradingContent } from "./identity";
|
|
12
|
+
import type { Harness, TaskSnapshot } from "./types";
|
|
13
|
+
import { buildView, projectCatalog, summarizeTrials } from "./view";
|
|
14
|
+
import type { Catalog, View, ViewFilters, ViewTrial } from "./view";
|
|
15
|
+
|
|
16
|
+
/** One of the two grading versions compared. */
|
|
17
|
+
export interface CompareSide {
|
|
18
|
+
/** Full grading hash. */
|
|
19
|
+
hash: string;
|
|
20
|
+
/** Its name, from what changed since the version before it for the same task and judge ("Original grading" for the first). */
|
|
21
|
+
name: string;
|
|
22
|
+
/** What changed since that version, in short words; empty for the original grading. */
|
|
23
|
+
nameParts: string[];
|
|
24
|
+
/** The task hash it grades. */
|
|
25
|
+
taskHash: string;
|
|
26
|
+
/** The task file's `version` stored with that task hash (a label only), or null when the task has none. */
|
|
27
|
+
label: number | null;
|
|
28
|
+
judgeLabel: string;
|
|
29
|
+
/** When the setup was first recorded (ISO time). */
|
|
30
|
+
firstSeen: string;
|
|
31
|
+
/** True when it is today's grading setup of the task for the judge in view. */
|
|
32
|
+
current: boolean;
|
|
33
|
+
/** The task's criteria as this setup graded with them. */
|
|
34
|
+
def: TaskSnapshot;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One config's task score under each setup, over the compared trials. */
|
|
38
|
+
export interface CompareConfig {
|
|
39
|
+
configKey: string;
|
|
40
|
+
label: string;
|
|
41
|
+
harness: Harness;
|
|
42
|
+
family: string;
|
|
43
|
+
profile?: string;
|
|
44
|
+
/** Compared trials of this config. */
|
|
45
|
+
n: number;
|
|
46
|
+
/** Task score (0–100) under setup a and setup b, and b − a. */
|
|
47
|
+
a: number | null;
|
|
48
|
+
b: number | null;
|
|
49
|
+
change: number | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One criterion's pass rate (0–1) for one config under each setup. Null when that setup's task has no such criterion. */
|
|
53
|
+
export interface CompareCriterion {
|
|
54
|
+
criterionId: string;
|
|
55
|
+
configKey: string;
|
|
56
|
+
a: number | null;
|
|
57
|
+
b: number | null;
|
|
58
|
+
/**
|
|
59
|
+
* True when the criterion itself differs between the two versions: it was added, removed or changed (`diff.criteria`),
|
|
60
|
+
* or it is a command check whose `run` names a grader file that differs (`diff.files.grader`). A script the named
|
|
61
|
+
* file loads in its turn is not traced.
|
|
62
|
+
*/
|
|
63
|
+
changed: boolean;
|
|
64
|
+
/** True when its pass rate differs between the versions (or it has one under one version only). */
|
|
65
|
+
moved: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface GradingComparison {
|
|
69
|
+
taskId: string;
|
|
70
|
+
a: CompareSide;
|
|
71
|
+
b: CompareSide;
|
|
72
|
+
/** Everything that differs from version a to version b, from their stored snapshots. */
|
|
73
|
+
diff: GradingDiff;
|
|
74
|
+
/** True when both setups grade the same task hash. Setups of two task versions share no trials. */
|
|
75
|
+
sameTask: boolean;
|
|
76
|
+
/** Trials with a valid grading under both setups: the only ones compared. */
|
|
77
|
+
trials: number;
|
|
78
|
+
/** Trials with a valid grading under only setup a, or only setup b. They are not counted. */
|
|
79
|
+
onlyA: number;
|
|
80
|
+
onlyB: number;
|
|
81
|
+
/** Configs with a compared trial, best under setup b first. */
|
|
82
|
+
configs: CompareConfig[];
|
|
83
|
+
/** Every criterion of either setup's task, for each config in `configs`. */
|
|
84
|
+
criteria: CompareCriterion[];
|
|
85
|
+
/** What the page's Reading line states (US-007, DEC-005). Null when no trial is compared, as with two task versions. */
|
|
86
|
+
reading: CompareReading | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The counts behind the Reading line. They count criteria and gates, not criterion-and-config rows: a criterion
|
|
91
|
+
* moved when its pass rate moved for at least one config.
|
|
92
|
+
*/
|
|
93
|
+
export interface CompareReading {
|
|
94
|
+
/** Changed criteria that moved. */
|
|
95
|
+
changedMoved: number;
|
|
96
|
+
/** Changed criteria that did not move. */
|
|
97
|
+
changedSteady: number;
|
|
98
|
+
/** Unchanged criteria that moved. */
|
|
99
|
+
unchangedMoved: number;
|
|
100
|
+
/**
|
|
101
|
+
* True when the difference is the judge varying between runs, not an effect of the change: no changed criterion
|
|
102
|
+
* moved, at least one unchanged criterion moved, and nothing outside criteria and gates differs (`diff.criteriaOnly`).
|
|
103
|
+
*/
|
|
104
|
+
judgeVariation: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const valid = (t: ViewTrial) => t.status === "done" && !!t.grades;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compares grading setups `a` and `b` (full hashes or unique prefixes) of one task in the scope of `filters`.
|
|
111
|
+
* The view's own `grading` filter is replaced by each setup in turn. Throws on an unknown or ambiguous hash, the
|
|
112
|
+
* same setup twice, setups of two tasks, or a task outside the scope.
|
|
113
|
+
*/
|
|
114
|
+
export function compareGradings(store: Store, filters: ViewFilters, a: string, b: string, catalog: Catalog = projectCatalog()): GradingComparison {
|
|
115
|
+
const sa = resolve(store, a, "a"), sb = resolve(store, b, "b");
|
|
116
|
+
if (sa.hash === sb.hash) throw new Error(`a and b are the same grading version (${sa.hash}); pick two different ones`);
|
|
117
|
+
if (sa.taskId !== sb.taskId) throw new Error(`grading versions ${sa.hash.slice(0, 8)} and ${sb.hash.slice(0, 8)} are of two tasks (${sa.taskId}, ${sb.taskId}); compare two grading versions of one task`);
|
|
118
|
+
const taskId = sa.taskId;
|
|
119
|
+
// Each setup brings its own judge (the view refuses another judge with a picked setup).
|
|
120
|
+
const { judge: _judge, ...rest } = filters;
|
|
121
|
+
const va = buildView(store, { ...rest, grading: sa.hash }, catalog); // throws when the task is not in scope
|
|
122
|
+
const vb = buildView(store, { ...rest, grading: sb.hash }, catalog);
|
|
123
|
+
const ofTask = (v: View) => new Map(v.trials.filter((t) => t.taskId === taskId && valid(t)).map((t) => [t.id, t]));
|
|
124
|
+
const ta = ofTask(va), tb = ofTask(vb);
|
|
125
|
+
const both = [...ta.keys()].filter((id) => tb.has(id));
|
|
126
|
+
|
|
127
|
+
const records = new Map<string, TrialRecord>(both.map((id) => [id, store.getTrial(id)!]));
|
|
128
|
+
const side = (v: View, trials: Map<string, ViewTrial>) =>
|
|
129
|
+
summarizeTrials(both.map((id) => trials.get(id)!), records, { [taskId]: v.tasks[taskId]! }, []).configs;
|
|
130
|
+
const ca = side(va, ta), cb = side(vb, tb);
|
|
131
|
+
|
|
132
|
+
const scoreOf = (list: typeof ca, key: string) => list.find((c) => c.configKey === key)?.tasks[taskId]?.score.mean ?? null;
|
|
133
|
+
const configs: CompareConfig[] = cb.map((c) => {
|
|
134
|
+
const x = scoreOf(ca, c.configKey), y = c.tasks[taskId]?.score.mean ?? null;
|
|
135
|
+
return {
|
|
136
|
+
configKey: c.configKey, label: c.label, harness: c.harness, family: c.family, ...(c.profile ? { profile: c.profile } : {}),
|
|
137
|
+
n: c.pooled[taskId]?.n ?? 0, a: x, b: y, change: x == null || y == null ? null : y - x,
|
|
138
|
+
};
|
|
139
|
+
}).sort((p, q) => (q.b ?? -1) - (p.b ?? -1) || (p.configKey < q.configKey ? -1 : 1));
|
|
140
|
+
|
|
141
|
+
const ids = (d: TaskSnapshot) => [...d.gates, ...d.measures.flatMap((m) => m.criteria)].map((c) => c.id);
|
|
142
|
+
const da = va.tasks[taskId]!.def, db = vb.tasks[taskId]!.def;
|
|
143
|
+
const inA = new Set(ids(da)), inB = new Set(ids(db));
|
|
144
|
+
const rate = (list: typeof ca, key: string, id: string, has: Set<string>) =>
|
|
145
|
+
has.has(id) ? list.find((c) => c.configKey === key)?.tasks[taskId]?.criteria[id] ?? null : null;
|
|
146
|
+
const diff = gradingDiff({ setup: sa, task: store.getTaskVersion(sa.taskHash) }, { setup: sb, task: store.getTaskVersion(sb.taskHash) });
|
|
147
|
+
const changed = changedCriteria(sa, sb, diff);
|
|
148
|
+
const criteria = [...new Set([...ids(da), ...ids(db)])].flatMap((criterionId) => configs.map((c): CompareCriterion => {
|
|
149
|
+
const x = rate(ca, c.configKey, criterionId, inA), y = rate(cb, c.configKey, criterionId, inB);
|
|
150
|
+
return { criterionId, configKey: c.configKey, a: x, b: y, changed: changed.has(criterionId), moved: x == null || y == null ? x !== y : Math.abs(y - x) >= 1e-9 };
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
const names = versionNames(store, taskId);
|
|
154
|
+
const sideOf = (s: GradingSetupRecord, v: View): CompareSide => ({
|
|
155
|
+
hash: s.hash, name: names.get(s.hash)?.name ?? s.hash.slice(0, 8), nameParts: names.get(s.hash)?.nameParts ?? [], taskHash: s.taskHash, label: v.tasks[taskId]!.label, judgeLabel: s.judgeLabel, firstSeen: s.firstSeen,
|
|
156
|
+
current: v.tasks[taskId]!.current, def: v.tasks[taskId]!.def,
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
taskId, a: sideOf(sa, va), b: sideOf(sb, vb),
|
|
160
|
+
diff,
|
|
161
|
+
sameTask: sa.taskHash === sb.taskHash,
|
|
162
|
+
trials: both.length, onlyA: ta.size - both.length, onlyB: tb.size - both.length, configs, criteria,
|
|
163
|
+
reading: both.length ? reading(criteria, diff) : null,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function reading(rows: CompareCriterion[], diff: GradingDiff): CompareReading {
|
|
168
|
+
const moved = new Map<string, { changed: boolean; moved: boolean }>();
|
|
169
|
+
for (const r of rows) {
|
|
170
|
+
const m = moved.get(r.criterionId);
|
|
171
|
+
moved.set(r.criterionId, { changed: r.changed, moved: r.moved || !!m?.moved });
|
|
172
|
+
}
|
|
173
|
+
const list = [...moved.values()];
|
|
174
|
+
const changedMoved = list.filter((c) => c.changed && c.moved).length;
|
|
175
|
+
const changedSteady = list.filter((c) => c.changed && !c.moved).length;
|
|
176
|
+
const unchangedMoved = list.filter((c) => !c.changed && c.moved).length;
|
|
177
|
+
return { changedMoved, changedSteady, unchangedMoved, judgeVariation: changedMoved === 0 && unchangedMoved > 0 && diff.criteriaOnly };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The ids of the gates and criteria that differ between the two versions: each one in the difference, and each
|
|
182
|
+
* command check whose `run` (under either version) names a grader file, by its path in the task folder, that was
|
|
183
|
+
* added, removed or changed.
|
|
184
|
+
*/
|
|
185
|
+
function changedCriteria(sa: GradingSetupRecord, sb: GradingSetupRecord, diff: GradingDiff): Set<string> {
|
|
186
|
+
const out = new Set(diff.criteria.map((c) => c.id));
|
|
187
|
+
const g = diff.files.grader, files = [...g.added, ...g.removed, ...g.changed];
|
|
188
|
+
if (!files.length) return out;
|
|
189
|
+
for (const snap of [sa.snapshot, sb.snapshot] as unknown as Partial<GradingContent>[]) {
|
|
190
|
+
for (const c of [...(snap.gates ?? []), ...(snap.criteria ?? [])]) {
|
|
191
|
+
if (c.check?.type === "command" && files.some((f) => (c.check as { run: string }).run.includes(f))) out.add(c.id);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolve(store: Store, prefix: string, which: string): GradingSetupRecord {
|
|
198
|
+
if (!prefix) throw new Error(`${which}: pick a grading version`);
|
|
199
|
+
const p = prefix.toLowerCase();
|
|
200
|
+
const found = store.listGradingSetups().filter((g) => g.hash.startsWith(p));
|
|
201
|
+
if (found.length === 1) return found[0]!;
|
|
202
|
+
throw new Error(found.length ? `${which}: grading ${prefix} matches ${found.length} grading versions; give more characters` : `${which}: no grading version ${prefix}`);
|
|
203
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Agent and human configs: configs/<id>.yaml → AgentConfig, validated and with paths resolved.
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { HARNESSES, type AgentConfig, type Harness, type HumanOutput } from "./types";
|
|
5
|
+
|
|
6
|
+
const expand = (p: string, root: string) => (p.startsWith("~/") ? join(homedir(), p.slice(2)) : isAbsolute(p) ? p : join(root, p));
|
|
7
|
+
|
|
8
|
+
export function normalizeConfig(id: string, raw: any, root: string): AgentConfig {
|
|
9
|
+
const harness = raw?.harness as Harness;
|
|
10
|
+
if (!HARNESSES.includes(harness)) throw new Error(`configs/${id}.yaml: harness must be one of ${HARNESSES.join(", ")}`);
|
|
11
|
+
if ("label" in (raw ?? {})) throw new Error(`configs/${id}.yaml: remove label - it is built from harness, model, effort and profile`);
|
|
12
|
+
if (harness !== "human") {
|
|
13
|
+
if (!raw.model) throw new Error(`configs/${id}.yaml: an agent config needs a model`);
|
|
14
|
+
return { id, ...raw };
|
|
15
|
+
}
|
|
16
|
+
const entries = Object.entries(raw.outputs ?? {});
|
|
17
|
+
if (!entries.length) throw new Error(`configs/${id}.yaml: a human config needs outputs (task id → file)`);
|
|
18
|
+
const outputs: Record<string, HumanOutput> = {};
|
|
19
|
+
for (const [task, v] of entries) {
|
|
20
|
+
const o = typeof v === "string" ? { path: v } : (v as HumanOutput);
|
|
21
|
+
if (!o?.path) throw new Error(`configs/${id}.yaml: outputs.${task} needs a path`);
|
|
22
|
+
outputs[task] = { path: expand(o.path, root), ...(o.note ? { note: o.note } : {}) };
|
|
23
|
+
}
|
|
24
|
+
return { id, harness, model: "by hand", family: "human", outputs };
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Did an agent copy the held-out original (for example by finding the published version on the web)?
|
|
2
|
+
// Share of the output's 8-word runs that also appear in the original. Independent writing on the same
|
|
3
|
+
// topic shares almost none; a copy, even lightly edited, shares most.
|
|
4
|
+
const N = 8;
|
|
5
|
+
const words = (s: string) => s.toLowerCase().replace(/[^a-z0-9'\s]/g, " ").split(/\s+/).filter(Boolean);
|
|
6
|
+
const runs = (ws: string[]) => { const out = new Set<string>(); for (let i = 0; i + N <= ws.length; i++) out.add(ws.slice(i, i + N).join(" ")); return out; };
|
|
7
|
+
|
|
8
|
+
export function copiedShare(output: string, original: string): number {
|
|
9
|
+
const mine = runs(words(output));
|
|
10
|
+
if (!mine.size) return 0;
|
|
11
|
+
const theirs = runs(words(original));
|
|
12
|
+
let shared = 0;
|
|
13
|
+
for (const r of mine) if (theirs.has(r)) shared++;
|
|
14
|
+
return shared / mine.size;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Above this share, the trial is invalid: the output is a copy, not the agent's own work. */
|
|
18
|
+
export const COPY_LIMIT = 0.3;
|