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
package/web/app.tsx
ADDED
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import type { AgentConfig, Criterion, JudgeConfig, Suite, Trial } from "../src/core/types";
|
|
5
|
+
import type { TrialDetail } from "../src/core/trial-detail";
|
|
6
|
+
import type { View, ViewConfigSummary, ViewTrial } from "../src/core/view";
|
|
7
|
+
import { parseViewQuery, viewQueryParams, type Scope, type ViewQuery } from "../src/core/view-query";
|
|
8
|
+
import { suiteNotes } from "./suite-notes";
|
|
9
|
+
import type { Facets } from "../src/core/facets";
|
|
10
|
+
import { configLabel } from "../src/core/label";
|
|
11
|
+
import type { RegradeEnded, RegradeRun } from "../src/api";
|
|
12
|
+
import { BarList, ConfigTable, dateRange, FrontierChart, money, secs, type HumanLine, type Point, type Row } from "./charts";
|
|
13
|
+
import { familyName, familySlot, HARNESS_NAME, HARNESS_ORDER, Marker } from "./encoding";
|
|
14
|
+
import { PAGES, pageOf, pathOf, type PageId } from "./pages";
|
|
15
|
+
import { api } from "./api";
|
|
16
|
+
import { ComparePage } from "./compare";
|
|
17
|
+
import { CLEAR_ALL, filterTags, menuSections, pick, searchMenu, SECTION_NAMES, type MenuValue, type SectionId } from "./filter-menu";
|
|
18
|
+
import { runStatus, STATUS_REFRESH_MS, statusText, type RunStatus } from "./run-status";
|
|
19
|
+
|
|
20
|
+
/** The authored definitions the page lists: every suite and task (for the scope selector) and the config files. */
|
|
21
|
+
interface Catalog { suites: Suite[]; titles: Record<string, string>; configs: AgentConfig[] }
|
|
22
|
+
/** A loaded view and the scope it was asked for. */
|
|
23
|
+
interface Loaded { view: View; scope: Scope }
|
|
24
|
+
|
|
25
|
+
const pct = (v: number | null | undefined, d = 0) => (v == null ? "—" : v.toFixed(d));
|
|
26
|
+
const short = (hash: string) => hash.slice(0, 8);
|
|
27
|
+
const viewUrl = (q: ViewQuery) => { const p = viewQueryParams(q).toString(); return `/api/view${p ? `?${p}` : ""}`; };
|
|
28
|
+
/** The page's URL query is the view query: a copied URL opens the same view. A URL the page cannot read is an error. */
|
|
29
|
+
const readUrl = (): { query: ViewQuery; error: string } => {
|
|
30
|
+
try { return { query: parseViewQuery(new URLSearchParams(location.search)), error: "" }; } catch (e) { return { query: {}, error: (e as Error).message }; }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function App() {
|
|
34
|
+
// What the page asks for. The scope selector sets `scope`; the filter bar (#10) adds the other fields.
|
|
35
|
+
// Which page the path names (web/pages.ts). Only the Results page reads its query: another page's URL has none.
|
|
36
|
+
const [page, setPage] = useState<PageId>(() => pageOf(location.pathname));
|
|
37
|
+
const [start] = useState(() => (pageOf(location.pathname) === "results" ? readUrl() : { query: {}, error: "" }));
|
|
38
|
+
const [query, setQuery] = useState<ViewQuery>(start.query);
|
|
39
|
+
const [urlError, setUrlError] = useState(start.error);
|
|
40
|
+
const [facets, setFacets] = useState<Facets | null>(null);
|
|
41
|
+
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
42
|
+
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
|
43
|
+
const [tick, setTick] = useState(0);
|
|
44
|
+
const batches = useBatches(tick);
|
|
45
|
+
const [trialId, setTrialId] = useState<string | null>(null);
|
|
46
|
+
const [newOpen, setNewOpen] = useState(false);
|
|
47
|
+
const [loadError, setLoadError] = useState(""); // the view's request
|
|
48
|
+
const [catalogError, setCatalogError] = useState(""); // the suites, tasks and configs requests: loaded once, so kept
|
|
49
|
+
const [regrade, setRegrade] = useState<RegradeStatus>({ running: null, last: null });
|
|
50
|
+
const [regradeError, setRegradeError] = useState("");
|
|
51
|
+
const wasRegrading = useRef(false);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const es = new EventSource("/api/events");
|
|
55
|
+
es.onmessage = () => setTick((t) => t + 1);
|
|
56
|
+
return () => es.close();
|
|
57
|
+
}, []);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
Promise.all([api<Suite[]>("/api/suites"), api<{ id: string; title: string }[]>("/api/tasks"), api<AgentConfig[]>("/api/configs")])
|
|
60
|
+
.then(([suites, tasks, configs]) => setCatalog({ suites, titles: Object.fromEntries(tasks.map((t) => [t.id, t.title])), configs }))
|
|
61
|
+
.catch((e: Error) => setCatalogError(e.message));
|
|
62
|
+
}, []);
|
|
63
|
+
// Every change to the query is a history entry; Back and Forward set the query from the URL again.
|
|
64
|
+
const search = viewQueryParams(query).toString();
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (urlError || page !== "results") return; // the query is the Results page's URL, not another page's
|
|
67
|
+
const here = new URLSearchParams(location.search).toString();
|
|
68
|
+
if (here === search) return;
|
|
69
|
+
let same = false;
|
|
70
|
+
try { same = viewQueryParams(parseViewQuery(new URLSearchParams(location.search))).toString() === search; } catch { /* a bad URL is replaced */ }
|
|
71
|
+
const next = `${location.pathname}${search ? `?${search}` : ""}`;
|
|
72
|
+
if (same) history.replaceState(null, "", next); else history.pushState(null, "", next);
|
|
73
|
+
}, [search, urlError, page]);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
// Back and Forward to another page leave the Results query as it was, so coming back keeps the filters.
|
|
76
|
+
const back = () => {
|
|
77
|
+
const p = pageOf(location.pathname);
|
|
78
|
+
setPage(p);
|
|
79
|
+
if (p === "results") { const r = readUrl(); setQuery(r.query); setUrlError(r.error); }
|
|
80
|
+
};
|
|
81
|
+
window.addEventListener("popstate", back);
|
|
82
|
+
return () => window.removeEventListener("popstate", back);
|
|
83
|
+
}, []);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (page !== "results") return; // loaded again on the way back to Results
|
|
86
|
+
let live = true;
|
|
87
|
+
api<Facets>(`/api/facets${search ? `?${search}` : ""}`)
|
|
88
|
+
.then((f) => { if (live) setFacets(f); })
|
|
89
|
+
.catch(() => { if (live) setFacets(null); }); // the view's own request reports a bad filter
|
|
90
|
+
return () => { live = false; };
|
|
91
|
+
}, [search, tick, page]);
|
|
92
|
+
// The server's regrade (one at a time, in its process), so a reload and a second tab see it. Read on each
|
|
93
|
+
// event, and every second while one runs: its end must show even when the last write came before it cleared.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
let live = true;
|
|
96
|
+
const read = () => api<RegradeStatus>("/api/regrade").then((r) => {
|
|
97
|
+
if (!live) return;
|
|
98
|
+
setRegrade(r);
|
|
99
|
+
if (wasRegrading.current && !r.running) setTick((t) => t + 1); // it ended: load the view again
|
|
100
|
+
wasRegrading.current = !!r.running;
|
|
101
|
+
}).catch(() => {});
|
|
102
|
+
read();
|
|
103
|
+
const timer = regrade.running ? setInterval(read, 1000) : undefined;
|
|
104
|
+
return () => { live = false; clearInterval(timer); };
|
|
105
|
+
}, [tick, !!regrade.running]);
|
|
106
|
+
useEffect(() => setRegradeError(""), [search]);
|
|
107
|
+
const startRegrade = () => {
|
|
108
|
+
setRegradeError("");
|
|
109
|
+
fetch(`/api/regrade${search ? `?${search}` : ""}`, { method: "POST" })
|
|
110
|
+
.then(async (r) => {
|
|
111
|
+
const body = await r.json().catch(() => null);
|
|
112
|
+
if (r.status === 409 && body?.running) setRegrade((was) => ({ ...was, running: body.running }));
|
|
113
|
+
else if (!r.ok) setRegradeError(body?.error ?? `${r.status} ${r.statusText}`);
|
|
114
|
+
setTick((t) => t + 1);
|
|
115
|
+
})
|
|
116
|
+
.catch((e: Error) => setRegradeError(e.message));
|
|
117
|
+
};
|
|
118
|
+
const url = viewUrl(query);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (page !== "results") return; // loaded again on the way back to Results, with the trials of any batch that ran meanwhile
|
|
121
|
+
let live = true; // a response for an earlier query must not replace the current one
|
|
122
|
+
const scope = query.scope ?? { kind: "index" as const };
|
|
123
|
+
api<View>(url)
|
|
124
|
+
.then((view) => { if (live) { setLoaded({ view, scope }); setLoadError(""); } })
|
|
125
|
+
.catch((e: Error) => { if (live) { setLoaded(null); setLoadError(e.message); } });
|
|
126
|
+
return () => { live = false; };
|
|
127
|
+
}, [url, tick, page]);
|
|
128
|
+
/** Opens another page as a history entry. Results gets its query back, so its filters survive the trip. */
|
|
129
|
+
const go = (to: PageId) => {
|
|
130
|
+
if (to === page) return;
|
|
131
|
+
history.pushState(null, "", hrefOf(to));
|
|
132
|
+
if (to === "results") setUrlError(""); // the pushed URL holds the query in state, not the unreadable one
|
|
133
|
+
setPage(to);
|
|
134
|
+
window.scrollTo(0, 0);
|
|
135
|
+
};
|
|
136
|
+
const hrefOf = (to: PageId) => (to === "results" ? `${pathOf(to)}${search ? `?${search}` : ""}` : pathOf(to));
|
|
137
|
+
|
|
138
|
+
const view = loaded?.view;
|
|
139
|
+
const trial = view?.trials.find((t) => t.id === trialId) ?? null;
|
|
140
|
+
return (
|
|
141
|
+
<div className="app">
|
|
142
|
+
<header className="topbar">
|
|
143
|
+
<div className="brand">Static Fire</div>
|
|
144
|
+
<nav className="nav" aria-label="Pages">
|
|
145
|
+
{PAGES.map((p) => (
|
|
146
|
+
<a key={p.id} href={hrefOf(p.id)} className={p.id === page ? "on" : undefined} aria-current={p.id === page ? "page" : undefined}
|
|
147
|
+
onClick={(e) => { if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); go(p.id); }}>{p.title}</a>
|
|
148
|
+
))}
|
|
149
|
+
</nav>
|
|
150
|
+
<div className="spacer" />
|
|
151
|
+
<RunStatusButton batches={batches} onOpen={() => go("runs")} />
|
|
152
|
+
</header>
|
|
153
|
+
|
|
154
|
+
{page === "runs" ? <RunsPage batches={batches} tick={tick} onNew={() => setNewOpen(true)} />
|
|
155
|
+
: page === "compare" ? <ComparePage tick={tick} titles={catalog?.titles ?? {}} />
|
|
156
|
+
: urlError ? <div className="empty card"><div className="kicker">This link has a filter the dashboard cannot read</div><p className="error" style={{ whiteSpace: "pre-wrap" }}>{urlError}</p><a className="btn" href={location.pathname}>Reset filters</a></div>
|
|
157
|
+
: catalogError ? <div className="empty card"><div className="kicker">Could not load the suites, tasks and configs</div><p className="error" style={{ whiteSpace: "pre-wrap" }}>{catalogError}</p><a className="btn" href={location.href}>Reload</a></div>
|
|
158
|
+
: loadError ? <div className="empty card"><div className="kicker">Could not load the view</div><p className="error" style={{ whiteSpace: "pre-wrap" }}>{loadError}</p></div>
|
|
159
|
+
: !loaded || !catalog ? <div className="empty card"><div className="kicker">Static Fire</div><h1>Loading…</h1></div>
|
|
160
|
+
: <Dashboard loaded={loaded} catalog={catalog} onScope={(scope) => setQuery((q) => withScope(q, scope, catalog, facets))} onTrial={setTrialId} onNew={() => go("runs")}
|
|
161
|
+
bar={<FilterBar query={query} view={loaded.view} facets={facets} titles={catalog.titles} onChange={(patch) => setQuery((q) => ({ ...q, ...patch }))} />}
|
|
162
|
+
notice={<LeftOutNotice view={loaded.view} scope={loaded.scope} titles={catalog.titles} search={search} regrade={regrade} error={regradeError}
|
|
163
|
+
onRegrade={startRegrade} onVersion={(grading) => setQuery((q) => ({ ...q, grading, judge: undefined }))} />} />}
|
|
164
|
+
{page === "results" && trial && view && <TrialDrawer view={view} trial={trial} tick={tick} onClose={() => setTrialId(null)} />}
|
|
165
|
+
{page === "runs" && newOpen && <NewRun onClose={() => setNewOpen(false)} onStarted={() => { setNewOpen(false); setTick((t) => t + 1); }} />}
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The query with a new scope. A picked grading version belongs to one task: it is kept only when the new scope
|
|
172
|
+
* still covers that task, because the view refuses a grading version outside its scope.
|
|
173
|
+
*/
|
|
174
|
+
function withScope(q: ViewQuery, scope: Scope, catalog: Catalog, facets: Facets | null): ViewQuery {
|
|
175
|
+
if (!q.grading) return { ...q, scope };
|
|
176
|
+
const taskId = facets?.grading.find((g) => g.setups.some((s) => s.hash.startsWith(q.grading!)))?.taskId;
|
|
177
|
+
const covered = scope.kind === "task" ? [scope.id]
|
|
178
|
+
: catalog.suites.filter((s) => scope.kind === "index" || s.id === scope.id).flatMap((s) => s.tasks);
|
|
179
|
+
return taskId && covered.includes(taskId) ? { ...q, scope } : { ...q, scope, grading: undefined };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The filters besides scope (the chips): one Filter button that opens a searchable menu of collapsed sections,
|
|
184
|
+
* and a tag per active filter, one per picked value of a config facet (the data is web/filter-menu.ts). Each value shows how many trials the view would
|
|
185
|
+
* list with it. Every filter is a field of the query, so of the URL.
|
|
186
|
+
*/
|
|
187
|
+
function FilterBar({ query, view, facets, titles, onChange }: {
|
|
188
|
+
query: ViewQuery; view: View; facets: Facets | null; titles: Record<string, string>; onChange: (patch: Partial<ViewQuery>) => void;
|
|
189
|
+
}) {
|
|
190
|
+
const [open, setOpen] = useState(false);
|
|
191
|
+
const [text, setText] = useState("");
|
|
192
|
+
const [expanded, setExpanded] = useState<Set<SectionId>>(new Set());
|
|
193
|
+
const root = useRef<HTMLDivElement>(null), button = useRef<HTMLButtonElement>(null), searchBox = useRef<HTMLInputElement>(null);
|
|
194
|
+
const sections = menuSections({ facets, query, judge: view.filters.judge, titles });
|
|
195
|
+
const tags = filterTags(sections);
|
|
196
|
+
const found = text.trim() ? searchMenu(sections, text) : null;
|
|
197
|
+
// Open: the search box takes focus. A click outside the menu or Escape closes it.
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (!open) return;
|
|
200
|
+
searchBox.current?.focus();
|
|
201
|
+
const away = (e: MouseEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); };
|
|
202
|
+
const esc = (e: KeyboardEvent) => { if (e.key === "Escape") { setOpen(false); button.current?.focus(); } };
|
|
203
|
+
document.addEventListener("mousedown", away);
|
|
204
|
+
document.addEventListener("keydown", esc);
|
|
205
|
+
return () => { document.removeEventListener("mousedown", away); document.removeEventListener("keydown", esc); };
|
|
206
|
+
}, [open]);
|
|
207
|
+
// Each opening starts with no search text and every section collapsed.
|
|
208
|
+
const toggleMenu = () => { if (!open) { setText(""); setExpanded(new Set()); } setOpen(!open); };
|
|
209
|
+
const toggleSection = (id: SectionId) => setExpanded((was) => { const next = new Set(was); if (!next.delete(id)) next.add(id); return next; });
|
|
210
|
+
|
|
211
|
+
const option = (v: MenuValue, labelled: boolean) => {
|
|
212
|
+
const detail = [labelled ? v.group : undefined, v.detail].filter(Boolean).join(" · ");
|
|
213
|
+
return (
|
|
214
|
+
<button key={`${v.section}:${v.key}`} type="button" className={`opt${v.picked ? " on" : ""}`} aria-pressed={v.picked} title={v.title} onClick={() => onChange(pick(v))}>
|
|
215
|
+
<span>{labelled && <em>{v.sectionName}: </em>}{v.label}{v.picked && " ✓"}{detail && <small>{detail}</small>}</span>
|
|
216
|
+
<span className="opt-count" title={`${v.count} trial${v.count === 1 ? "" : "s"}`}>{v.count}</span>
|
|
217
|
+
</button>
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
// An open section's values; a grading version under its task's heading when more than one task is in scope.
|
|
221
|
+
const values = (list: MenuValue[]) => list.map((v, i) => (
|
|
222
|
+
<Fragment key={`${v.section}:${v.key}`}>
|
|
223
|
+
{v.group && v.group !== list[i - 1]?.group && <div className="grp">{v.group}</div>}
|
|
224
|
+
{option(v, false)}
|
|
225
|
+
</Fragment>
|
|
226
|
+
));
|
|
227
|
+
const day = (iso: string | null) => iso?.slice(0, 10);
|
|
228
|
+
const dates = (
|
|
229
|
+
<div className="menu-dates">
|
|
230
|
+
<label>From<input type="date" aria-label="From" value={(query.since ?? "").slice(0, 10)} min={day(facets?.dates.first ?? null)} max={day(facets?.dates.last ?? null)} onChange={(e) => onChange({ since: e.target.value || undefined })} /></label>
|
|
231
|
+
<label>To<input type="date" aria-label="To" value={(query.until ?? "").slice(0, 10)} min={day(facets?.dates.first ?? null)} max={day(facets?.dates.last ?? null)} onChange={(e) => onChange({ until: e.target.value || undefined })} /></label>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
return (
|
|
235
|
+
<div className="filter-bar" aria-label="Filters">
|
|
236
|
+
<div className="pop" ref={root}>
|
|
237
|
+
<button ref={button} type="button" className={`btn filter-btn${open ? " on" : ""}`} aria-expanded={open} aria-haspopup="dialog" onClick={toggleMenu}>
|
|
238
|
+
<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><path d="M1 3h14M4 8h8M6.5 13h3" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" fill="none" /></svg>
|
|
239
|
+
Filter
|
|
240
|
+
</button>
|
|
241
|
+
{open && (
|
|
242
|
+
<div className="menu" role="dialog" aria-label="Filter">
|
|
243
|
+
<input ref={searchBox} type="search" aria-label="Search filters" placeholder="Search all filters, for example opus" value={text} onChange={(e) => setText(e.target.value)} />
|
|
244
|
+
<div className="menu-list">
|
|
245
|
+
{found ? (
|
|
246
|
+
<>
|
|
247
|
+
{found.values.map((v) => option(v, true))}
|
|
248
|
+
{found.dates && <div className="menu-flat-dates"><em>{SECTION_NAMES.dates}</em>{dates}</div>}
|
|
249
|
+
{found.values.length === 0 && !found.dates && <p className="muted menu-none">No filter matches.</p>}
|
|
250
|
+
</>
|
|
251
|
+
) : sections.map((s) => {
|
|
252
|
+
const on = expanded.has(s.id);
|
|
253
|
+
return (
|
|
254
|
+
<div key={s.id} className="menu-sec">
|
|
255
|
+
<button type="button" className="sec" aria-expanded={on} onClick={() => toggleSection(s.id)}>
|
|
256
|
+
<span><i aria-hidden="true">{on ? "▾" : "▸"}</i>{s.name}</span>
|
|
257
|
+
<span className={s.picked ? "sec-on" : undefined}>{s.summary}</span>
|
|
258
|
+
</button>
|
|
259
|
+
{on && <div className="in">{s.id === "dates" ? dates : values(s.values)}</div>}
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
})}
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
{tags.map((t) => (
|
|
268
|
+
<span key={t.key} className={`tag${t.section === "grading" ? " grading" : ""}`}>
|
|
269
|
+
<em>{t.name}:</em> {t.value}
|
|
270
|
+
<button type="button" aria-label={`Remove filter ${t.text}`} title="Remove this filter" onClick={() => onChange(t.remove)}>×</button>
|
|
271
|
+
</span>
|
|
272
|
+
))}
|
|
273
|
+
{tags.length > 0 && <button type="button" className="btn clear-all" onClick={() => onChange(CLEAR_ALL)}>Clear all</button>}
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The task ids a scope covers in this view. */
|
|
279
|
+
const tasksOf = (view: View, sel: Scope, suites: Map<string, Suite>) =>
|
|
280
|
+
sel.kind === "task" ? [sel.id] : sel.kind === "suite" ? suites.get(sel.id)?.tasks ?? [] : Object.keys(view.tasks);
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* What the selected scope says about one config: a table row. Score and cost are null when the scope has
|
|
284
|
+
* none. The chart point is this same row (pointFor), so the chart and the table never disagree.
|
|
285
|
+
*/
|
|
286
|
+
function rowFor(view: View, s: ViewConfigSummary, sel: Scope, suites: Map<string, Suite>): Row {
|
|
287
|
+
const tasks = tasksOf(view, sel, suites);
|
|
288
|
+
const stat = sel.kind === "index" ? s.index : sel.kind === "suite" ? s.suites[sel.id] : s.tasks[sel.id]?.score;
|
|
289
|
+
const per = tasks.map((t) => s.tasks[t]).filter((t) => t && t.valid > 0);
|
|
290
|
+
const costs = per.map((t) => t.costUsd).filter((c): c is number => c != null);
|
|
291
|
+
const times = per.map((t) => t.durationMs).filter((x): x is number => x != null);
|
|
292
|
+
const pooled = tasks.map((t) => s.pooled[t]).filter((p) => p && p.n > 0);
|
|
293
|
+
return {
|
|
294
|
+
s, score: stat?.mean ?? null, ci: stat?.ci ?? null,
|
|
295
|
+
cost: costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : null,
|
|
296
|
+
n: pooled.reduce((a, p) => a + p.n, 0),
|
|
297
|
+
from: pooled.map((p) => p.from!).sort()[0] ?? null,
|
|
298
|
+
to: pooled.map((p) => p.to!).sort().at(-1) ?? null,
|
|
299
|
+
costSource: per.some((t) => t.costSource === "estimated") ? "estimated" : "reported",
|
|
300
|
+
durationMs: times.length ? times.reduce((a, b) => a + b, 0) / times.length : null,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The chart point of one config: its row, when it has both a score and a cost. */
|
|
305
|
+
function pointFor(view: View, s: ViewConfigSummary, sel: Scope, suites: Map<string, Suite>): Point | null {
|
|
306
|
+
const r = rowFor(view, s, sel, suites);
|
|
307
|
+
return r.score == null || r.cost == null ? null : { ...r, score: r.score, cost: r.cost };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Why a config with trials in view has no point: a suite it does not cover in full. */
|
|
311
|
+
function partialText(s: ViewConfigSummary, sel: Scope, suites: Map<string, Suite>): string | null {
|
|
312
|
+
const ids = sel.kind === "suite" ? [sel.id] : sel.kind === "index" ? Object.keys(s.partial) : [];
|
|
313
|
+
const parts = ids.flatMap((id) => {
|
|
314
|
+
const p = s.partial[id];
|
|
315
|
+
return p ? [`${sel.kind === "index" ? `${suites.get(id)?.title ?? id} ` : ""}partial (${p.have} of ${p.of} tasks)`] : [];
|
|
316
|
+
});
|
|
317
|
+
return parts.length ? parts.join(", ") : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const sameScope = (a: Scope, b: Scope) => a.kind === b.kind && (a.kind === "index" || (b.kind !== "index" && a.id === b.id));
|
|
321
|
+
|
|
322
|
+
function Dashboard({ loaded, catalog, onScope, onTrial, onNew, bar, notice }: { loaded: Loaded; catalog: Catalog; onScope: (s: Scope) => void; onTrial: (id: string) => void; onNew: () => void; bar?: ReactNode; notice?: ReactNode }) {
|
|
323
|
+
const { view, scope: sel } = loaded;
|
|
324
|
+
const { trials, summary } = view;
|
|
325
|
+
const suites = new Map(catalog.suites.map((s) => [s.id, s]));
|
|
326
|
+
const taskIds = Object.keys(view.tasks);
|
|
327
|
+
const allTasks = [...new Set(catalog.suites.flatMap((s) => s.tasks))];
|
|
328
|
+
const taskTitle = (id: string) => view.tasks[id]?.def.title ?? catalog.titles[id] ?? id;
|
|
329
|
+
const valid = trials.filter((t) => t.status === "done" && t.grades);
|
|
330
|
+
const starts = valid.map((t) => t.startedAt).filter((x): x is string => !!x).sort();
|
|
331
|
+
|
|
332
|
+
const title = sel.kind === "index" ? "Workflow Index" : sel.kind === "suite" ? `${suites.get(sel.id)?.title ?? sel.id} Index` : taskTitle(sel.id);
|
|
333
|
+
const agents = summary.configs.filter((s) => s.harness !== "human");
|
|
334
|
+
const points = agents.map((s) => pointFor(view, s, sel, suites)).filter((p): p is Point => p != null);
|
|
335
|
+
const humans: HumanLine[] = summary.configs.filter((s) => s.harness === "human").flatMap((s) => {
|
|
336
|
+
const stat = sel.kind === "index" ? s.index : sel.kind === "suite" ? s.suites[sel.id] : s.tasks[sel.id]?.score;
|
|
337
|
+
return stat?.mean != null ? [{ s, score: stat.mean }] : [];
|
|
338
|
+
});
|
|
339
|
+
// Every config in view the chart draws neither as a point nor as a human line, agents and humans alike.
|
|
340
|
+
const unplotted = summary.configs.filter((s) => !points.some((p) => p.s.configId === s.configId) && !humans.some((h) => h.s.configId === s.configId));
|
|
341
|
+
|
|
342
|
+
const pickConfig = (configId: string) => {
|
|
343
|
+
const t = trials.filter((t) => t.configId === configId && t.status === "done" && (sel.kind !== "task" || t.taskId === sel.id))
|
|
344
|
+
.sort((a, b) => (b.grades?.score ?? 0) - (a.grades?.score ?? 0))[0];
|
|
345
|
+
if (t) onTrial(t.id);
|
|
346
|
+
};
|
|
347
|
+
const harnesses = HARNESS_ORDER.filter((h) => h !== "human" && agents.some((s) => s.harness === h));
|
|
348
|
+
const families = [...new Set(agents.map((s) => s.family))].sort((a, b) => familySlot(a) - familySlot(b));
|
|
349
|
+
const one = sel.kind === "task" ? view.tasks[sel.id] : null;
|
|
350
|
+
const [shown, setShown] = useState<"chart" | "table">("chart");
|
|
351
|
+
// The table: every config the chart draws (points and human lines, by score), then the ones it cannot draw.
|
|
352
|
+
const tableRows: Row[] = [
|
|
353
|
+
...[...points, ...summary.configs.filter((s) => humans.some((h) => h.s.configId === s.configId)).map((s) => rowFor(view, s, sel, suites))]
|
|
354
|
+
.sort((a, b) => b.score! - a.score!),
|
|
355
|
+
...unplotted.map((s) => { const r = rowFor(view, s, sel, suites); return { ...r, reason: partialText(s, sel, suites) ?? (r.score == null ? "no valid score" : "no cost data") }; }),
|
|
356
|
+
];
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<>
|
|
360
|
+
<section className="hero">
|
|
361
|
+
<div className="kicker">View</div>
|
|
362
|
+
<h1>{sel.kind === "task" ? taskTitle(sel.id) : view.filters.suiteIds.map((s) => suites.get(s)?.title ?? s).join(" + ")}</h1>
|
|
363
|
+
<div className="meta">
|
|
364
|
+
<span><b>{taskIds.length}</b> task{taskIds.length === 1 ? "" : "s"} × <b>{summary.configs.length}</b> config{summary.configs.length === 1 ? "" : "s"} · <b>{valid.length}</b> valid trial{valid.length === 1 ? "" : "s"}</span>
|
|
365
|
+
<span>Judge <b>{view.filters.judge.label}</b></span>
|
|
366
|
+
{starts.length > 0 && <span>Ran {dateRange(starts[0]!, starts.at(-1)!)}</span>}
|
|
367
|
+
{one && <span><VersionLabel label={one.label} hash={one.taskHash} /></span>}
|
|
368
|
+
</div>
|
|
369
|
+
{suiteNotes(sel, view.filters.suiteIds, suites).map((n) => <p key={n.title} className="note">{sel.kind !== "suite" && <b>{n.title}: </b>}{n.description}</p>)}
|
|
370
|
+
</section>
|
|
371
|
+
|
|
372
|
+
<div className="chips" role="tablist" aria-label="Score shown">
|
|
373
|
+
<button className={`chip ${sel.kind === "index" ? "on" : ""}`} onClick={() => onScope({ kind: "index" })}>Workflow Index</button>
|
|
374
|
+
{catalog.suites.map((s) => <button key={s.id} className={`chip ${sameScope(sel, { kind: "suite", id: s.id }) ? "on" : ""}`} onClick={() => onScope({ kind: "suite", id: s.id })}>{s.title} Index</button>)}
|
|
375
|
+
{allTasks.map((id) => <button key={id} className={`chip ${sameScope(sel, { kind: "task", id }) ? "on" : ""}`} onClick={() => onScope({ kind: "task", id })}>{taskTitle(id)}</button>)}
|
|
376
|
+
</div>
|
|
377
|
+
{bar}
|
|
378
|
+
|
|
379
|
+
{trials.length === 0 ? (
|
|
380
|
+
<div className="empty card">
|
|
381
|
+
<div className="kicker">{title}</div>
|
|
382
|
+
<h1>No trials in this view</h1>
|
|
383
|
+
<p>Run agent configs on these tasks and each trial shows here as soon as it is graded.</p>
|
|
384
|
+
<button className="btn btn-primary" onClick={onNew}>New run</button>
|
|
385
|
+
{notice}
|
|
386
|
+
</div>
|
|
387
|
+
) : <>
|
|
388
|
+
<section className="card">
|
|
389
|
+
<div className="card-head">
|
|
390
|
+
<h2>{title} vs. cost per task</h2>
|
|
391
|
+
<div className="view-toggle" role="group" aria-label="Show as">
|
|
392
|
+
<button className={shown === "chart" ? "on" : ""} aria-pressed={shown === "chart"} onClick={() => setShown("chart")}>Chart</button>
|
|
393
|
+
<button className={shown === "table" ? "on" : ""} aria-pressed={shown === "table"} onClick={() => setShown("table")}>Table</button>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
<p className="sub">{shown === "chart"
|
|
397
|
+
? "Each point is one agent config: its mean over every comparable valid trial, with a 95% confidence interval. Click a point to read its best output."
|
|
398
|
+
: "One row per config in the view, pooled over every comparable valid trial. The chart and the table show the same configs."}</p>
|
|
399
|
+
{shown === "chart" ? <>
|
|
400
|
+
<div className="legend">
|
|
401
|
+
{harnesses.map((h) => <span key={h}><Marker s={{ harness: h, profile: "x", family: "_" }} neutral />{HARNESS_NAME[h]}</span>)}
|
|
402
|
+
<span className="sep" />
|
|
403
|
+
{families.map((f) => <span key={f}><span className="swatch" style={{ background: `var(--dataviz-${familySlot(f)})` }} />{familyName(f)}</span>)}
|
|
404
|
+
<span className="sep" />
|
|
405
|
+
<span><Marker s={{ harness: "claude", profile: undefined, family: "_" }} neutral />no Profile (user setup only)</span>
|
|
406
|
+
<span><span className="quad-key" />Most attractive quadrant</span>
|
|
407
|
+
<span><span className="pareto-key" />Pareto frontier</span>
|
|
408
|
+
{humans.length > 0 && <span><span className="human-key" />User (by hand): your score, no agent cost</span>}
|
|
409
|
+
</div>
|
|
410
|
+
<FrontierChart points={points} yTitle={title} humans={humans} onPick={pickConfig} />
|
|
411
|
+
</> : <ConfigTable rows={tableRows} onPick={pickConfig} />}
|
|
412
|
+
{notice}
|
|
413
|
+
{shown === "chart" && unplotted.length > 0 && <p className="note">Not plotted (no valid score for every task in this view, or no cost data): {unplotted.map((s) => { const why = partialText(s, sel, suites); return why ? `${s.label}: ${why}` : s.label; }).join("; ")}.</p>}
|
|
414
|
+
<p className="note">Costs are API-equivalent. Claude Code and Pi report their own; Codex and Antigravity are estimated from tokens × <code>pricing.yaml</code>. On a flat subscription you pay in usage limits, not dollars.</p>
|
|
415
|
+
</section>
|
|
416
|
+
|
|
417
|
+
<div className="grid3">
|
|
418
|
+
<section className="card">
|
|
419
|
+
<h3>{title}</h3>
|
|
420
|
+
<p className="sub">Mean of valid trials · higher is better</p>
|
|
421
|
+
<BarList rows={summary.configs.map((s) => ({ s, v: s.harness === "human" ? humans.find((h) => h.s.configId === s.configId)?.score ?? null : pointFor(view, s, sel, suites)?.score ?? null }))} fmt={(v) => v.toFixed(1)} />
|
|
422
|
+
</section>
|
|
423
|
+
<section className="card">
|
|
424
|
+
<h3>Cost per task</h3>
|
|
425
|
+
<p className="sub">API-equivalent USD · lower is better</p>
|
|
426
|
+
<BarList rows={agents.map((s) => ({ s, v: pointFor(view, s, sel, suites)?.cost ?? null }))} fmt={money} lowerIsBetter />
|
|
427
|
+
</section>
|
|
428
|
+
<section className="card">
|
|
429
|
+
<h3>Time per task</h3>
|
|
430
|
+
<p className="sub">Agent wall-clock · lower is better</p>
|
|
431
|
+
<BarList rows={agents.map((s) => ({ s, v: pointFor(view, s, sel, suites)?.durationMs ?? null }))} fmt={secs} lowerIsBetter />
|
|
432
|
+
</section>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
{taskIds.length > 1 && <Heatmap view={view} suites={suites} />}
|
|
436
|
+
{(sel.kind === "task" ? [sel.id] : taskIds).map((t) => <TaskBreakdown key={t} view={view} taskId={t} configs={catalog.configs} />)}
|
|
437
|
+
<TrialsCard view={view} onTrial={onTrial} />
|
|
438
|
+
</>}
|
|
439
|
+
</>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** GET /api/regrade: the regrade the server is running, and the last one that ended there. */
|
|
444
|
+
interface RegradeStatus { running: RegradeRun | null; last: RegradeEnded | null }
|
|
445
|
+
|
|
446
|
+
const trialsFrom = (c: { trials: number; configs: number }) =>
|
|
447
|
+
`${c.trials} trial${c.trials === 1 ? "" : "s"} from ${c.configs} config${c.configs === 1 ? "" : "s"}`;
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Under the chart: the trials in scope the view left out, and the way to bring them in. Trials graded under
|
|
451
|
+
* another grading hash, or never graded, come back with a regrade (judge calls only; POST /api/regrade with the
|
|
452
|
+
* view's query). Trials on another task hash did another job: the link switches the view to their version by
|
|
453
|
+
* setting the grading version filter, and reads by that grading version's name. Renders nothing when no trial was left out.
|
|
454
|
+
*/
|
|
455
|
+
function LeftOutNotice({ view, scope, titles, search, regrade, error, onRegrade, onVersion }: {
|
|
456
|
+
view: View; scope: Scope; titles: Record<string, string>; search: string; regrade: RegradeStatus; error: string;
|
|
457
|
+
onRegrade: () => void; onVersion: (gradingHash: string) => void;
|
|
458
|
+
}) {
|
|
459
|
+
const { otherGrading, ungraded, otherTask } = view.leftOut;
|
|
460
|
+
if (!otherGrading.trials && !ungraded.trials && !otherTask.trials) return null;
|
|
461
|
+
const current = Object.values(view.tasks).every((t) => t.current);
|
|
462
|
+
const mine = regrade.running?.query === search;
|
|
463
|
+
const lastError = !regrade.running && regrade.last?.error && regrade.last.query === search ? regrade.last.error : "";
|
|
464
|
+
// The grading version each task version's link opens: picked by the view (linkTarget).
|
|
465
|
+
const setupFor = (v: OtherVersion) => v.gradingSetups.find((g) => g.hash === v.link);
|
|
466
|
+
const byTask = new Map<string, typeof otherTask.versions>();
|
|
467
|
+
for (const v of otherTask.versions) byTask.set(v.taskId, [...(byTask.get(v.taskId) ?? []), v]);
|
|
468
|
+
const regradeButton = current && (otherGrading.trials > 0 || ungraded.trials > 0) && (
|
|
469
|
+
<button className="btn btn-quiet" disabled={!!regrade.running} onClick={onRegrade}>
|
|
470
|
+
{mine ? `Regrading… ${regrade.running!.graded} of ${regrade.running!.total}` : regrade.running ? "Another regrade is running" : "Regrade (judge calls only)"}
|
|
471
|
+
</button>
|
|
472
|
+
);
|
|
473
|
+
return (
|
|
474
|
+
<div className="left-out" role="status">
|
|
475
|
+
{otherGrading.trials > 0 && <p className="note">{trialsFrom(otherGrading)} {otherGrading.trials === 1 ? "was" : "were"} graded under {current ? "earlier" : "other"} criteria.</p>}
|
|
476
|
+
{ungraded.trials > 0 && <p className="note">{trialsFrom(ungraded)} {ungraded.trials === 1 ? "has" : "have"} no grading.</p>}
|
|
477
|
+
{regradeButton}
|
|
478
|
+
{(error || lastError) && <p className="error">The regrade did not finish: {error || lastError}</p>}
|
|
479
|
+
{[...byTask].map(([taskId, versions]) => {
|
|
480
|
+
const n = versions.reduce((a, v) => a + v.trials, 0);
|
|
481
|
+
const which = view.tasks[taskId]?.current ? "an earlier" : "another";
|
|
482
|
+
const what = scope.kind === "task" ? "this task" : titles[taskId] ?? taskId;
|
|
483
|
+
const links = versions.flatMap((v) => { const g = setupFor(v); return g ? [{ v, g }] : []; });
|
|
484
|
+
const tip = (v: OtherVersion, hash: string) => `Task hash ${v.taskHash} · grading hash ${hash}`;
|
|
485
|
+
return (
|
|
486
|
+
<p className="note" key={taskId}>
|
|
487
|
+
{n} trial{n === 1 ? "" : "s"} ran on {which} version of {what}.{" "}
|
|
488
|
+
{links.length === 1 ? <button className="linkish" title={tip(links[0]!.v, links[0]!.g.hash)} onClick={() => onVersion(links[0]!.g.hash)}>Switch to that version: {links[0]!.g.name}</button>
|
|
489
|
+
: links.map(({ v, g }, i) => <span key={v.taskHash}>{i > 0 && " · "}<button className="linkish" title={tip(v, g.hash)} onClick={() => onVersion(g.hash)}>{g.name} · {v.label != null ? `v${v.label}` : `task ${short(v.taskHash)}`} ({v.trials} trial{v.trials === 1 ? "" : "s"})</button></span>)}
|
|
490
|
+
</p>
|
|
491
|
+
);
|
|
492
|
+
})}
|
|
493
|
+
</div>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* A task version: its readable label next to its short task hash (the hash is what decides comparability). A task
|
|
499
|
+
* file with no `version` has no label: its short task hash stands alone.
|
|
500
|
+
*/
|
|
501
|
+
const VersionLabel = ({ label, hash }: { label: number | null; hash: string }) =>
|
|
502
|
+
<span className="version" title={`Task hash ${hash}`}>{label != null && <>v{label} · </>}task <code>{short(hash)}</code></span>;
|
|
503
|
+
|
|
504
|
+
const heat = (v?: number | null) => (v == null ? {} : { background: `color-mix(in srgb, var(--dataviz-7) ${Math.round(6 + (v / 100) ** 1.5 * 64)}%, var(--color-white))` });
|
|
505
|
+
const sortedConfigs = (view: View, key: (s: ViewConfigSummary) => number | null) => [...view.summary.configs].sort((a, b) => (key(b) ?? -1) - (key(a) ?? -1));
|
|
506
|
+
|
|
507
|
+
function Heatmap({ view, suites }: { view: View; suites: Map<string, Suite> }) {
|
|
508
|
+
const suiteIds = view.filters.suiteIds.filter((id) => suites.has(id));
|
|
509
|
+
const title = (t: string) => view.tasks[t]?.def.title ?? t;
|
|
510
|
+
return (
|
|
511
|
+
<section className="card">
|
|
512
|
+
<h2>Where each config is strong</h2>
|
|
513
|
+
<p className="sub">Score per task, Suite Index and Workflow Index (0–100). A blank cell means no valid trials; a config without a score on every task of a suite is partial there.</p>
|
|
514
|
+
<div className="table-wrap"><table>
|
|
515
|
+
<thead><tr><th>Configuration</th>
|
|
516
|
+
{suiteIds.flatMap((sid) => [...suites.get(sid)!.tasks.map((t) => <th key={`${sid}-${t}`}>{title(t)}</th>), <th key={sid} className="strong">{suites.get(sid)!.title} Index</th>])}
|
|
517
|
+
<th className="strong">Workflow Index</th></tr></thead>
|
|
518
|
+
<tbody>{sortedConfigs(view, (s) => s.index.mean).map((s) => (
|
|
519
|
+
<tr key={s.configId}>
|
|
520
|
+
<td className="cfg"><Marker s={s} />{s.label}<ConditionsNote c={s.conditions} /></td>
|
|
521
|
+
{suiteIds.flatMap((sid) => {
|
|
522
|
+
const p = s.partial[sid];
|
|
523
|
+
return [...suites.get(sid)!.tasks.map((t) => <td key={`${sid}-${t}`} style={heat(s.tasks[t]?.score.mean)}>{pct(s.tasks[t]?.score.mean)}</td>),
|
|
524
|
+
<td key={sid} className={`strong${p ? " muted" : ""}`} style={heat(s.suites[sid]?.mean)}>{p ? `partial (${p.have} of ${p.of} tasks)` : pct(s.suites[sid]?.mean)}</td>];
|
|
525
|
+
})}
|
|
526
|
+
<td className="strong" style={heat(s.index.mean)}>{pct(s.index.mean, 1)}</td>
|
|
527
|
+
</tr>
|
|
528
|
+
))}</tbody>
|
|
529
|
+
</table></div>
|
|
530
|
+
</section>
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
type OtherVersion = View["leftOut"]["otherTask"]["versions"][number];
|
|
535
|
+
|
|
536
|
+
const LEVEL_TEXT: Record<string, string> = { floor: "Floor", strong: "Strong", ceiling: "Ceiling" };
|
|
537
|
+
|
|
538
|
+
function TaskBreakdown({ view, taskId, configs }: { view: View; taskId: string; configs: AgentConfig[] }) {
|
|
539
|
+
const vt = view.tasks[taskId];
|
|
540
|
+
const task = vt.def;
|
|
541
|
+
const [open, setOpen] = useState(false);
|
|
542
|
+
const inView = new Set(view.summary.configs.map((s) => s.configKey));
|
|
543
|
+
// A human config's note on its file, from the config file (its config key is human:<id>).
|
|
544
|
+
const notes = configs.flatMap((c) => (c.harness === "human" && inView.has(`human:${c.id}`) && c.outputs?.[taskId]?.note ? [{ id: c.id, label: configLabel(c), note: c.outputs[taskId].note! }] : []));
|
|
545
|
+
const rows = sortedConfigs(view, (s) => s.tasks[taskId]?.score.mean ?? null).filter((s) => s.tasks[taskId]?.valid > 0);
|
|
546
|
+
const dead = new Set(view.summary.deadCriteria[taskId] ?? []);
|
|
547
|
+
return (
|
|
548
|
+
<section className="card">
|
|
549
|
+
<div className="kicker">{task.title} · <VersionLabel label={vt.label} hash={vt.taskHash} /></div>
|
|
550
|
+
<h2>Where the points come from</h2>
|
|
551
|
+
<p className="sub">Measure score = % of its criteria passed (judge criteria count their share of passing votes). The task score is the mean of the measures.</p>
|
|
552
|
+
<div className="table-wrap"><table>
|
|
553
|
+
<thead><tr><th>Configuration</th>{task.measures.map((m) => <th key={m.id}>{m.label}</th>)}<th className="strong">Task score</th><th>Valid</th></tr></thead>
|
|
554
|
+
<tbody>
|
|
555
|
+
{rows.map((s) => {
|
|
556
|
+
const t = s.tasks[taskId];
|
|
557
|
+
return (
|
|
558
|
+
<tr key={s.configId}>
|
|
559
|
+
<td className="cfg"><Marker s={s} />{s.label}</td>
|
|
560
|
+
{task.measures.map((m) => <td key={m.id} style={heat(t.measures[m.id])}>{pct(t.measures[m.id])}</td>)}
|
|
561
|
+
<td className="strong">{pct(t.score.mean, 1)}{t.score.ci != null && <span className="ci"> ±{t.score.ci.toFixed(0)}</span>}</td>
|
|
562
|
+
<td className="muted">{t.valid}/{t.total}</td>
|
|
563
|
+
</tr>
|
|
564
|
+
);
|
|
565
|
+
})}
|
|
566
|
+
</tbody>
|
|
567
|
+
</table></div>
|
|
568
|
+
{notes.map((n) => <p key={n.id} className="note">{n.label}: {n.note}</p>)}
|
|
569
|
+
|
|
570
|
+
<button className="btn btn-quiet" onClick={() => setOpen((o) => !o)} aria-expanded={open}>{open ? "Hide" : "Show"} every criterion</button>
|
|
571
|
+
{open && (
|
|
572
|
+
<div className="table-wrap"><table className="criteria">
|
|
573
|
+
<thead><tr><th>Criterion</th><th>Level</th><th>Grader</th>{rows.map((s) => <th key={s.configId} title={s.label}><Marker s={s} size={12} /></th>)}</tr></thead>
|
|
574
|
+
<tbody>
|
|
575
|
+
{[{ id: "gates", label: "Gates", criteria: task.gates }, ...task.measures].filter((m) => m.criteria.length).map((m) => [
|
|
576
|
+
<tr key={m.id} className="group"><td colSpan={3 + rows.length}>{m.label}</td></tr>,
|
|
577
|
+
...m.criteria.map((c) => (
|
|
578
|
+
<tr key={c.id}>
|
|
579
|
+
<td className="q" title={c.question}>{c.id}{dead.has(c.id) && <span className="tag warn" title="Every config passed or every config failed: no signal">dead</span>}</td>
|
|
580
|
+
<td><span className={`tag ${c.level}`}>{LEVEL_TEXT[c.level]}</span></td>
|
|
581
|
+
<td className="muted">{c.grader}</td>
|
|
582
|
+
{rows.map((s) => { const r = s.tasks[taskId].criteria[c.id]; return <td key={s.configId} style={heat(r == null ? null : r * 100)}>{r == null ? "—" : Math.round(r * 100)}</td>; })}
|
|
583
|
+
</tr>
|
|
584
|
+
)),
|
|
585
|
+
])}
|
|
586
|
+
</tbody>
|
|
587
|
+
</table></div>
|
|
588
|
+
)}
|
|
589
|
+
</section>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const STATUS_TEXT: Record<Trial["status"], string> = { queued: "Queued", running: "Working…", grading: "Grading…", done: "", invalid: "Invalid", failed: "Failed" };
|
|
594
|
+
|
|
595
|
+
function TrialsCard({ view, onTrial }: { view: View; onTrial: (id: string) => void }) {
|
|
596
|
+
const { trials } = view;
|
|
597
|
+
const taskIds = Object.keys(view.tasks);
|
|
598
|
+
return (
|
|
599
|
+
<section className="card">
|
|
600
|
+
<h2>Trials</h2>
|
|
601
|
+
<p className="sub">Every trial in this view, from every batch. Click a finished trial to read each criterion's verdict. Invalid = a grader error (re-grade it); failed = the harness could not run.</p>
|
|
602
|
+
{taskIds.map((tid) => (
|
|
603
|
+
<div key={tid} className="trial-task">
|
|
604
|
+
{taskIds.length > 1 && <div className="kicker">{view.tasks[tid].def.title}</div>}
|
|
605
|
+
<div className="trial-rows">
|
|
606
|
+
{view.summary.configs.filter((s) => trials.some((t) => t.configKey === s.configKey && t.taskId === tid)).map((s) => (
|
|
607
|
+
<div className="trial-row" key={s.configKey}>
|
|
608
|
+
<div className="cfg"><Marker s={s} />{s.label}<ConditionsNote c={s.pooled[tid]} /></div>
|
|
609
|
+
<div className="trial-cells">
|
|
610
|
+
{trials.filter((t) => t.configKey === s.configKey && t.taskId === tid).map((t) => (
|
|
611
|
+
<button key={t.id} className={`cell ${t.status}`} onClick={() => onTrial(t.id)} title={t.error ?? `Batch ${t.batchId}`}>
|
|
612
|
+
<span className="k">Trial {t.index}</span>
|
|
613
|
+
<span className="v">{t.status === "done" ? t.grades!.score.toFixed(1) : STATUS_TEXT[t.status]}</span>
|
|
614
|
+
{t.status === "done" && <span className="k">{s.harness === "human" ? "by hand" : <>{money(t.result?.costUsd)} · {secs(t.durationMs)}</>}</span>}
|
|
615
|
+
</button>
|
|
616
|
+
))}
|
|
617
|
+
</div>
|
|
618
|
+
</div>
|
|
619
|
+
))}
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
))}
|
|
623
|
+
</section>
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* When a config's pooled trials ran under more than one user setup or Profile version, e.g. "2 setups, 2 Profile
|
|
629
|
+
* versions". Information, not a warning: run conditions never decide comparability (DEC-3).
|
|
630
|
+
*/
|
|
631
|
+
function conditionsText(c: { setups: number; profiles: number } | undefined): string | null {
|
|
632
|
+
if (!c) return null;
|
|
633
|
+
const parts = [c.setups > 1 && `${c.setups} setups`, c.profiles > 1 && `${c.profiles} Profile versions`].filter(Boolean);
|
|
634
|
+
return parts.length ? parts.join(", ") : null;
|
|
635
|
+
}
|
|
636
|
+
const ConditionsNote = ({ c }: { c: { setups: number; profiles: number } | undefined }) => {
|
|
637
|
+
const text = conditionsText(c);
|
|
638
|
+
return text ? <span className="conditions" title="Its pooled trials ran under these run conditions. Shown for information; they do not affect comparability.">{text}</span> : null;
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const when = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "—");
|
|
642
|
+
const GRADING_STATUS: Record<string, string> = { grading: "Grading…", done: "Done", invalid: "Invalid" };
|
|
643
|
+
|
|
644
|
+
/** The trial's run conditions: shown, never part of comparability. */
|
|
645
|
+
function RunConditions({ trial }: { trial: TrialDetail["trial"] }) {
|
|
646
|
+
const hash = (h: string | null) => (h ? <code title={h}>{short(h)}</code> : <span className="muted">none recorded</span>);
|
|
647
|
+
return (
|
|
648
|
+
<div className="conditions-list">
|
|
649
|
+
<h3>Run conditions</h3>
|
|
650
|
+
<dl>
|
|
651
|
+
<dt>User setup</dt><dd>{hash(trial.setupHash)}</dd>
|
|
652
|
+
<dt>Profile</dt><dd>{hash(trial.profileHash)}</dd>
|
|
653
|
+
<dt>Blocked tools</dt><dd>{trial.blockedTools == null ? <span className="muted">none recorded</span> : trial.blockedTools.length ? trial.blockedTools.join(", ") : "none"}</dd>
|
|
654
|
+
<dt>Harness version</dt><dd>{trial.harnessVersion ?? <span className="muted">none recorded</span>}</dd>
|
|
655
|
+
<dt>Started</dt><dd>{when(trial.startedAt)}</dd>
|
|
656
|
+
</dl>
|
|
657
|
+
</div>
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Every grading of the trial, oldest first; the one the current view counts is marked. */
|
|
662
|
+
function GradingList({ gradings, counted }: { gradings: TrialDetail["gradings"]; counted: number | null }) {
|
|
663
|
+
return (
|
|
664
|
+
<div className="gradings">
|
|
665
|
+
<h3>Gradings</h3>
|
|
666
|
+
{gradings.length === 0 ? <p className="muted">Not graded yet.</p> : (
|
|
667
|
+
<ul>{gradings.map((g) => (
|
|
668
|
+
<li key={g.id} className={g.id === counted ? "counted" : undefined}>
|
|
669
|
+
<span className="g-name">{g.setup?.name ?? "Unknown grading version"}</span>
|
|
670
|
+
<span>{g.setup?.judgeLabel ?? "Unknown judge"}</span>
|
|
671
|
+
<span className="muted">{when(g.gradedAt)}</span>
|
|
672
|
+
<span className="g-score">{g.status === "done" && g.score != null ? g.score.toFixed(1) : "—"}</span>
|
|
673
|
+
<span className={`g-status ${g.status}`}>{GRADING_STATUS[g.status] ?? g.status}</span>
|
|
674
|
+
<code title={`Grading hash ${g.gradingHash}`}>{short(g.gradingHash)}</code>
|
|
675
|
+
{g.id === counted && <span className="g-counted">Counts in this view</span>}
|
|
676
|
+
</li>
|
|
677
|
+
))}</ul>
|
|
678
|
+
)}
|
|
679
|
+
</div>
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function TrialDrawer({ view, trial, tick, onClose }: { view: View; trial: ViewTrial; tick: number; onClose: () => void }) {
|
|
684
|
+
const [md, setMd] = useState("");
|
|
685
|
+
const [image, setImage] = useState<string | null>(null); // an object URL when the output is an image
|
|
686
|
+
const [detail, setDetail] = useState<TrialDetail | null>(null);
|
|
687
|
+
const [detailError, setDetailError] = useState("");
|
|
688
|
+
// The output never changes once the trial has ended: loaded once per trial. An image output (the server sends
|
|
689
|
+
// its media type) is shown as an image, any other output as Markdown text.
|
|
690
|
+
useEffect(() => {
|
|
691
|
+
let live = true, objectUrl: string | null = null;
|
|
692
|
+
setMd("");
|
|
693
|
+
setImage(null);
|
|
694
|
+
fetch(`/api/trials/${encodeURIComponent(trial.id)}/output`)
|
|
695
|
+
.then(async (r) => {
|
|
696
|
+
if (!r.ok) return;
|
|
697
|
+
if (r.headers.get("content-type")?.startsWith("image/")) {
|
|
698
|
+
const blob = await r.blob();
|
|
699
|
+
if (live) { objectUrl = URL.createObjectURL(blob); setImage(objectUrl); }
|
|
700
|
+
} else {
|
|
701
|
+
const t = await r.text();
|
|
702
|
+
if (live) setMd(t);
|
|
703
|
+
}
|
|
704
|
+
})
|
|
705
|
+
.catch(() => {});
|
|
706
|
+
return () => { live = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
|
707
|
+
}, [trial.id]);
|
|
708
|
+
useEffect(() => { setDetail(null); setDetailError(""); }, [trial.id]);
|
|
709
|
+
// Its gradings do change (a regrade adds one): loaded again on each database event.
|
|
710
|
+
useEffect(() => {
|
|
711
|
+
let live = true;
|
|
712
|
+
api<TrialDetail>(`/api/trials/${encodeURIComponent(trial.id)}`)
|
|
713
|
+
.then((d) => { if (live) { setDetail(d); setDetailError(""); } })
|
|
714
|
+
.catch((e: Error) => { if (live) setDetailError(e.message); });
|
|
715
|
+
return () => { live = false; };
|
|
716
|
+
}, [trial.id, tick]);
|
|
717
|
+
const [tab, setTab] = useState<"rendered" | "raw">("rendered");
|
|
718
|
+
const s = view.summary.configs.find((x) => x.configId === trial.configId)!;
|
|
719
|
+
const config = s;
|
|
720
|
+
const task = view.tasks[trial.taskId].def;
|
|
721
|
+
useEffect(() => {
|
|
722
|
+
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
|
723
|
+
window.addEventListener("keydown", k);
|
|
724
|
+
return () => window.removeEventListener("keydown", k);
|
|
725
|
+
}, []);
|
|
726
|
+
const { fields, body } = useMemo(() => {
|
|
727
|
+
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
728
|
+
const field = (k: string) => m?.[1].match(new RegExp(`^${k}:\\s*["']?(.*?)["']?\\s*$`, "m"))?.[1];
|
|
729
|
+
return { fields: { title: field("title"), description: field("description") }, body: m ? md.slice(m[0].length) : md };
|
|
730
|
+
}, [md]);
|
|
731
|
+
// Image placeholders (often ``) are shown as labels, not loaded.
|
|
732
|
+
const html = useMemo(() => marked.parse(body.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_, alt) => `<span class="img-ph">Image: ${alt || "untitled"}</span>`), { async: false }) as string, [body]);
|
|
733
|
+
const g = trial.grades;
|
|
734
|
+
const groups = [{ id: "gates", label: "Gates", criteria: [{ id: "output-exists", question: "Did the agent write the output file?", level: "floor", grader: "check" } as Criterion, ...task.gates] }, ...task.measures];
|
|
735
|
+
|
|
736
|
+
return (
|
|
737
|
+
<>
|
|
738
|
+
<div className="scrim" onClick={onClose} />
|
|
739
|
+
<div className="drawer" role="dialog" aria-label="Trial detail">
|
|
740
|
+
<div className="drawer-head">
|
|
741
|
+
<div>
|
|
742
|
+
<div className="kicker"><Marker s={s} /> {task.title} · trial {trial.index} · batch {trial.batchId}</div>
|
|
743
|
+
<h2>{s.label}</h2>
|
|
744
|
+
<div className="stats">
|
|
745
|
+
<Stat k="Score" v={g ? g.score.toFixed(1) : "—"} />
|
|
746
|
+
<Stat k="Cost" v={money(trial.result?.costUsd) + (trial.result?.costSource === "estimated" ? " est." : "")} />
|
|
747
|
+
<Stat k="Time" v={secs(trial.durationMs)} />
|
|
748
|
+
<Stat k="Turns" v={String(trial.result?.turns ?? "—")} />
|
|
749
|
+
<Stat k="Model · effort" v={config.harness === "human" ? "by hand" : `${config.model} · ${config.effort ?? "default"}`} />
|
|
750
|
+
<Stat k="Profile" v={config.profile ?? "none"} />
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
<button className="close" onClick={onClose} aria-label="Close">×</button>
|
|
754
|
+
</div>
|
|
755
|
+
<div className="drawer-body">
|
|
756
|
+
<div>
|
|
757
|
+
{image ? <>
|
|
758
|
+
{trial.error && <p className="error">{trial.error}</p>}
|
|
759
|
+
<img className="output-image" src={image} alt={`Output of ${s.label} for ${task.title}, trial ${trial.index}`} />
|
|
760
|
+
</> : <>
|
|
761
|
+
<div className="tabs">
|
|
762
|
+
<button className={`tab ${tab === "rendered" ? "on" : ""}`} onClick={() => setTab("rendered")}>Rendered</button>
|
|
763
|
+
<button className={`tab ${tab === "raw" ? "on" : ""}`} onClick={() => setTab("raw")}>Markdown</button>
|
|
764
|
+
</div>
|
|
765
|
+
{trial.error && <p className="error">{trial.error}</p>}
|
|
766
|
+
{tab === "rendered" ? <>
|
|
767
|
+
{(fields.title || fields.description) && <div className="envelope"><div className="k">Subject</div><div className="subject">{fields.title ?? "—"}</div><div className="k">Preview</div><div>{fields.description ?? "—"}</div></div>}
|
|
768
|
+
<div className="prose" dangerouslySetInnerHTML={{ __html: html }} />
|
|
769
|
+
</> : <pre className="raw">{md}</pre>}
|
|
770
|
+
</>}
|
|
771
|
+
</div>
|
|
772
|
+
<div>
|
|
773
|
+
{detailError && <p className="error">{detailError}</p>}
|
|
774
|
+
{detail && <><RunConditions trial={detail.trial} /><GradingList gradings={detail.gradings} counted={trial.gradingId} /></>}
|
|
775
|
+
<h3>Verdicts</h3>
|
|
776
|
+
{groups.map((m) => (
|
|
777
|
+
<div key={m.id} className="verdict-group">
|
|
778
|
+
<div className="crit-head"><b>{m.label}</b>{m.id !== "gates" && g && <span className="crit-score">{pct(g.measures[m.id])}</span>}</div>
|
|
779
|
+
{m.criteria.map((c) => {
|
|
780
|
+
const v = g?.verdicts[c.id];
|
|
781
|
+
const state = v?.score == null ? "err" : v.score === 1 ? "pass" : v.score === 0 ? "fail" : "split";
|
|
782
|
+
return (
|
|
783
|
+
<div className={`crit ${state}`} key={c.id}>
|
|
784
|
+
<div className="crit-top">
|
|
785
|
+
<span className={`badge ${state}`}>{state === "err" ? "Error" : state === "split" ? `${v!.votes.filter((x) => x.pass).length}/${v!.votes.length}` : state === "pass" ? "Pass" : "Fail"}</span>
|
|
786
|
+
<span className="q">{c.question}</span>
|
|
787
|
+
</div>
|
|
788
|
+
<div className="crit-meta"><span className={`tag ${c.level}`}>{LEVEL_TEXT[c.level]}</span><span className="muted">{c.grader}</span></div>
|
|
789
|
+
{v?.error && <p className="error">{v.error}</p>}
|
|
790
|
+
{v?.votes.map((x, i) => <p key={i} className="reason"><b>{x.pass ? "Yes" : "No"}.</b> {x.reason}</p>)}
|
|
791
|
+
</div>
|
|
792
|
+
);
|
|
793
|
+
})}
|
|
794
|
+
</div>
|
|
795
|
+
))}
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
</div>
|
|
799
|
+
</>
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const Stat = ({ k, v }: { k: string; v: string }) => <div className="stat"><div className="k">{k}</div><div className="v">{v}</div></div>;
|
|
804
|
+
|
|
805
|
+
/** What GET /api/batches returns for each batch (src/core/batches.ts and src/core/batch-state.ts). */
|
|
806
|
+
interface BatchRow {
|
|
807
|
+
batch: { id: string; startedAt: string; endedAt: string | null };
|
|
808
|
+
request: { tasks?: string[]; suites?: string[]; configs?: string[]; trials?: number; judge?: string; votes?: number; taskIds?: string[] };
|
|
809
|
+
counts: Record<"queued" | "running" | "done" | "failed" | "invalid", number>;
|
|
810
|
+
total: number;
|
|
811
|
+
lastMoveAt: string;
|
|
812
|
+
state: "running" | "stalled" | "ended";
|
|
813
|
+
}
|
|
814
|
+
interface BatchTrialRow { id: string; taskId: string; configId: string; n: number; status: string; error: string | null }
|
|
815
|
+
const STATUSES = ["done", "failed", "invalid", "running", "queued"] as const;
|
|
816
|
+
const STATE_TEXT = { running: "Running", stalled: "Not ended", ended: "Ended" } as const;
|
|
817
|
+
|
|
818
|
+
/** The last read of GET /api/batches: rows is null until the first read; error is the last read's failure, if any. */
|
|
819
|
+
interface Batches { rows: BatchRow[] | null; error: string }
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The one reader of GET /api/batches, for the indicator and the Runs page both, so they show the same state. It reads
|
|
823
|
+
* on each /api/events tick, and on a timer, since a batch turns stalled by the clock alone, with no database change
|
|
824
|
+
* and so no event.
|
|
825
|
+
*/
|
|
826
|
+
function useBatches(tick: number): Batches {
|
|
827
|
+
const [batches, setBatches] = useState<Batches>({ rows: null, error: "" });
|
|
828
|
+
const [beat, setBeat] = useState(0);
|
|
829
|
+
useEffect(() => {
|
|
830
|
+
const id = setInterval(() => setBeat((n) => n + 1), STATUS_REFRESH_MS);
|
|
831
|
+
return () => clearInterval(id);
|
|
832
|
+
}, []);
|
|
833
|
+
useEffect(() => {
|
|
834
|
+
let live = true;
|
|
835
|
+
api<BatchRow[]>("/api/batches")
|
|
836
|
+
.then((rows) => live && setBatches({ rows, error: "" }))
|
|
837
|
+
.catch((e: Error) => live && setBatches((b) => ({ ...b, error: e.message })));
|
|
838
|
+
return () => { live = false; };
|
|
839
|
+
}, [tick, beat]);
|
|
840
|
+
return batches;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** The run status indicator in the top bar (US-005). A failed read shows as unavailable. A click opens Runs. */
|
|
844
|
+
function RunStatusButton({ batches, onOpen }: { batches: Batches; onOpen: () => void }) {
|
|
845
|
+
const status: RunStatus | null = batches.error ? { kind: "unavailable", error: batches.error } : batches.rows && runStatus(batches.rows);
|
|
846
|
+
if (!status) return null;
|
|
847
|
+
const dot = status.kind === "running" ? "run" : status.kind === "idle" && status.stalled ? "stalled" : status.kind;
|
|
848
|
+
return (
|
|
849
|
+
<button className="run-status" title="Open the Runs page" aria-label={`${statusText(status, when)}. Open the Runs page`} onClick={onOpen}>
|
|
850
|
+
<span className={`dot ${dot}`} aria-hidden="true" />
|
|
851
|
+
<span className="run-status-text">{statusText(status, when)}</span>
|
|
852
|
+
{status.kind === "running" && <>
|
|
853
|
+
<span className="mini" aria-hidden="true"><b style={{ width: `${status.total ? (status.ended / status.total) * 100 : 0}%` }} /></span>
|
|
854
|
+
<span className="run-status-count">{status.ended} of {status.total}</span>
|
|
855
|
+
</>}
|
|
856
|
+
</button>
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** The Runs page: New run, and every batch with its progress. */
|
|
861
|
+
function RunsPage({ batches, tick, onNew }: { batches: Batches; tick: number; onNew: () => void }) {
|
|
862
|
+
return (
|
|
863
|
+
<>
|
|
864
|
+
<section className="hero page-head">
|
|
865
|
+
<div>
|
|
866
|
+
<div className="kicker">Runs</div>
|
|
867
|
+
<h1>Runs</h1>
|
|
868
|
+
<p className="sub">Start a run and watch its progress here. Each trial shows on the Results page as soon as it is graded.</p>
|
|
869
|
+
</div>
|
|
870
|
+
<button className="btn btn-primary" onClick={onNew}>New run</button>
|
|
871
|
+
</section>
|
|
872
|
+
<BatchesPanel batches={batches} tick={tick} onNew={onNew} />
|
|
873
|
+
</>
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Every batch: running and not-ended ones first, then the ended ones, newest first. The rows come from useBatches. */
|
|
878
|
+
function BatchesPanel({ batches: { rows, error }, tick, onNew }: { batches: Batches; tick: number; onNew: () => void }) {
|
|
879
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
880
|
+
if (error) return <section className="card batches"><h2>Batches</h2><p className="error">{error}</p></section>;
|
|
881
|
+
if (!rows) return <section className="card batches"><h2>Batches</h2><p className="muted">Loading…</p></section>;
|
|
882
|
+
if (!rows.length) return (
|
|
883
|
+
<div className="empty card batches"><div className="kicker">Batches</div><h1>No runs yet</h1><p>Start one and its progress shows here.</p><button className="btn btn-primary" onClick={onNew}>New run</button></div>
|
|
884
|
+
);
|
|
885
|
+
// The route lists newest first; the order holds within each group.
|
|
886
|
+
const shown = [...rows.filter((b) => b.state !== "ended"), ...rows.filter((b) => b.state === "ended")];
|
|
887
|
+
return (
|
|
888
|
+
<section className="card batches" aria-label="Batches">
|
|
889
|
+
<h2>Batches</h2>
|
|
890
|
+
<p className="sub">Running and not-ended batches first, then the ones that ended, newest first. Click a batch for each trial's status.</p>
|
|
891
|
+
<div className="batch-cols" aria-hidden="true"><span>Batch</span><span>Asked for</span><span>Progress</span><span>State</span></div>
|
|
892
|
+
<ul className="batch-list">
|
|
893
|
+
{shown.map((b) => <BatchItem key={b.batch.id} b={b} open={open === b.batch.id} onToggle={() => setOpen(open === b.batch.id ? null : b.batch.id)} tick={tick} />)}
|
|
894
|
+
</ul>
|
|
895
|
+
</section>
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function BatchItem({ b, open, onToggle, tick }: { b: BatchRow; open: boolean; onToggle: () => void; tick: number }) {
|
|
900
|
+
const [trials, setTrials] = useState<BatchTrialRow[] | null>(null);
|
|
901
|
+
useEffect(() => {
|
|
902
|
+
if (!open) return;
|
|
903
|
+
let live = true;
|
|
904
|
+
api<{ trials: BatchTrialRow[] }>(`/api/batches/${encodeURIComponent(b.batch.id)}`).then((d) => live && setTrials(d.trials)).catch(() => live && setTrials(null));
|
|
905
|
+
return () => { live = false; };
|
|
906
|
+
}, [open, tick, b.batch.id]);
|
|
907
|
+
const r = b.request;
|
|
908
|
+
const asked = r.suites?.length ? `Suite${r.suites.length === 1 ? "" : "s"} ${r.suites.join(", ")}` : `Task${(r.tasks?.length ?? 0) === 1 ? "" : "s"} ${(r.tasks ?? r.taskIds ?? []).join(", ")}`;
|
|
909
|
+
const parts = [`${b.counts.done} of ${b.total} done`, ...STATUSES.filter((s) => s !== "done" && b.counts[s] > 0).map((s) => `${b.counts[s]} ${s}`)];
|
|
910
|
+
return (
|
|
911
|
+
<li className={`batch ${b.state}`}>
|
|
912
|
+
<button className="batch-head" aria-expanded={open} onClick={onToggle}>
|
|
913
|
+
<span className="batch-id"><b>{when(b.batch.startedAt)}</b><code>{b.batch.id}</code></span>
|
|
914
|
+
<span className="batch-asked">{asked} · {r.configs?.length ?? 0} config{r.configs?.length === 1 ? "" : "s"} · {b.total} trial{b.total === 1 ? "" : "s"}</span>
|
|
915
|
+
<span className="batch-progress">
|
|
916
|
+
<span className="batch-bar" role="img" aria-label={parts.join(", ")}>
|
|
917
|
+
{STATUSES.map((s) => b.counts[s] > 0 && <span key={s} className={`seg ${s}`} style={{ flexGrow: b.counts[s] }} title={`${b.counts[s]} ${s}`} />)}
|
|
918
|
+
</span>
|
|
919
|
+
<span className="batch-line">{parts.join(" · ")}</span>
|
|
920
|
+
</span>
|
|
921
|
+
<span className={`batch-state ${b.state}`}>{STATE_TEXT[b.state]}</span>
|
|
922
|
+
</button>
|
|
923
|
+
{b.state === "stalled" && <p className="note warn">Not ended. No trial has moved since {when(b.lastMoveAt)}; its process may have stopped.</p>}
|
|
924
|
+
{open && (trials ? (
|
|
925
|
+
<table className="batch-trials">
|
|
926
|
+
<thead><tr><th>Task</th><th>Config</th><th>#</th><th>Status</th></tr></thead>
|
|
927
|
+
<tbody>{trials.map((t) => <tr key={t.id}><td>{t.taskId}</td><td>{t.configId}</td><td>{t.n}</td><td><span className={`status ${t.status}`}>{t.status}</span>{t.error && <span className="muted" title={t.error}> · {t.error.slice(0, 80)}</span>}</td></tr>)}</tbody>
|
|
928
|
+
</table>
|
|
929
|
+
) : <p className="muted">Loading…</p>)}
|
|
930
|
+
</li>
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** The New run dialog: tasks or suites, configs, trials, judge and votes. Start sends POST /api/runs. */
|
|
935
|
+
function NewRun({ onClose, onStarted }: { onClose: () => void; onStarted: (id: string) => void }) {
|
|
936
|
+
const [configs, setConfigs] = useState<AgentConfig[]>([]);
|
|
937
|
+
const [suites, setSuites] = useState<Suite[]>([]);
|
|
938
|
+
const [tasks, setTasks] = useState<{ id: string; title: string }[]>([]);
|
|
939
|
+
const [judges, setJudges] = useState<JudgeConfig[]>([]);
|
|
940
|
+
const [by, setBy] = useState<"suites" | "tasks">("suites");
|
|
941
|
+
const [pickedSuites, setPickedSuites] = useState<Set<string>>(new Set());
|
|
942
|
+
const [pickedTasks, setPickedTasks] = useState<Set<string>>(new Set());
|
|
943
|
+
const [picked, setPicked] = useState<Set<string>>(new Set());
|
|
944
|
+
const [judgeId, setJudgeId] = useState("");
|
|
945
|
+
const [trials, setTrials] = useState(3);
|
|
946
|
+
const [votes, setVotes] = useState(3);
|
|
947
|
+
const [busy, setBusy] = useState(false);
|
|
948
|
+
const [error, setError] = useState("");
|
|
949
|
+
useEffect(() => {
|
|
950
|
+
api<AgentConfig[]>("/api/configs").then((c) => { setConfigs(c); setPicked(new Set(c.filter((x) => x.harness !== "pi").map((x) => x.id))); }).catch((e: Error) => setError(e.message));
|
|
951
|
+
api<Suite[]>("/api/suites").then((s) => { setSuites(s); setPickedSuites(new Set(s.map((x) => x.id))); }).catch((e: Error) => setError(e.message));
|
|
952
|
+
api<{ id: string; title: string }[]>("/api/tasks").then(setTasks).catch((e: Error) => setError(e.message));
|
|
953
|
+
api<JudgeConfig[]>("/api/judges").then((j) => { setJudges(j); setJudgeId(j[0]?.id ?? ""); }).catch((e: Error) => setError(e.message));
|
|
954
|
+
}, []);
|
|
955
|
+
const toggle = (set: Set<string>, fn: (s: Set<string>) => void, id: string) => { const n = new Set(set); n.has(id) ? n.delete(id) : n.add(id); fn(n); };
|
|
956
|
+
const taskIds = by === "suites" ? new Set(suites.filter((s) => pickedSuites.has(s.id)).flatMap((s) => s.tasks)) : pickedTasks;
|
|
957
|
+
const pickedConfigs = configs.filter((c) => picked.has(c.id));
|
|
958
|
+
const agentConfigs = pickedConfigs.filter((c) => c.harness !== "human").length;
|
|
959
|
+
const runs = taskIds.size * agentConfigs * trials;
|
|
960
|
+
const handOutputs = pickedConfigs.filter((c) => c.harness === "human").reduce((n, c) => n + Object.keys(c.outputs ?? {}).filter((t) => taskIds.has(t)).length, 0);
|
|
961
|
+
const start = async () => {
|
|
962
|
+
setBusy(true); setError("");
|
|
963
|
+
const scope = by === "suites" ? { suites: [...pickedSuites] } : { tasks: [...pickedTasks] };
|
|
964
|
+
try {
|
|
965
|
+
const r = await fetch("/api/runs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...scope, configs: [...picked], trials, judge: judgeId, votes }) });
|
|
966
|
+
const body = await r.json().catch(() => ({ error: `${r.status} ${r.statusText}` }));
|
|
967
|
+
if (!r.ok) { setError(body.error); setBusy(false); return; }
|
|
968
|
+
onStarted(body.id);
|
|
969
|
+
} catch (e) { setError(e instanceof Error ? e.message : String(e)); setBusy(false); }
|
|
970
|
+
};
|
|
971
|
+
const markerOf = (c: AgentConfig) => ({ harness: c.harness, profile: c.profile, family: c.family ?? familyOf(c.model) });
|
|
972
|
+
const scopePicked = by === "suites" ? pickedSuites.size : pickedTasks.size;
|
|
973
|
+
return (
|
|
974
|
+
<>
|
|
975
|
+
<div className="scrim" onClick={onClose} />
|
|
976
|
+
<div className="modal" role="dialog" aria-label="New run">
|
|
977
|
+
<h2>New run</h2>
|
|
978
|
+
<div className="chips" role="tablist" aria-label="Run by">
|
|
979
|
+
<button role="tab" aria-selected={by === "suites"} className={`chip ${by === "suites" ? "on" : ""}`} onClick={() => setBy("suites")}>Suites</button>
|
|
980
|
+
<button role="tab" aria-selected={by === "tasks"} className={`chip ${by === "tasks" ? "on" : ""}`} onClick={() => setBy("tasks")}>Tasks</button>
|
|
981
|
+
</div>
|
|
982
|
+
{by === "suites" ? (
|
|
983
|
+
<fieldset><legend>Suites</legend>
|
|
984
|
+
{suites.map((s) => <label className="opt" key={s.id}><input type="checkbox" checked={pickedSuites.has(s.id)} onChange={() => toggle(pickedSuites, setPickedSuites, s.id)} />{s.title}<span className="muted">{s.tasks.length} task{s.tasks.length > 1 ? "s" : ""}</span></label>)}
|
|
985
|
+
</fieldset>
|
|
986
|
+
) : (
|
|
987
|
+
<fieldset><legend>Tasks</legend>
|
|
988
|
+
{tasks.map((t) => <label className="opt" key={t.id}><input type="checkbox" checked={pickedTasks.has(t.id)} onChange={() => toggle(pickedTasks, setPickedTasks, t.id)} />{t.title}<span className="muted">{t.id}</span></label>)}
|
|
989
|
+
</fieldset>
|
|
990
|
+
)}
|
|
991
|
+
<fieldset><legend>Configs</legend>
|
|
992
|
+
{configs.map((c) => <label className="opt" key={c.id}><input type="checkbox" checked={picked.has(c.id)} onChange={() => toggle(picked, setPicked, c.id)} /><Marker s={markerOf(c)} />{configLabel(c)}</label>)}
|
|
993
|
+
</fieldset>
|
|
994
|
+
<fieldset><legend>Judge</legend>
|
|
995
|
+
<select value={judgeId} onChange={(e) => setJudgeId(e.target.value)} aria-label="Judge">
|
|
996
|
+
{judges.map((j) => <option key={j.id} value={j.id}>{j.label}</option>)}
|
|
997
|
+
</select>
|
|
998
|
+
<p className="note">Runs every judge criterion. Scores from different judges are not comparable.</p>
|
|
999
|
+
</fieldset>
|
|
1000
|
+
<div className="row">
|
|
1001
|
+
<label>Trials per config <input type="number" min={1} max={10} value={trials} onChange={(e) => setTrials(Number(e.target.value))} /></label>
|
|
1002
|
+
<label>Votes per judge criterion <input type="number" min={1} max={7} value={votes} onChange={(e) => setVotes(Number(e.target.value))} /></label>
|
|
1003
|
+
</div>
|
|
1004
|
+
<p className="note">{runs} agent runs ({taskIds.size} task{taskIds.size === 1 ? "" : "s"} × {agentConfigs} agent configs × {trials} trials){handOutputs > 0 && <> + {handOutputs} file{handOutputs > 1 ? "s" : ""} written by hand</>}, each graded with up to {votes} judge vote{votes > 1 ? "s" : ""} per measure (the last ones run only when the first disagree).</p>
|
|
1005
|
+
{error && <p className="error" role="alert">{error}</p>}
|
|
1006
|
+
<div className="row end">
|
|
1007
|
+
<button className="btn" onClick={onClose}>Cancel</button>
|
|
1008
|
+
<button className="btn btn-primary" disabled={!picked.size || !scopePicked || busy} onClick={start}>Start</button>
|
|
1009
|
+
</div>
|
|
1010
|
+
</div>
|
|
1011
|
+
</>
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Same rule as the server's familyOf (src/core/score.ts), for configs not yet in a summary.
|
|
1016
|
+
const EFFORT = new Set(["low", "medium", "high", "xhigh", "max", "minimal"]);
|
|
1017
|
+
const familyOf = (model: string) => model.toLowerCase().split(/[-_/:]/).filter((p) => p && !/\d/.test(p) && !EFFORT.has(p)).slice(0, 2).join("-") || model;
|
|
1018
|
+
|
|
1019
|
+
createRoot(document.getElementById("root")!).render(<App />);
|