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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +230 -2
  3. package/bin/static-fire.js +46 -0
  4. package/package.json +29 -4
  5. package/pricing.yaml +20 -0
  6. package/src/adapters/index.ts +153 -0
  7. package/src/api.ts +218 -0
  8. package/src/cli.ts +297 -0
  9. package/src/core/apkit-install.ts +47 -0
  10. package/src/core/authored.ts +47 -0
  11. package/src/core/batch-state.ts +25 -0
  12. package/src/core/batch.ts +435 -0
  13. package/src/core/batches.ts +221 -0
  14. package/src/core/checks.ts +63 -0
  15. package/src/core/commands.ts +42 -0
  16. package/src/core/compare.ts +203 -0
  17. package/src/core/config.ts +25 -0
  18. package/src/core/copying.ts +18 -0
  19. package/src/core/db.ts +572 -0
  20. package/src/core/derived.ts +74 -0
  21. package/src/core/engine.ts +85 -0
  22. package/src/core/facets.ts +128 -0
  23. package/src/core/grade.ts +197 -0
  24. package/src/core/grading-versions.ts +281 -0
  25. package/src/core/identity.ts +263 -0
  26. package/src/core/init.ts +231 -0
  27. package/src/core/judge-schema.ts +37 -0
  28. package/src/core/judge.ts +166 -0
  29. package/src/core/label.ts +43 -0
  30. package/src/core/lock.ts +40 -0
  31. package/src/core/migrate.ts +288 -0
  32. package/src/core/new-task.ts +85 -0
  33. package/src/core/outside-tools.ts +129 -0
  34. package/src/core/profiles.ts +42 -0
  35. package/src/core/project.ts +54 -0
  36. package/src/core/regrade.ts +233 -0
  37. package/src/core/score.ts +148 -0
  38. package/src/core/settings.ts +44 -0
  39. package/src/core/task-file.ts +95 -0
  40. package/src/core/trial-detail.ts +73 -0
  41. package/src/core/types.ts +195 -0
  42. package/src/core/view-query.ts +85 -0
  43. package/src/core/view.ts +498 -0
  44. package/src/guide.md +547 -0
  45. package/src/server.ts +24 -0
  46. package/web/api.ts +6 -0
  47. package/web/app.tsx +1019 -0
  48. package/web/brand/offset-tokens.css +126 -0
  49. package/web/charts.tsx +190 -0
  50. package/web/compare.tsx +317 -0
  51. package/web/encoding.tsx +55 -0
  52. package/web/filter-menu.ts +164 -0
  53. package/web/index.html +17 -0
  54. package/web/pages.ts +16 -0
  55. package/web/run-status.ts +51 -0
  56. package/web/styles.css +333 -0
  57. package/web/suite-notes.ts +20 -0
  58. package/web/wording.ts +38 -0
