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,54 @@
|
|
|
1
|
+
// What the dashboard's Tasks page shows of a task (#79): what the agent gets, the output path, the gates (the
|
|
2
|
+
// engine's output gate first) and the criteria by measure. Built from an allowlist, so the graders' own material (references, sources, the held-out
|
|
3
|
+
// file) and grading details (context, check details, evidence, derived evidence scripts) never reach the page.
|
|
4
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
5
|
+
import { join, relative, sep } from "node:path";
|
|
6
|
+
import { OUTPUT_GATE } from "./grade";
|
|
7
|
+
import type { Criterion, Level, Task } from "./types";
|
|
8
|
+
|
|
9
|
+
export interface TaskPageCriterion { id: string; question: string; level: Level; grader: Criterion["grader"] }
|
|
10
|
+
|
|
11
|
+
export interface TaskPage {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
version?: number;
|
|
16
|
+
prompt: string;
|
|
17
|
+
/** The input files as the agent finds them in its workspace (`inputs/brief.md`). */
|
|
18
|
+
inputFiles: string[];
|
|
19
|
+
/** The project files, copied to the workspace root. Only when the task has `project`. */
|
|
20
|
+
projectFiles?: string[];
|
|
21
|
+
output: string;
|
|
22
|
+
/** The engine's own output gate (grade.ts OUTPUT_GATE), which every task has, first, then the task's own gates. */
|
|
23
|
+
gates: TaskPageCriterion[];
|
|
24
|
+
measures: { id: string; label: string; weight: number; criteria: TaskPageCriterion[] }[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** File paths under `dir`, relative to `from`, with "/" separators. Names that start with "." are left out, as the task hash leaves them out. */
|
|
28
|
+
function fileNames(dir: string, from: string): string[] {
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
const walk = (p: string) => {
|
|
31
|
+
if (statSync(p).isFile()) { out.push(relative(from, p).split(sep).join("/")); return; }
|
|
32
|
+
for (const f of readdirSync(p).sort()) if (!f.startsWith(".")) walk(join(p, f));
|
|
33
|
+
};
|
|
34
|
+
if (existsSync(dir)) walk(dir);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const criterion = (c: Criterion): TaskPageCriterion => ({ id: c.id, question: c.question, level: c.level, grader: c.grader });
|
|
39
|
+
|
|
40
|
+
export function taskPage(task: Task): TaskPage {
|
|
41
|
+
const projectDir = task.project ? join(task.dir, task.project) : null;
|
|
42
|
+
return {
|
|
43
|
+
id: task.id,
|
|
44
|
+
title: task.title,
|
|
45
|
+
description: task.description ?? "",
|
|
46
|
+
...(task.version != null ? { version: task.version } : {}),
|
|
47
|
+
prompt: task.prompt,
|
|
48
|
+
inputFiles: fileNames(join(task.dir, task.inputs), task.dir),
|
|
49
|
+
...(projectDir ? { projectFiles: fileNames(projectDir, projectDir) } : {}),
|
|
50
|
+
output: task.output,
|
|
51
|
+
gates: [OUTPUT_GATE, ...(task.gates ?? [])].map(criterion),
|
|
52
|
+
measures: task.measures.map((m) => ({ id: m.id, label: m.label, weight: m.weight ?? 1, criteria: m.criteria.map(criterion) })),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// One trial as the trial drawer reads it (GET /api/trials/:id and its output): the trial row with its run
|
|
2
|
+
// conditions, and every grading it has, each with its grading setup and verdicts. Read-only.
|
|
3
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { extname, isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
+
import { trialDir } from "./db";
|
|
6
|
+
import type { GradingRecord, Store, TrialRecord, UserSetupRecord, VerdictRecord } from "./db";
|
|
7
|
+
import { namedSetup, versionNames } from "./grading-versions";
|
|
8
|
+
import type { NamedGradingSetup } from "./grading-versions";
|
|
9
|
+
import { IMAGE_MEDIA } from "./judge";
|
|
10
|
+
|
|
11
|
+
export interface TrialGrading extends GradingRecord {
|
|
12
|
+
/** The grading version of its grading hash, with its name, or null when the store has no row for it. */
|
|
13
|
+
setup: NamedGradingSetup | null;
|
|
14
|
+
verdicts: VerdictRecord[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TrialDetail {
|
|
18
|
+
/** The trial row as stored, run conditions included (setupHash, profileHash, blockedTools, harnessVersion, startedAt). */
|
|
19
|
+
trial: TrialRecord;
|
|
20
|
+
/** The user setup of its setup hash (path → content hash of each file), or null. */
|
|
21
|
+
userSetup: UserSetupRecord | null;
|
|
22
|
+
/** Every grading of the trial, oldest first, finished or not. Which one a view counts is the view's ViewTrial.gradingId. */
|
|
23
|
+
gradings: TrialGrading[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The trial and every grading it has, or null for an unknown id. */
|
|
27
|
+
export function trialDetail(store: Store, trialId: string): TrialDetail | null {
|
|
28
|
+
const trial = store.getTrial(trialId);
|
|
29
|
+
if (!trial) return null;
|
|
30
|
+
const setups = new Map<string, NamedGradingSetup | null>();
|
|
31
|
+
let names: ReturnType<typeof versionNames> | undefined; // built once, on the first grading that has a setup
|
|
32
|
+
const setupOf = (hash: string) => {
|
|
33
|
+
if (!setups.has(hash)) {
|
|
34
|
+
const s = store.getGradingSetup(hash);
|
|
35
|
+
// The task's grading versions include every one its trials were graded under, so each stored setup has a name.
|
|
36
|
+
setups.set(hash, s && namedSetup(s, names ??= versionNames(store, trial.taskId)));
|
|
37
|
+
}
|
|
38
|
+
return setups.get(hash)!;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
trial,
|
|
42
|
+
userSetup: trial.setupHash ? store.getUserSetup(trial.setupHash) : null,
|
|
43
|
+
gradings: store.listGradings(trialId).map((g) => ({ ...g, setup: setupOf(g.gradingHash), verdicts: store.listVerdicts(g.id) })),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The trial's output file as the drawer shows it: an image (by the output path's extension) or text, with the output path it was read from. */
|
|
48
|
+
export type TrialOutputFile = ({ image: Uint8Array<ArrayBuffer>; mediaType: string } | { text: string }) & { path: string };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The trial's output file: the task's `output` path, as recorded with the task version the trial ran on, inside
|
|
52
|
+
* the trial's saved workspace. An image type the judges take (IMAGE_MEDIA) is read as bytes with its media
|
|
53
|
+
* type; any other file as UTF-8 text. Otherwise a plain reason: the trial is unknown, its task version has no
|
|
54
|
+
* recorded output path (never guessed from today's task file), the file is missing, or the path (after
|
|
55
|
+
* symlinks) leaves the workspace.
|
|
56
|
+
*/
|
|
57
|
+
export function readTrialOutputFile(store: Store, dbPath: string, trialId: string): TrialOutputFile | { missing: string } {
|
|
58
|
+
const trial = store.getTrial(trialId);
|
|
59
|
+
if (!trial) return { missing: `no trial ${trialId}` };
|
|
60
|
+
const snap = store.getTaskVersion(trial.taskHash)?.snapshot as { output?: unknown } | undefined;
|
|
61
|
+
const output = typeof snap?.output === "string" && snap.output ? snap.output : null;
|
|
62
|
+
if (!output) return { missing: `trial ${trialId}: its task version ${trial.taskHash.slice(0, 8)} has no recorded output path` };
|
|
63
|
+
const none = { missing: `trial ${trialId} has no output file at ${output} in its saved workspace` };
|
|
64
|
+
const workspace = join(trialDir(dbPath, trial.id), "workspace");
|
|
65
|
+
const file = join(workspace, output);
|
|
66
|
+
if (!existsSync(file) || !existsSync(workspace)) return none;
|
|
67
|
+
let real: string;
|
|
68
|
+
try { real = realpathSync(file); } catch { return none; }
|
|
69
|
+
const rel = relative(realpathSync(workspace), real);
|
|
70
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return { missing: `trial ${trialId}: its output path ${output} leaves the workspace` };
|
|
71
|
+
const mediaType = IMAGE_MEDIA[extname(output).toLowerCase()];
|
|
72
|
+
try { return mediaType ? { image: readFileSync(real), mediaType, path: output } : { text: readFileSync(real, "utf8"), path: output }; } catch { return none; } // a folder, or unreadable
|
|
73
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// The primitives (see CONTEXT.md): Suite → Task → Measure → Criterion → Grader → Verdict.
|
|
2
|
+
// Trials, gradings and batches are records in the record store (src/core/db.ts).
|
|
3
|
+
|
|
4
|
+
/** Agent harnesses run a model headless. `human` is a person: its output is a file they wrote. */
|
|
5
|
+
export type AgentHarness = "claude" | "codex" | "pi" | "agy";
|
|
6
|
+
export type Harness = AgentHarness | "human";
|
|
7
|
+
export const HARNESSES: Harness[] = ["claude", "codex", "pi", "agy", "human"];
|
|
8
|
+
|
|
9
|
+
export interface HumanOutput { path: string; note?: string }
|
|
10
|
+
|
|
11
|
+
export interface AgentConfig {
|
|
12
|
+
id: string; // file name in configs/ (the label is built from the fields: core/label.ts)
|
|
13
|
+
harness: Harness;
|
|
14
|
+
model: string; // "by hand" for a human config
|
|
15
|
+
effort?: string;
|
|
16
|
+
provider?: string; // pi only
|
|
17
|
+
profile?: string; // apkit Profile name; omitted = no Profile
|
|
18
|
+
family?: string; // model family for chart colour; defaults to a guess from `model`
|
|
19
|
+
outputs?: Record<string, HumanOutput>; // human only: task id → the file they wrote (absolute path)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface JudgeConfig {
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
harness: Exclude<AgentHarness, "pi">; // judge Hosts must support JSON Schema output
|
|
26
|
+
model: string;
|
|
27
|
+
effort?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---- Task definition -------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export type Level = "floor" | "strong" | "ceiling";
|
|
33
|
+
export type Evidence = "output" | "inputs" | "references" | "project" | "sources";
|
|
34
|
+
|
|
35
|
+
export type Check =
|
|
36
|
+
| { type: "regex"; pattern: string; flags?: string; min?: number; max?: number }
|
|
37
|
+
| { type: "words"; min?: number; max?: number }
|
|
38
|
+
| { type: "frontmatter"; field: string; minChars?: number; maxChars?: number }
|
|
39
|
+
| { type: "command"; run: string; timeoutSec?: number };
|
|
40
|
+
|
|
41
|
+
export interface Criterion {
|
|
42
|
+
id: string;
|
|
43
|
+
question: string; // phrased so that "yes" = pass
|
|
44
|
+
level: Level;
|
|
45
|
+
grader: "check" | "judge" | "human";
|
|
46
|
+
check?: Check; // grader: check
|
|
47
|
+
evidence?: Evidence[]; // grader: judge (default: ["output"])
|
|
48
|
+
isolate?: boolean; // grader: judge — its own call instead of the measure's batch
|
|
49
|
+
derived?: string; // grader: judge — a script under the task's grader/ folder that makes an image from the output; the judge sees it (README, "Derived evidence")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Measure {
|
|
53
|
+
id: string;
|
|
54
|
+
label: string;
|
|
55
|
+
weight?: number; // default 1
|
|
56
|
+
criteria: Criterion[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Task {
|
|
60
|
+
id: string;
|
|
61
|
+
version?: number; // a readable label only, in neither hash; a task file can leave it out
|
|
62
|
+
title: string;
|
|
63
|
+
description: string;
|
|
64
|
+
prompt: string;
|
|
65
|
+
inputs: string; // copied into the trial workspace
|
|
66
|
+
output: string; // path the agent must write, relative to the workspace
|
|
67
|
+
references?: string; // graders only
|
|
68
|
+
project?: string; // files the agent would find in the real project (copied into the workspace root, not shown to graders)
|
|
69
|
+
sources?: string; // graders only: saved copies of the pages the notes link to, for fact checks
|
|
70
|
+
heldOut?: string; // the real original this task was built from; an agent output that copies it is invalid
|
|
71
|
+
context?: string; // one paragraph a judge reads before the criteria
|
|
72
|
+
gates: Criterion[];
|
|
73
|
+
measures: Measure[];
|
|
74
|
+
dir: string; // absolute task directory (set by the loader)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Suite {
|
|
78
|
+
id: string;
|
|
79
|
+
title: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
tasks: string[];
|
|
82
|
+
weights?: Record<string, number>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---- Results -----------------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export interface Usage {
|
|
88
|
+
inputTokens: number;
|
|
89
|
+
outputTokens: number;
|
|
90
|
+
cacheReadTokens: number;
|
|
91
|
+
cacheWriteTokens: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface AgentResult {
|
|
95
|
+
ok: boolean;
|
|
96
|
+
error?: string;
|
|
97
|
+
usage: Usage;
|
|
98
|
+
costUsd: number | null;
|
|
99
|
+
costSource: "reported" | "estimated" | "unknown";
|
|
100
|
+
turns?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Vote { pass: boolean; reason: string }
|
|
104
|
+
|
|
105
|
+
export interface Verdict {
|
|
106
|
+
score: number | null; // 0–1 (judge: share of votes passed); null = error
|
|
107
|
+
votes: Vote[];
|
|
108
|
+
error?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface Grades {
|
|
112
|
+
verdicts: Record<string, Verdict>; // by criterion id, gates included
|
|
113
|
+
gatesPassed: boolean;
|
|
114
|
+
measures: Record<string, number>; // 0–100 per measure
|
|
115
|
+
score: number; // 0–100 task score
|
|
116
|
+
errors: string[]; // criterion ids that errored (→ trial invalid)
|
|
117
|
+
judgeCostUsd: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type TrialStatus = "queued" | "running" | "grading" | "done" | "invalid" | "failed";
|
|
121
|
+
|
|
122
|
+
export interface Trial {
|
|
123
|
+
id: string; // the trial id
|
|
124
|
+
experimentId: string; // the batch id (the name stays because tests/score.test.ts sets it)
|
|
125
|
+
taskId: string;
|
|
126
|
+
configId: string;
|
|
127
|
+
index: number;
|
|
128
|
+
status: TrialStatus;
|
|
129
|
+
startedAt?: string;
|
|
130
|
+
endedAt?: string;
|
|
131
|
+
durationMs?: number; // agent wall-clock only
|
|
132
|
+
result?: AgentResult;
|
|
133
|
+
grades?: Grades;
|
|
134
|
+
error?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The input summarize() in score.ts takes: the tasks, suites and configs of one view (view.ts builds it).
|
|
139
|
+
* It is also the shape of the old `experiment.json` that migrate.ts imports. Nothing writes it any more.
|
|
140
|
+
* The name stays because score.ts and tests/score.test.ts use it, and both stay as they are.
|
|
141
|
+
*/
|
|
142
|
+
export interface Experiment {
|
|
143
|
+
schema: 2;
|
|
144
|
+
id: string;
|
|
145
|
+
suiteIds: string[];
|
|
146
|
+
tasks: Record<string, number | null>; // task id → version at run time (null: the task file has none)
|
|
147
|
+
configIds: string[];
|
|
148
|
+
trialsPerConfig: number;
|
|
149
|
+
judge: JudgeConfig & { votes: number };
|
|
150
|
+
createdAt: string;
|
|
151
|
+
status: "running" | "done";
|
|
152
|
+
configs: Record<string, AgentConfig>; // snapshot at run time
|
|
153
|
+
suites: Record<string, Suite>; // snapshot at run time
|
|
154
|
+
userSetup?: { files: Record<string, string> }; // the user-level setup trials ran with (path → hash)
|
|
155
|
+
blockedTools?: Partial<Record<AgentHarness, string[]>>; // outside-service tools blocked in trials
|
|
156
|
+
gradedAt?: string; // when the current grading started; createdAt before any regrade
|
|
157
|
+
taskDefs?: Record<string, TaskSnapshot>; // each task's criteria as the current grading used them
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A task's definition as one grading setup used it, so an earlier grading setup shows its own criteria. */
|
|
161
|
+
export type TaskSnapshot = Pick<Task, "id" | "title" | "description" | "version" | "gates" | "measures">;
|
|
162
|
+
|
|
163
|
+
// ---- Summaries (what the dashboard and `static-fire show` read) -------------------------------------
|
|
164
|
+
|
|
165
|
+
export interface Stat { mean: number | null; ci: number | null; n: number }
|
|
166
|
+
|
|
167
|
+
export interface TaskSummary {
|
|
168
|
+
score: Stat;
|
|
169
|
+
measures: Record<string, number>; // mean 0–100 per measure
|
|
170
|
+
criteria: Record<string, number>; // mean pass rate 0–1 per criterion
|
|
171
|
+
costUsd: number | null;
|
|
172
|
+
costSource: AgentResult["costSource"];
|
|
173
|
+
durationMs: number | null;
|
|
174
|
+
valid: number; invalid: number; failed: number; total: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface ConfigSummary {
|
|
178
|
+
configId: string;
|
|
179
|
+
label: string;
|
|
180
|
+
harness: Harness;
|
|
181
|
+
model: string;
|
|
182
|
+
family: string;
|
|
183
|
+
effort?: string;
|
|
184
|
+
profile?: string;
|
|
185
|
+
tasks: Record<string, TaskSummary>;
|
|
186
|
+
suites: Record<string, Stat>; // Suite Index per suite
|
|
187
|
+
index: Stat; // Agent Performance Index
|
|
188
|
+
costUsd: number | null; // mean cost per task over all tasks
|
|
189
|
+
durationMs: number | null; // mean time per task
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ExperimentSummary {
|
|
193
|
+
configs: ConfigSummary[];
|
|
194
|
+
deadCriteria: Record<string, string[]>; // task id → criterion ids every config passed or failed
|
|
195
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The query of GET /api/view, shared by the server and the dashboard (no Node imports here).
|
|
2
|
+
// One table of parameters: a filter added to the view is one more field in ViewQuery and one more entry in
|
|
3
|
+
// SINGLE (one value) or CONFIG_FACETS (repeated, several values), and in PARAMS for its place in the URL. Both
|
|
4
|
+
// the route and the dashboard's loading code pick it up. This file is the one home of the config facet list.
|
|
5
|
+
import type { ViewFilters } from "./view";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the chart is about: the Agent Performance Index over the suites in view (the picked suites, else the default
|
|
9
|
+
* set), or one task.
|
|
10
|
+
*/
|
|
11
|
+
export type Scope = { kind: "index" } | { kind: "task"; id: string };
|
|
12
|
+
|
|
13
|
+
/** The view a page asks for. Every field is optional; an empty object is the default view. */
|
|
14
|
+
export interface ViewQuery {
|
|
15
|
+
scope?: Scope;
|
|
16
|
+
/**
|
|
17
|
+
* The picked suites of the index, as the repeated `suite` parameter (`?suite=short&suite=writing`). None picked:
|
|
18
|
+
* the view chooses the default set. Only with the index scope.
|
|
19
|
+
*/
|
|
20
|
+
suites?: string[];
|
|
21
|
+
judge?: string;
|
|
22
|
+
grading?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Config facets: the picked values of each. A trial matches a facet when its value is any of them.
|
|
25
|
+
* `none` selects configs without an effort or without a Profile.
|
|
26
|
+
*/
|
|
27
|
+
harness?: string[];
|
|
28
|
+
model?: string[];
|
|
29
|
+
effort?: string[];
|
|
30
|
+
profile?: string[];
|
|
31
|
+
since?: string;
|
|
32
|
+
until?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Scope in a URL: absent or `index`, `task:<id>`. A link from before #80 can say `suite:<id>`: that is `suite=<id>`. */
|
|
36
|
+
export const scopeParam = (s: Scope): string => (s.kind === "index" ? "index" : `task:${s.id}`);
|
|
37
|
+
|
|
38
|
+
function parseScope(v: string): { scope: Scope; suite?: string } {
|
|
39
|
+
if (v === "index") return { scope: { kind: "index" } };
|
|
40
|
+
const m = /^(suite|task):(.+)$/.exec(v);
|
|
41
|
+
if (!m) throw new Error(`scope ${v} is not index, suite:<id> or task:<id>`);
|
|
42
|
+
return m[1] === "task" ? { scope: { kind: "task", id: m[2]! } } : { scope: { kind: "index" }, suite: m[2]! };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parameters that take one value each. A view has one judge, one grading version and one date range. */
|
|
46
|
+
type Single = "judge" | "grading" | "since" | "until";
|
|
47
|
+
const SINGLE: Single[] = ["judge", "grading", "since", "until"];
|
|
48
|
+
/** The config facets, each a field of the trial row: the parameter repeats for each picked value (`?model=opus&model=haiku`). */
|
|
49
|
+
export const CONFIG_FACETS = ["harness", "model", "effort", "profile"] as const;
|
|
50
|
+
export type ConfigFacet = (typeof CONFIG_FACETS)[number];
|
|
51
|
+
/** Every parameter, in the order a URL the page writes lists them. */
|
|
52
|
+
const PARAMS: readonly ("scope" | "suite" | Single | ConfigFacet)[] = ["scope", "suite", "judge", "grading", "harness", "model", "effort", "profile", "since", "until"];
|
|
53
|
+
const KNOWN = new Set<string>(["scope", "suite", ...SINGLE, ...CONFIG_FACETS]);
|
|
54
|
+
const isConfig = (k: string): k is ConfigFacet => (CONFIG_FACETS as readonly string[]).includes(k);
|
|
55
|
+
|
|
56
|
+
/** The values in one stable order, each once, with empty ones dropped: a set of picks has one URL. */
|
|
57
|
+
export const valueSet = (values: Iterable<string>): string[] => [...new Set(values)].filter(Boolean).sort();
|
|
58
|
+
|
|
59
|
+
/** Reads a query string. Throws on an unknown or repeated parameter or a bad scope, so a filter is never silently ignored. */
|
|
60
|
+
export function parseViewQuery(params: URLSearchParams): ViewQuery {
|
|
61
|
+
const unknown = [...new Set([...params.keys()].filter((k) => !KNOWN.has(k)))];
|
|
62
|
+
if (unknown.length) throw new Error(`unknown view parameter(s): ${unknown.join(", ")} (known: ${PARAMS.join(", ")})`);
|
|
63
|
+
const repeated = (["scope", ...SINGLE] as string[]).filter((k) => params.getAll(k).length > 1);
|
|
64
|
+
if (repeated.length) throw new Error(`view parameter(s) ${repeated.join(", ")} can take only one value (repeat only ${CONFIG_FACETS.join(", ")})`);
|
|
65
|
+
const q: ViewQuery = {};
|
|
66
|
+
const scope = params.get("scope");
|
|
67
|
+
const suites = params.getAll("suite");
|
|
68
|
+
if (scope) {
|
|
69
|
+
const parsed = parseScope(scope);
|
|
70
|
+
q.scope = parsed.scope;
|
|
71
|
+
if (parsed.suite) suites.push(parsed.suite);
|
|
72
|
+
}
|
|
73
|
+
const picked = valueSet(suites);
|
|
74
|
+
if (picked.length && q.scope?.kind === "task") throw new Error(`suite cannot be picked with scope ${scope}: a task's view has no suites`);
|
|
75
|
+
if (picked.length) q.suites = picked;
|
|
76
|
+
for (const k of SINGLE) { const v = params.get(k); if (v) q[k] = v; }
|
|
77
|
+
for (const k of CONFIG_FACETS) { const v = valueSet(params.getAll(k)); if (v.length) q[k] = v; }
|
|
78
|
+
return q;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The query string for `q`: only the fields that are set; the index scope is the default, so it is left out. */
|
|
82
|
+
export function viewQueryParams(q: ViewQuery): URLSearchParams {
|
|
83
|
+
const p = new URLSearchParams();
|
|
84
|
+
if (q.scope && q.scope.kind !== "index") p.set("scope", scopeParam(q.scope));
|
|
85
|
+
else for (const v of valueSet(q.suites ?? [])) p.append("suite", v);
|
|
86
|
+
for (const k of PARAMS) {
|
|
87
|
+
if (k === "scope" || k === "suite") continue;
|
|
88
|
+
if (isConfig(k)) for (const v of valueSet(q[k] ?? [])) p.append(k, v);
|
|
89
|
+
else if (q[k]) p.set(k, q[k]!);
|
|
90
|
+
}
|
|
91
|
+
return p;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The view function's filters for `q`: the picked suites of the index, or a task scope's task. */
|
|
95
|
+
export function toViewFilters(q: ViewQuery): ViewFilters {
|
|
96
|
+
const f: ViewFilters = {};
|
|
97
|
+
if (q.scope?.kind === "task") f.tasks = [q.scope.id];
|
|
98
|
+
else if (q.suites?.length) f.suites = valueSet(q.suites);
|
|
99
|
+
for (const k of SINGLE) if (q[k]) f[k] = q[k];
|
|
100
|
+
for (const k of CONFIG_FACETS) { const v = valueSet(q[k] ?? []); if (v.length) f[k] = v; }
|
|
101
|
+
return f;
|
|
102
|
+
}
|