@@ -0,0 +1,126 @@
1
+ /* Copied from ai-launchpad-business brand/generated/tokens.css at 5c1143d (branch feat/dataviz-tokens). Do not edit: change brand/tokens.json there, rebuild, and copy again. */
2
+ /* GENERATED by brand/build.py from brand/tokens.json — do not edit. */
3
+
4
+ :root {
5
+ color-scheme: light;
6
+ --color-ink: #141512;
7
+ --color-graphite: #3A3D36;
8
+ --color-soft: #4F524A;
9
+ --color-mist: #C9CCC2;
10
+ --color-ash: #A9ADA3;
11
+ --color-paper: #FFFDF6;
12
+ --color-butter: #FFF8E3;
13
+ --color-white: #FFFFFF;
14
+ --color-rule: #E4DFCC;
15
+ --color-rule-soft: #EEEADB;
16
+ --color-sun: #FFCB05;
17
+ --color-sun-2: #FFE277;
18
+ --color-sun-ink: #7A5A00;
19
+ --color-mint: #D5EFD9;
20
+ --color-mint-2: #9FD8A9;
21
+ --color-blush: #FBDCD6;
22
+ --color-blush-2: #F4B8AD;
23
+ --color-sky: #D7E8F7;
24
+ --color-lavender: #E6DDF7;
25
+ --color-cream: #F1EFE6;
26
+
27
+ --font-display: "Hanken Grotesk", "Helvetica Neue", Arial, sans-serif;
28
+ --font-heading: "Hanken Grotesk", "Helvetica Neue", Arial, sans-serif;
29
+ --font-text: "Hanken Grotesk", "Helvetica Neue", Arial, sans-serif;
30
+ --font-kicker: "Hanken Grotesk", "Helvetica Neue", Arial, sans-serif;
31
+ --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace;
32
+ --tracking-display: -0.04em;
33
+ --leading-display: 0.98;
34
+ --tracking-heading: -0.035em;
35
+ --leading-heading: 1.02;
36
+ --tracking-kicker: 0.08em;
37
+ --tracking-text: 0;
38
+ --leading-text: 1.55;
39
+ --weight-display: 900;
40
+ --weight-heading: 900;
41
+ --weight-heading-card: 800;
42
+ --weight-kicker: 800;
43
+
44
+ --text-xs: 0.6875rem;
45
+ --text-sm: 0.8125rem;
46
+ --text-base: 1.0rem;
47
+ --text-lg: 1.125rem;
48
+ --text-xl: 1.375rem;
49
+ --text-2xl: 1.75rem;
50
+ --text-3xl: 2.25rem;
51
+ --text-4xl: 3.0rem;
52
+ --text-5xl: 4.0rem;
53
+ --text-6xl: 5.25rem;
54
+
55
+ --spacing-4: 4px;
56
+ --spacing-8: 8px;
57
+ --spacing-12: 12px;
58
+ --spacing-16: 16px;
59
+ --spacing-24: 24px;
60
+ --spacing-32: 32px;
61
+ --spacing-48: 48px;
62
+ --spacing-64: 64px;
63
+ --spacing-96: 96px;
64
+ --spacing-128: 128px;
65
+ --spacing-192: 192px;
66
+
67
+ --radius-lg: 24px;
68
+ --radius-md: 16px;
69
+ --radius-sm: 12px;
70
+ --radius-xs: 8px;
71
+ --radius-pill: 999px;
72
+ --outline-width: 2px;
73
+ --press-y: 2px;
74
+
75
+ --ground: var(--color-paper);
76
+ --surface: var(--color-butter);
77
+ --raised: var(--color-white);
78
+ --fg: var(--color-ink);
79
+ --fg-2: var(--color-graphite);
80
+ --fg-3: var(--color-soft);
81
+ --line: var(--color-rule);
82
+ --line-soft: var(--color-rule-soft);
83
+ --outline: var(--color-ink);
84
+ --accent: var(--color-sun);
85
+ --accent-text: var(--color-sun-ink);
86
+ --shadow-lg: 0 8px 0 var(--outline);
87
+ --shadow-md: 0 6px 0 var(--outline);
88
+ --shadow-sm: 0 4px 0 var(--outline);
89
+ --shadow-sm-pressed: 0 2px 0 var(--outline);
90
+
91
+ --dataviz-1: #DBAC28;
92
+ --dataviz-1-strong: #B68B00;
93
+ --dataviz-2: #B99CFD;
94
+ --dataviz-2-strong: #9A77E4;
95
+ --dataviz-3: #B36305;
96
+ --dataviz-3-strong: #7A4202;
97
+ --dataviz-4: #B05EA7;
98
+ --dataviz-4-strong: #7C3276;
99
+ --dataviz-5: #F492A1;
100
+ --dataviz-5-strong: #CF6F7F;
101
+ --dataviz-6: #2588E0;
102
+ --dataviz-6-strong: #296CAD;
103
+ --dataviz-7: #55A560;
104
+ --dataviz-7-strong: #2E7E3D;
105
+ --dataviz-baseline: var(--color-soft);
106
+ --dataviz-grid: var(--color-rule);
107
+ }
108
+
109
+ [data-scheme="dark"] {
110
+ color-scheme: dark;
111
+ --ground: var(--color-ink);
112
+ --surface: var(--color-ink);
113
+ --raised: var(--color-graphite);
114
+ --fg: var(--color-white);
115
+ --fg-2: var(--color-mist);
116
+ --fg-3: var(--color-ash);
117
+ --line: var(--color-graphite);
118
+ --line-soft: var(--color-graphite);
119
+ --outline: var(--color-white);
120
+ --accent: var(--color-sun);
121
+ --accent-text: var(--color-sun);
122
+ --shadow-lg: 0 8px 0 var(--outline);
123
+ --shadow-md: 0 6px 0 var(--outline);
124
+ --shadow-sm: 0 4px 0 var(--outline);
125
+ --shadow-sm-pressed: 0 2px 0 var(--outline);
126
+ }
package/web/charts.tsx ADDED
@@ -0,0 +1,190 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { fill, Marker, markerProps, shapePath } from "./encoding";
3
+ import { chartLabels } from "../src/core/label";
4
+ import type { ConfigSummary } from "../src/core/types";
5
+
6
+ export const money = (v: number | null | undefined) =>
7
+ v == null ? "—" : v < 0.01 ? `$${v.toFixed(4)}` : v < 1 ? `$${v.toFixed(3)}` : `$${v.toFixed(2)}`;
8
+ export const secs = (ms: number | null | undefined) => (ms == null ? "—" : ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${(ms / 60_000).toFixed(1)}m`);
9
+
10
+ function useWidth<T extends HTMLElement>() {
11
+ const ref = useRef<T>(null);
12
+ const [w, setW] = useState(900);
13
+ useEffect(() => {
14
+ if (!ref.current) return;
15
+ const ro = new ResizeObserver(([e]) => setW(e.contentRect.width));
16
+ ro.observe(ref.current);
17
+ return () => ro.disconnect();
18
+ }, []);
19
+ return [ref, w] as const;
20
+ }
21
+
22
+ const day = (iso: string) => new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
23
+ /** "6 Nov 2026", or "6 Nov 2026 to 13 Nov 2026". */
24
+ export const dateRange = (from: string | null, to: string | null) => (!from || !to ? "—" : day(from) === day(to) ? day(from) : `${day(from)} to ${day(to)}`);
25
+
26
+ /**
27
+ * One plotted config: what the selected view says its score, interval and cost are. `n` is the valid trials
28
+ * pooled into it and `from`/`to` the first and last start time of those trials.
29
+ */
30
+ export interface Point { s: ConfigSummary; score: number; ci: number | null; n: number; from: string | null; to: string | null; cost: number; costSource: string; durationMs: number | null }
31
+
32
+
33
+ /**
34
+ * One config in the chart card's table: a Point whose score or cost may be missing, with the reason the
35
+ * chart cannot draw it when it cannot.
36
+ */
37
+ export interface Row extends Omit<Point, "score" | "cost"> { score: number | null; cost: number | null; reason?: string }
38
+
39
+ /** The chart card's table view: one row per config, the same numbers the chart draws. */
40
+ export function ConfigTable({ rows, onPick }: { rows: Row[]; onPick?: (id: string) => void }) {
41
+ return (
42
+ <div className="table-wrap">
43
+ <table className="configs">
44
+ <thead><tr><th>Config</th><th className="num">Score</th><th className="num">± 95%</th><th className="num">n</th><th className="num">$ / task</th><th>Ran</th></tr></thead>
45
+ <tbody>
46
+ {rows.map((r) => (
47
+ <tr key={r.s.configId} className={r.reason ? "unplotted" : undefined} onClick={() => onPick?.(r.s.configId)}>
48
+ <td><div className="cfg"><Marker s={r.s} size={12} />{r.s.label}</div>{r.reason && <div className="why">Not plotted: {r.reason}</div>}</td>
49
+ <td className="num strong">{r.score == null ? "—" : r.score.toFixed(1)}</td>
50
+ <td className="num">{r.ci == null ? "—" : r.ci.toFixed(1)}</td>
51
+ <td className="num">{r.n}</td>
52
+ <td className="num">{r.s.harness === "human" ? "no agent cost" : r.cost == null ? "—" : `${money(r.cost)}${r.costSource === "estimated" ? " (est.)" : ""}`}</td>
53
+ <td>{dateRange(r.from, r.to)}</td>
54
+ </tr>
55
+ ))}
56
+ {rows.length === 0 && <tr><td colSpan={6} className="why">No configs in this view.</td></tr>}
57
+ </tbody>
58
+ </table>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ /** A person's score: drawn as a line across the chart, because a person has no agent cost. */
64
+ export interface HumanLine { s: ConfigSummary; score: number }
65
+
66
+ /** Score vs. cost per task — the Artificial Analysis-style frontier chart. */
67
+ export function FrontierChart({ points, yTitle, humans = [], onPick }: {
68
+ points: Point[]; yTitle: string; humans?: HumanLine[]; onPick?: (id: string) => void;
69
+ }) {
70
+ const [ref, width] = useWidth<HTMLDivElement>();
71
+ const [hover, setHover] = useState<string | null>(null);
72
+ const short = chartLabels(points.map((p) => p.s));
73
+ const narrow = width < 640;
74
+ const H = narrow ? 360 : 440, m = { t: 16, r: narrow ? 16 : 170, b: 52, l: 52 };
75
+ const iw = Math.max(160, width - m.l - m.r), ih = H - m.t - m.b;
76
+
77
+ const costs = points.map((p) => p.cost);
78
+ const lo = costs.length ? Math.min(...costs) / 1.8 : 0.001, hi = costs.length ? Math.max(...costs) * 1.8 : 1;
79
+ const lx0 = Math.log10(lo), lx1 = Math.log10(hi);
80
+ const x = (v: number) => m.l + ((Math.log10(v) - lx0) / (lx1 - lx0 || 1)) * iw;
81
+ const extent = points.flatMap((p) => [p.score - (p.ci ?? 0), p.score + (p.ci ?? 0)]).concat(humans.map((h) => h.score));
82
+ const y0 = Math.max(0, Math.floor((Math.min(...extent, 100) - 8) / 10) * 10), y1 = 100;
83
+ const y = (v: number) => m.t + ih - ((Math.min(100, Math.max(y0, v)) - y0) / (y1 - y0)) * ih;
84
+
85
+ const xticks: number[] = [];
86
+ for (let e = Math.floor(lx0); e <= Math.ceil(lx1); e++)
87
+ for (const k of [1, 2, 5]) { const v = k * 10 ** e; if (v >= lo && v <= hi) xticks.push(v); }
88
+ const yticks = Array.from({ length: (y1 - y0) / 10 + 1 }, (_, i) => y0 + i * 10);
89
+
90
+ // Pareto frontier: cheapest first, keep each point that beats every cheaper one.
91
+ const frontier: Point[] = [];
92
+ for (const p of [...points].sort((a, b) => a.cost - b.cost)) if (!frontier.length || p.score > frontier[frontier.length - 1].score) frontier.push(p);
93
+
94
+ // "Most attractive" quadrant: better than the median score at lower than the median cost.
95
+ const med = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s.length ? (s[(s.length - 1) >> 1] + s[s.length >> 1]) / 2 : 0; };
96
+ const qx = x(med(costs)), qy = y(med(points.map((p) => p.score)));
97
+
98
+ // Direct labels on wide screens, nudged so they don't collide.
99
+ const labels = [...points].sort((a, b) => y(a.score) - y(b.score)).map((p) => ({ p, ly: y(p.score) }));
100
+ for (let i = 1; i < labels.length; i++)
101
+ for (let j = 0; j < i; j++) {
102
+ const a = labels[j], b = labels[i];
103
+ if (Math.abs(x(a.p.cost) - x(b.p.cost)) < 190 && Math.abs(a.ly - b.ly) < 16) b.ly = a.ly + 16;
104
+ }
105
+
106
+ const hp = points.find((p) => p.s.configId === hover);
107
+ return (
108
+ <div className="chart-wrap" ref={ref}>
109
+ <svg className="chart" width={width} height={H} role="img" aria-label={`${yTitle} versus cost per task`}>
110
+ {points.length > 1 && <rect x={m.l} y={m.t} width={Math.max(0, qx - m.l)} height={Math.max(0, qy - m.t)} className="quadrant" />}
111
+ {yticks.map((v) => <g key={v}><line className="gridline" x1={m.l} x2={m.l + iw} y1={y(v)} y2={y(v)} /><text x={m.l - 10} y={y(v) + 4} textAnchor="end">{v}</text></g>)}
112
+ {xticks.map((v) => <g key={v}><line className="gridline" x1={x(v)} x2={x(v)} y1={m.t} y2={m.t + ih} strokeDasharray="2 4" /><text x={x(v)} y={m.t + ih + 18} textAnchor="middle">{money(v)}</text></g>)}
113
+ <text className="axis-title" x={m.l + iw / 2} y={H - 8} textAnchor="middle">Cost per task (USD, API-equivalent, log scale)</text>
114
+ <text className="axis-title" transform={`translate(14 ${m.t + ih / 2}) rotate(-90)`} textAnchor="middle">{yTitle} (0–100)</text>
115
+
116
+ {humans.map((h) => (
117
+ <g key={h.s.configId} className="pt" onClick={() => onPick?.(h.s.configId)}>
118
+ <title>{`${h.s.label}: ${h.score.toFixed(1)} (no agent cost)`}</title>
119
+ {/* A reference line, not a point: solid and thick (the Pareto frontier is dotted). Its label is drawn after the points. */}
120
+ <line x1={m.l} x2={m.l + iw} y1={y(h.score)} y2={y(h.score)} className="human-line" />
121
+ </g>
122
+ ))}
123
+
124
+ {frontier.length > 1 && <polyline points={frontier.map((p) => `${x(p.cost)},${y(p.score)}`).join(" ")} fill="none" stroke="var(--fg)" strokeWidth={2} strokeDasharray="1.5 5" strokeLinecap="round" />}
125
+
126
+ {points.map((p) => {
127
+ const cx = x(p.cost), cy = y(p.score), on = hover === p.s.configId, dim = hover && !on;
128
+ return (
129
+ <g key={p.s.configId} opacity={dim ? 0.3 : 1} style={{ transition: "opacity .15s" }}>
130
+ {p.ci != null && p.ci > 0 && <line x1={cx} x2={cx} y1={y(p.score + p.ci)} y2={y(p.score - p.ci)} stroke="var(--fg-3)" strokeWidth={2} strokeLinecap="round" />}
131
+ <g transform={`translate(${cx} ${cy})`}>
132
+ <path d={shapePath(p.s.harness, on ? 9 : 7.5)} {...markerProps(p.s)} />
133
+ {!p.s.profile && <path d={shapePath(p.s.harness, on ? 4 : 3.4)} fill={fill(p.s.family)} />}
134
+ </g>
135
+ <circle cx={cx} cy={cy} r={20} fill="transparent" style={{ cursor: "pointer" }}
136
+ onMouseEnter={() => setHover(p.s.configId)} onMouseLeave={() => setHover(null)} onClick={() => onPick?.(p.s.configId)} />
137
+ </g>
138
+ );
139
+ })}
140
+ {!narrow && labels.map(({ p, ly }) => (
141
+ <text key={p.s.configId} className="pt-label" x={x(p.cost) + 13} y={ly + 4} opacity={hover && hover !== p.s.configId ? 0.3 : 1}>
142
+ {short.get(p.s.configId)}
143
+ </text>
144
+ ))}
145
+ {/* In the right margin on wide screens, clear of the points; on narrow ones above the line's right end. */}
146
+ {humans.map((h) => (
147
+ <text key={h.s.configId} className="human-label" x={narrow ? m.l + iw - 4 : m.l + iw + 8} y={narrow ? y(h.score) - 8 : y(h.score) + 4} textAnchor={narrow ? "end" : "start"}>
148
+ {h.s.label} · {h.score.toFixed(0)}
149
+ </text>
150
+ ))}
151
+ </svg>
152
+ {hp && (
153
+ <div className="tooltip" style={{ left: x(hp.cost), top: y(hp.score) }}>
154
+ <div className="tt-head"><Marker s={hp.s} /><b>{hp.s.label}</b></div>
155
+ <div className="row"><span>{yTitle}</span><span>{hp.score.toFixed(1)}{hp.ci != null ? ` ± ${hp.ci.toFixed(1)}` : ""}</span></div>
156
+ <div className="row"><span>Cost / task</span><span>{money(hp.cost)}{hp.costSource === "estimated" ? " (est.)" : ""}</span></div>
157
+ <div className="row"><span>Time / task</span><span>{secs(hp.durationMs)}</span></div>
158
+ <div className="row"><span>Valid trials</span><span>{hp.n}</span></div>
159
+ <div className="row"><span>Ran</span><span>{dateRange(hp.from, hp.to)}</span></div>
160
+ <div className="row"><span>On the frontier</span><span>{frontier.includes(hp) ? "yes" : "no"}</span></div>
161
+ </div>
162
+ )}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ /** Horizontal bars, sorted; fill = model family colour inside an ink outline; hollow = no Profile. */
168
+ export function BarList({ rows, fmt, lowerIsBetter }: {
169
+ rows: { s: ConfigSummary; v: number | null }[]; fmt: (v: number) => string; lowerIsBetter?: boolean;
170
+ }) {
171
+ const data = rows.filter((r) => r.v != null).sort((a, b) => (lowerIsBetter ? 1 : -1) * (a.v! - b.v!));
172
+ const max = Math.max(...data.map((d) => d.v!), 1e-9);
173
+ const short = chartLabels(data.map((d) => d.s));
174
+ return (
175
+ <div className="bars">
176
+ {data.map(({ s, v }) => {
177
+ const pct = (v! / max) * 70;
178
+ return (
179
+ <div className="bar-row" key={s.configId} title={`${s.label}: ${fmt(v!)}`}>
180
+ <div className="bar-label"><Marker s={s} size={12} />{short.get(s.configId)}</div>
181
+ <div className="bar-track">
182
+ <div className="bar" style={{ width: `${pct}%`, background: s.profile || s.harness === "human" ? fill(s.family) : "var(--color-white)" }} />
183
+ <span className="bar-val" style={{ left: `${pct}%` }}>{fmt(v!)}</span>
184
+ </div>
185
+ </div>
186
+ );
187
+ })}
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,317 @@
1
+ // The Compare page (#27): two grading versions of one task. What changed between them, the effect on scores over
2
+ // the trials graded under both, and which criteria moved. Everything comes from GET /api/facets (the versions and
3
+ // their names) and GET /api/compare (the scores and the difference from A to B): the page does no diffing of
4
+ // grading versions (DEC-002, DEC-004). Marking the changed words of a reworded text is display work (./wording).
5
+ // The picks are page state: the URL query belongs to the Results page (web/pages.ts).
6
+ import { useEffect, useState } from "react";
7
+ import type { CompareCriterion, CompareReading, GradingComparison } from "../src/core/compare";
8
+ import { engineChangeName, type EngineChange } from "../src/core/engine";
9
+ import type { Facets } from "../src/core/facets";
10
+ import type { CriterionDiff, FileChanges, GradingDiff } from "../src/core/grading-versions";
11
+ import type { TaskSnapshot } from "../src/core/types";
12
+ import { api } from "./api";
13
+ import { Marker } from "./encoding";
14
+ import { wordChanges } from "./wording";
15
+
16
+ type Version = Facets["grading"][number]["setups"][number];
17
+
18
+ const short = (hash: string) => hash.slice(0, 8);
19
+ /** A task version in the "what changed" list: its label with its short task hash, or the short hash alone when it has none. */
20
+ const TaskVersion = ({ label, hash }: { label: number | null; hash: string }) =>
21
+ label != null ? <>v{label} (task <code>{short(hash)}</code>)</> : <>task <code>{short(hash)}</code></>;
22
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
23
+ /** Date and time: two versions of one task are often recorded on the same day. */
24
+ const when = (iso: string) => new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" });
25
+ const score = (v: number | null) => (v == null ? "—" : v.toFixed(1));
26
+ const rate = (v: number | null) => (v == null ? "—" : `${Math.round(v * 100)}%`);
27
+ /** A signed change with its direction for colour; "—" when one side has none. */
28
+ function Change({ v, unit = "" }: { v: number | null; unit?: string }) {
29
+ if (v == null) return <>—</>;
30
+ const r = Math.round(v * 10) / 10;
31
+ return <span className={`delta${r > 0 ? " up" : r < 0 ? " down" : ""}`}>{r > 0 ? "+" : r < 0 ? "−" : ""}{Math.abs(r).toFixed(unit ? 0 : 1)}{unit}</span>;
32
+ }
33
+
34
+ /** " · judge Opus" when the task's versions are of more than one judge. */
35
+ const judgeNote = (all: Version[], v: Version) => (new Set(all.map((x) => x.judgeLabel)).size > 1 ? ` · judge ${v.judgeLabel}` : "");
36
+ const optionText = (all: Version[], v: Version) => `${v.name}${judgeNote(all, v)} · ${when(v.firstSeen)} · ${plural(v.count, "trial")}${v.current ? " · current" : ""}`;
37
+
38
+ export function ComparePage({ tick, titles }: { tick: number; titles: Record<string, string> }) {
39
+ const [facets, setFacets] = useState<Facets | null>(null);
40
+ const [facetsError, setFacetsError] = useState("");
41
+ const [picked, setPicked] = useState<{ task?: string; a?: string; b?: string }>({});
42
+ useEffect(() => {
43
+ let live = true;
44
+ api<Facets>("/api/facets").then((f) => { if (live) { setFacets(f); setFacetsError(""); } }).catch((e: Error) => { if (live) setFacetsError(e.message); });
45
+ return () => { live = false; };
46
+ }, [tick]);
47
+
48
+ // Tasks with two or more grading versions that graded a stored trial.
49
+ const groups = (facets?.grading ?? []).filter((g) => g.setups.length > 1);
50
+ const group = groups.find((g) => g.taskId === picked.task) ?? groups[0];
51
+ const versions = group?.setups ?? []; // oldest first
52
+ const known = (h?: string) => !!h && versions.some((v) => v.hash === h);
53
+ // By default B is the latest version and A the version before it (the change B is named by).
54
+ const b = known(picked.b) ? picked.b! : versions.at(-1)?.hash;
55
+ const bVersion = versions.find((v) => v.hash === b);
56
+ const before = bVersion?.previous && known(bVersion.previous) ? bVersion.previous : versions[versions.findIndex((v) => v.hash === b) - 1]?.hash;
57
+ const a = known(picked.a) && picked.a !== b ? picked.a! : before ?? versions.find((v) => v.hash !== b)?.hash;
58
+ const pickA = (h: string) => setPicked({ task: group!.taskId, a: h, b: h === b ? a : b }); // picking B's version swaps them
59
+ const pickB = (h: string) => setPicked({ task: group!.taskId, a: h === a ? b : a, b: h });
60
+
61
+ const [result, setResult] = useState<{ key: string; r: GradingComparison } | null>(null);
62
+ const [error, setError] = useState("");
63
+ const key = a && b ? `a=${a}&b=${b}` : "";
64
+ useEffect(() => {
65
+ if (!key) return;
66
+ let live = true; // a response for an earlier pick must not replace the current one
67
+ api<GradingComparison>(`/api/compare?${key}`)
68
+ .then((r) => { if (live) { setResult({ key, r }); setError(""); } })
69
+ .catch((e: Error) => { if (live) { setResult(null); setError(e.message); } });
70
+ return () => { live = false; };
71
+ }, [key, tick]);
72
+ const r = result?.key === key ? result.r : null;
73
+ const va = versions.find((v) => v.hash === a), vb = bVersion;
74
+
75
+ return (
76
+ <>
77
+ <section className="hero page-head">
78
+ <div>
79
+ <div className="kicker">Compare</div>
80
+ <h1>Did a grading change move the scores?</h1>
81
+ <p className="sub">Pick two grading versions of one task. Only trials graded under both are compared, so a difference comes from the grading, not from different outputs. Gradings are never averaged together.</p>
82
+ </div>
83
+ </section>
84
+
85
+ {facetsError ? <div className="card"><p className="error">Could not load the grading versions: {facetsError}</p></div>
86
+ : !facets ? <div className="card"><p className="note">Loading…</p></div>
87
+ : !group || !va || !vb ? (
88
+ <div className="card empty-compare">
89
+ <h2>Nothing to compare yet</h2>
90
+ <p className="sub">No task has two grading versions with graded trials. A task gets a new grading version when its grading changes (a question, a check, a file graders read, the judge or a part of the engine revision that the task uses) and a trial is graded under it, for example by a regrade.</p>
91
+ </div>
92
+ ) : <>
93
+ <section className="card" aria-label="Grading versions compared">
94
+ <div className="card-head"><h2>{titles[group.taskId] ?? group.taskId}: grading versions</h2><span className="muted">named by what changed since the version before</span></div>
95
+ <div className="filters compare-picks">
96
+ {groups.length > 1 && (
97
+ <label>Task
98
+ <select aria-label="Task compared" value={group.taskId} onChange={(e) => setPicked({ task: e.target.value })}>
99
+ {groups.map((g) => <option key={g.taskId} value={g.taskId}>{titles[g.taskId] ?? g.taskId} ({g.setups.length} versions)</option>)}
100
+ </select>
101
+ </label>
102
+ )}
103
+ <label>Version A
104
+ <select aria-label="Version A" value={a} onChange={(e) => pickA(e.target.value)}>
105
+ {versions.map((v) => <option key={v.hash} value={v.hash} title={`Grading hash ${v.hash}`}>{optionText(versions, v)}</option>)}
106
+ </select>
107
+ </label>
108
+ <label>Version B
109
+ <select aria-label="Version B" value={b} onChange={(e) => pickB(e.target.value)}>
110
+ {versions.map((v) => <option key={v.hash} value={v.hash} title={`Grading hash ${v.hash}`}>{optionText(versions, v)}</option>)}
111
+ </select>
112
+ </label>
113
+ </div>
114
+ <div className="two">
115
+ <VersionCard side="A" v={va} def={r?.a.def} />
116
+ <VersionCard side="B" v={vb} def={r?.b.def} />
117
+ </div>
118
+ </section>
119
+
120
+ {error ? <div className="card"><p className="error">Could not compare: {error}</p></div>
121
+ : !r ? <div className="card"><p className="note">Loading…</p></div>
122
+ : <>
123
+ <WhatChanged r={r} />
124
+ <Effect r={r} />
125
+ </>}
126
+ </>}
127
+ </>
128
+ );
129
+ }
130
+
131
+ const criterionIds = (d: TaskSnapshot) => [...d.gates, ...d.measures.flatMap((m) => m.criteria)].map((c) => c.id);
132
+
133
+ function VersionCard({ side, v, def }: { side: "A" | "B"; v: Version; def?: TaskSnapshot }) {
134
+ return (
135
+ <div className="ver" data-side={side}>
136
+ <div className="kicker">{side} · {when(v.firstSeen)}{v.current && <span className="pill current">current</span>}</div>
137
+ <b className="ver-name">{v.name}</b>
138
+ <div className="muted">
139
+ {def && <>{plural(criterionIds(def).length, "criterion", "criteria")} · </>}judge {v.judgeLabel} · {plural(v.count, "trial")} graded · task {v.label != null ? <>v{v.label}</> : <code title={`Task hash ${v.taskHash}`}>{short(v.taskHash)}</code>} · <code title={`Grading hash ${v.hash}`}>{short(v.hash)}</code>
140
+ </div>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ /** Old and new text with the changed words marked. */
146
+ function Words({ a, b }: { a: string; b: string }) {
147
+ return <>{wordChanges(a, b).map((p, i) => (p.kind === "added" ? <ins key={i}>{p.text}</ins> : p.kind === "removed" ? <del key={i}>{p.text}</del> : <span key={i}>{p.text}</span>))}</>;
148
+ }
149
+
150
+ /** One changed part of the engine revision: its name, and the two revisions when it has them (the instructions are a hash of their wording). */
151
+ function EngineChangeLine({ c }: { c: EngineChange }) {
152
+ const name = engineChangeName(c);
153
+ return (
154
+ <li className="change">
155
+ <b>Engine</b>: {name}
156
+ {c.part !== "revision" && typeof c.a === "number" && <> (revision {c.a} to {c.b})</>}
157
+ {c.part === "instructions" && (c.name === "derived" ? " (the text a judge gets with derived evidence)" : " (the wording every judge gets)")}
158
+ . The grading code changed.
159
+ </li>
160
+ );
161
+ }
162
+
163
+ const FIELD: Record<string, string> = { question: "Question", check: "Check", grader: "Grader", evidence: "Evidence", isolate: "Graded alone", derived: "Derived evidence" };
164
+ const valueText = (v: unknown) => (v == null ? "(none)" : typeof v === "string" ? v : JSON.stringify(v));
165
+
166
+ /** The measure a criterion is in under either version, for its label. */
167
+ const measureOf = (r: GradingComparison, id: string) =>
168
+ [...r.b.def.measures, ...r.a.def.measures].find((m) => m.criteria.some((c) => c.id === id))?.label;
169
+
170
+ /** How a criterion changed, in one or two words, for its mark. */
171
+ function changeWord(c: CriterionDiff | undefined): string {
172
+ if (!c) return "script changed";
173
+ if (c.change !== "changed") return c.change;
174
+ return c.fields.length === 1 && c.fields[0]!.field === "question" ? "reworded" : "changed";
175
+ }
176
+
177
+ function CriterionChange({ r, c }: { r: GradingComparison; c: CriterionDiff }) {
178
+ const where = c.kind === "gate" ? "gate" : measureOf(r, c.id) ? `measure: ${measureOf(r, c.id)}` : "criterion";
179
+ const content = c.b ?? c.a!;
180
+ return (
181
+ <li className="change">
182
+ <div><b><code>{c.id}</code></b> <span className="muted">({where})</span> <span className={`pill mark ${c.change}`}>{changeWord(c)}</span></div>
183
+ {c.change !== "changed"
184
+ ? <div className="diff">{c.change === "added" ? <ins>{content.question}</ins> : <del>{content.question}</del>}</div>
185
+ : c.fields.map((f) => (
186
+ <div key={f.field} className="diff">
187
+ {f.field !== "question" && <span className="field">{FIELD[f.field] ?? f.field}: </span>}
188
+ <Words a={valueText(f.a)} b={valueText(f.b)} />
189
+ </div>
190
+ ))}
191
+ </li>
192
+ );
193
+ }
194
+
195
+ const anyFile = (f: FileChanges) => f.added.length + f.removed.length + f.changed.length > 0;
196
+ function Files({ label, f }: { label: string; f: FileChanges }) {
197
+ if (!anyFile(f)) return null;
198
+ const list = (word: string, names: string[]) => names.length > 0 && <span>{word} {names.map((n, i) => <span key={n}>{i > 0 && ", "}<code>{n}</code></span>)}</span>;
199
+ return <li className="change"><b>{label}</b>: {[list("changed", f.changed), list("added", f.added), list("removed", f.removed)].filter(Boolean).map((x, i) => <span key={i}>{i > 0 && "; "}{x}</span>)}</li>;
200
+ }
201
+
202
+ /** Everything that differs from A to B, in plain words, from the server's difference. */
203
+ function WhatChanged({ r }: { r: GradingComparison }) {
204
+ const d: GradingDiff = r.diff;
205
+ const total = new Set([...criterionIds(r.a.def), ...criterionIds(r.b.def)]).size;
206
+ const files = [d.files.references, d.files.sources, d.files.grader, ...(d.task ? [d.task.inputs, d.task.project] : [])].some(anyFile);
207
+ const measures = d.measures.filter((m) => !m.byCriteriaOnly);
208
+ return (
209
+ <section className="card what-changed" aria-label="What changed">
210
+ <h2>What changed from A to B</h2>
211
+ {d.same ? <p className="sub">Nothing differs between the stored snapshots of these two grading versions.</p> : <>
212
+ <p className="sub">
213
+ {d.criteria.length > 0 ? `${d.criteria.length} of ${plural(total, "criterion", "criteria")} and gates differ.` : "No criterion or gate differs."}
214
+ {d.criteriaOnly && " Nothing else differs: no measure, context, file, judge or engine change."}
215
+ </p>
216
+ <ul className="changes">
217
+ {d.criteria.map((c) => <CriterionChange key={`${c.kind}-${c.id}`} r={r} c={c} />)}
218
+ {measures.map((m) => (
219
+ <li key={m.id} className="change">
220
+ <b>Measure <code>{m.id}</code></b>: {m.change === "changed" ? "changed" : m.change}
221
+ {m.weight && <>; weight {m.weight.a ?? "none"} to {m.weight.b ?? "none"}</>}
222
+ {m.criteria.added.length > 0 && <>; criteria put in: {m.criteria.added.map((c) => <code key={c}> {c}</code>)}</>}
223
+ {m.criteria.removed.length > 0 && <>; criteria taken out: {m.criteria.removed.map((c) => <code key={c}> {c}</code>)}</>}
224
+ </li>
225
+ ))}
226
+ {d.context && <li className="change"><b>Context</b> (what every judge reads first)<div className="diff"><Words a={d.context.a ?? ""} b={d.context.b ?? ""} /></div></li>}
227
+ {d.judge && <li className="change"><b>Judge</b>: {d.judge.a.label} to {d.judge.b.label}</li>}
228
+ {d.engine.map((c) => <EngineChangeLine key={`${c.part}-${c.name}`} c={c} />)}
229
+ {d.task && <li className="change">
230
+ <b>Task</b>: <TaskVersion label={d.task.label.a} hash={d.task.a} /> to <TaskVersion label={d.task.label.b} hash={d.task.b} />. The agents were given a different job.
231
+ {d.task.prompt && " The prompt changed."}{d.task.output && " The output path changed."}
232
+ </li>}
233
+ {d.task && <Files label="Input files" f={d.task.inputs} />}
234
+ {d.task && <Files label="Project files" f={d.task.project} />}
235
+ <Files label="Check scripts" f={d.files.grader} />
236
+ <Files label="References" f={d.files.references} />
237
+ <Files label="Sources" f={d.files.sources} />
238
+ {d.files.heldOut && <li className="change"><b>Held-out original</b>: changed</li>}
239
+ </ul>
240
+ {(files || d.files.heldOut) && <p className="note">A changed file is listed by name only. The store keeps a hash of each file, not its content.</p>}
241
+ </>}
242
+ </section>
243
+ );
244
+ }
245
+
246
+ /** Each config's score under both versions, and each criterion whose pass rate moved. */
247
+ function Effect({ r }: { r: GradingComparison }) {
248
+ if (!r.sameTask) {
249
+ return (
250
+ <section className="card" aria-label="Effect on scores">
251
+ <h2>Effect on scores</h2>
252
+ <p className="note no-shared">These two grading versions grade two different task versions, so the agents did a different job and the versions share no trials. No scores are compared.</p>
253
+ </section>
254
+ );
255
+ }
256
+ const changes = new Map(r.diff.criteria.map((c) => [c.id, c]));
257
+ const configs = new Map(r.configs.map((c) => [c.configKey, c]));
258
+ const size = (m: CompareCriterion) => (m.a == null || m.b == null ? 2 : Math.abs(m.b - m.a));
259
+ const moved = r.criteria.filter((m) => m.moved).sort((p, q) => size(q) - size(p) || (p.criterionId < q.criterionId ? -1 : 1));
260
+ return (
261
+ <section className="card" aria-label="Effect on scores">
262
+ <h2>Effect on scores</h2>
263
+ <p className="sub trial-counts">
264
+ {plural(r.trials, "trial")} {r.trials === 1 ? "has" : "have"} a valid grading under both versions.
265
+ {r.onlyA + r.onlyB > 0 && ` ${r.onlyA} graded only under A and ${r.onlyB} only under B are not counted.`}
266
+ </p>
267
+ {r.trials === 0 ? <p className="note">No trial has a valid grading under both versions yet. Regrade the trials under the other version to compare them.</p> : <>
268
+ <div className="table-wrap"><table className="compare-scores">
269
+ <thead><tr><th>Configuration</th><th>n</th><th title={r.a.name}>Under A</th><th title={r.b.name}>Under B</th><th>Change</th></tr></thead>
270
+ <tbody>{r.configs.map((c) => (
271
+ <tr key={c.configKey}>
272
+ <td className="cfg"><Marker s={c} />{c.label}</td>
273
+ <td className="muted">{c.n}</td><td>{score(c.a)}</td><td>{score(c.b)}</td><td><Change v={c.change} /></td>
274
+ </tr>
275
+ ))}</tbody>
276
+ </table></div>
277
+
278
+ <h3 className="moved-head">Criteria whose pass rate moved</h3>
279
+ <p className="sub">Pass rate per configuration, largest change first. The mark says whether the criterion itself changed between the versions. "—" means the criterion is not in that version.</p>
280
+ {moved.length === 0 ? <p className="note">No criterion's pass rate moved.</p> : (
281
+ <div className="table-wrap"><table className="compare-moved">
282
+ <thead><tr><th>Criterion</th><th>Configuration</th><th>Criterion changed?</th><th>Under A</th><th>Under B</th><th>Change</th></tr></thead>
283
+ <tbody>{moved.map((m) => {
284
+ const c = configs.get(m.configKey)!;
285
+ return (
286
+ <tr key={`${m.criterionId}-${m.configKey}`}>
287
+ <td><code>{m.criterionId}</code></td>
288
+ <td className="cfg"><Marker s={c} />{c.label}</td>
289
+ <td>{m.changed ? <span className="pill mark changed">{changeWord(changes.get(m.criterionId))}</span> : <span className="pill mark same">unchanged</span>}</td>
290
+ <td>{rate(m.a)}</td><td>{rate(m.b)}</td>
291
+ <td><Change v={m.a == null || m.b == null ? null : (m.b - m.a) * 100} unit=" pts" /></td>
292
+ </tr>
293
+ );
294
+ })}</tbody>
295
+ </table></div>
296
+ )}
297
+ {r.reading && <p className="reading" aria-label="Reading">{readingText(r.reading)}</p>}
298
+ </>}
299
+ </section>
300
+ );
301
+ }
302
+
303
+ /** The Reading line, in plain words, from the counts the server computed (DEC-005). */
304
+ function readingText(x: CompareReading): string {
305
+ const changed = x.changedMoved + x.changedSteady;
306
+ const crit = (n: number) => plural(n, "criterion", "criteria");
307
+ if (x.changedMoved + x.unchangedMoved === 0) {
308
+ return `Reading: no criterion moved${changed === 1 ? ", including the one changed criterion" : changed > 1 ? `, including the ${changed} changed criteria` : ""}.`;
309
+ }
310
+ const head = changed === 0 ? "No criterion changed between the versions."
311
+ : changed === 1 ? `The one changed criterion ${x.changedMoved ? "moved" : "did not move"}.`
312
+ : x.changedMoved === 0 ? `None of the ${changed} changed criteria moved.`
313
+ : `${x.changedMoved} of the ${changed} changed criteria moved.`;
314
+ const rest = x.unchangedMoved === 0 ? "No unchanged criterion moved." : `${crit(x.unchangedMoved)} that did not change moved.`;
315
+ const judge = x.judgeVariation ? " So the difference is the judge varying between runs, not an effect of the change." : "";
316
+ return `Reading: ${head[0]!.toLowerCase()}${head.slice(1)} ${rest}${judge}`;
317
+ }