Skip to content

Commit 54e9cbe

Browse files
Kartik Anejaclaude
authored andcommitted
Add run-level diff view + landing-page picker
Web (web/): - /compare/runs/[a]/[b] route — fetches both runs' events + judges in parallel, computes per-judge means + 10-bucket score histograms + paired matches by prompt - lib/runStats.ts — judgeStats() rollup + pairByPrompt() matcher - components/JudgeHistogram.tsx — A/B side-by-side bars on a shared axis - "Top regressions" / "Top improvements" highlight panels above the full matched-prompt table; per-row "open ↗" jumps into event-level diff - RunCompareSelector client component at the bottom of the home page — two dropdowns + "Compare runs →" navigation Closes ROADMAP "Run-level diff" P0. Composes with the existing /compare/[a]/[b] event diff via the open-link in each row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a071ba2 commit 54e9cbe

5 files changed

Lines changed: 534 additions & 0 deletions

File tree

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
import Link from "next/link";
2+
import { notFound } from "next/navigation";
3+
import {
4+
getRun,
5+
listEvents,
6+
listJudgeResults,
7+
type JudgeResult,
8+
} from "@/lib/api";
9+
import { formatTimestamp, scoreColor, truncate } from "@/lib/format";
10+
import { judgeStats, pairByPrompt, type RunBundle } from "@/lib/runStats";
11+
import { JudgeHistogram } from "@/components/JudgeHistogram";
12+
13+
export const dynamic = "force-dynamic";
14+
15+
async function fetchBundle(runId: string): Promise<RunBundle> {
16+
const events = await listEvents(runId, 500);
17+
const judgesByEvent = new Map<string, JudgeResult[]>();
18+
await Promise.all(
19+
events.map(async (ev) => {
20+
const judges = await listJudgeResults(ev.id);
21+
judgesByEvent.set(ev.id, judges);
22+
})
23+
);
24+
return { events, judgesByEvent };
25+
}
26+
27+
export default async function CompareRunsPage({
28+
params,
29+
}: {
30+
params: { a: string; b: string };
31+
}) {
32+
const [runA, runB] = await Promise.all([
33+
getRun(params.a).catch(() => null),
34+
getRun(params.b).catch(() => null),
35+
]);
36+
if (!runA || !runB) notFound();
37+
38+
const [bundleA, bundleB] = await Promise.all([
39+
fetchBundle(params.a),
40+
fetchBundle(params.b),
41+
]);
42+
43+
const statsA = judgeStats(bundleA);
44+
const statsB = judgeStats(bundleB);
45+
const judges = Array.from(
46+
new Set([...statsA.map((s) => s.judge), ...statsB.map((s) => s.judge)])
47+
).sort();
48+
49+
const matches = pairByPrompt(bundleA, bundleB);
50+
const intersecting = matches.filter((m) => m.delta !== null);
51+
const regressions = intersecting
52+
.filter((m) => (m.delta ?? 0) < 0)
53+
.slice(0, 5);
54+
const improvements = intersecting
55+
.filter((m) => (m.delta ?? 0) > 0)
56+
.slice(0, 5);
57+
58+
return (
59+
<div>
60+
<Link href="/" className="text-sm text-zinc-500 hover:text-zinc-900">
61+
← All runs
62+
</Link>
63+
64+
<header className="mt-4">
65+
<h1 className="text-2xl font-semibold tracking-tight">Run diff</h1>
66+
<p className="mt-1 text-sm text-zinc-500">
67+
Compare two evaluation runs across judges and matched prompts.
68+
</p>
69+
</header>
70+
71+
<section className="mt-6 grid grid-cols-1 gap-3 md:grid-cols-2">
72+
<RunMeta side="A" runName={runA.name} createdAt={runA.created_at} suite={runA.suite_path} eventCount={bundleA.events.length} />
73+
<RunMeta side="B" runName={runB.name} createdAt={runB.created_at} suite={runB.suite_path} eventCount={bundleB.events.length} />
74+
</section>
75+
76+
<section className="mt-6 rounded-lg border border-zinc-200 bg-white">
77+
<header className="border-b border-zinc-100 px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
78+
Judge means
79+
</header>
80+
{judges.length === 0 ? (
81+
<p className="px-4 py-6 text-center text-sm text-zinc-500">
82+
No judge results recorded on either run.
83+
</p>
84+
) : (
85+
<table className="min-w-full text-sm">
86+
<thead className="border-b border-zinc-100 text-left text-xs uppercase tracking-wide text-zinc-500">
87+
<tr>
88+
<th className="px-4 py-2 font-medium">Judge</th>
89+
<th className="px-4 py-2 font-medium">A mean</th>
90+
<th className="px-4 py-2 font-medium">B mean</th>
91+
<th className="px-4 py-2 font-medium">Δ</th>
92+
<th className="px-4 py-2 font-medium">Score distribution</th>
93+
</tr>
94+
</thead>
95+
<tbody className="divide-y divide-zinc-100">
96+
{judges.map((j) => {
97+
const a = statsA.find((s) => s.judge === j);
98+
const b = statsB.find((s) => s.judge === j);
99+
const delta =
100+
a && b ? b.mean - a.mean : null;
101+
return (
102+
<tr key={j}>
103+
<td className="px-4 py-2 font-medium text-zinc-900">{j}</td>
104+
<td className="px-4 py-2">
105+
{a ? <ScorePill score={a.mean} /> : <Missing />}
106+
</td>
107+
<td className="px-4 py-2">
108+
{b ? <ScorePill score={b.mean} /> : <Missing />}
109+
</td>
110+
<td className="px-4 py-2 font-mono text-xs">
111+
{delta === null ? <Missing /> : <DeltaCell delta={delta} />}
112+
</td>
113+
<td className="w-64 px-4 py-2">
114+
<JudgeHistogram a={a} b={b} />
115+
</td>
116+
</tr>
117+
);
118+
})}
119+
</tbody>
120+
</table>
121+
)}
122+
{judges.length > 0 && (
123+
<p className="border-t border-zinc-100 px-4 py-2 text-[11px] text-zinc-500">
124+
<span className="mr-2 inline-block h-2 w-2 bg-zinc-400" /> A · {" "}
125+
<span className="mr-2 inline-block h-2 w-2 bg-indigo-500" /> B · 10 buckets across [0, 1]
126+
</p>
127+
)}
128+
</section>
129+
130+
<section className="mt-6 grid grid-cols-1 gap-3 md:grid-cols-2">
131+
<PromptDeltas
132+
title="Top regressions"
133+
tone="bad"
134+
matches={regressions}
135+
emptyText="No prompt is worse in B."
136+
/>
137+
<PromptDeltas
138+
title="Top improvements"
139+
tone="good"
140+
matches={improvements}
141+
emptyText="No prompt is better in B."
142+
/>
143+
</section>
144+
145+
<section className="mt-6 rounded-lg border border-zinc-200 bg-white">
146+
<header className="border-b border-zinc-100 px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
147+
All matched prompts ({intersecting.length})
148+
</header>
149+
{intersecting.length === 0 ? (
150+
<p className="px-4 py-6 text-center text-sm text-zinc-500">
151+
No prompts appear in both runs.
152+
</p>
153+
) : (
154+
<table className="min-w-full text-sm">
155+
<thead className="border-b border-zinc-100 text-left text-xs uppercase tracking-wide text-zinc-500">
156+
<tr>
157+
<th className="px-4 py-2 font-medium">Prompt</th>
158+
<th className="px-4 py-2 font-medium">A</th>
159+
<th className="px-4 py-2 font-medium">B</th>
160+
<th className="px-4 py-2 font-medium">Δ</th>
161+
<th className="px-4 py-2 font-medium">Diff</th>
162+
</tr>
163+
</thead>
164+
<tbody className="divide-y divide-zinc-100">
165+
{intersecting.map((m, i) => (
166+
<tr key={i}>
167+
<td className="max-w-md px-4 py-2 text-xs text-zinc-700">
168+
{truncate(m.prompt, 80)}
169+
</td>
170+
<td className="px-4 py-2">
171+
{m.meanA === null ? <Missing /> : <ScorePill score={m.meanA} />}
172+
</td>
173+
<td className="px-4 py-2">
174+
{m.meanB === null ? <Missing /> : <ScorePill score={m.meanB} />}
175+
</td>
176+
<td className="px-4 py-2 font-mono text-xs">
177+
<DeltaCell delta={m.delta ?? 0} />
178+
</td>
179+
<td className="px-4 py-2">
180+
{m.a && m.b ? (
181+
<Link
182+
href={`/compare/${m.a.event.id}/${m.b.event.id}`}
183+
className="text-xs text-indigo-700 hover:underline"
184+
>
185+
open ↗
186+
</Link>
187+
) : (
188+
<Missing />
189+
)}
190+
</td>
191+
</tr>
192+
))}
193+
</tbody>
194+
</table>
195+
)}
196+
</section>
197+
198+
{matches.length > intersecting.length && (
199+
<p className="mt-3 text-xs text-zinc-500">
200+
{matches.length - intersecting.length} prompt(s) only appear in one run — omitted from the matched-prompt table.
201+
</p>
202+
)}
203+
</div>
204+
);
205+
}
206+
207+
function RunMeta({
208+
side,
209+
runName,
210+
createdAt,
211+
suite,
212+
eventCount,
213+
}: {
214+
side: string;
215+
runName: string;
216+
createdAt: string;
217+
suite: string | null;
218+
eventCount: number;
219+
}) {
220+
return (
221+
<div className="rounded-lg border border-zinc-200 bg-white p-4">
222+
<div className="text-xs uppercase tracking-wide text-zinc-400">
223+
Run {side}
224+
</div>
225+
<div className="mt-1 font-medium text-zinc-900">{runName}</div>
226+
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
227+
<Field label="Created" value={formatTimestamp(createdAt)} />
228+
<Field label="Events" value={String(eventCount)} mono />
229+
{suite ? <Field label="Suite" value={suite} mono /> : null}
230+
</dl>
231+
</div>
232+
);
233+
}
234+
235+
function Field({
236+
label,
237+
value,
238+
mono = false,
239+
}: {
240+
label: string;
241+
value: string;
242+
mono?: boolean;
243+
}) {
244+
return (
245+
<div>
246+
<div className="text-[10px] uppercase tracking-wide text-zinc-400">
247+
{label}
248+
</div>
249+
<div className={mono ? "font-mono text-zinc-800" : "text-zinc-800"}>
250+
{value}
251+
</div>
252+
</div>
253+
);
254+
}
255+
256+
function PromptDeltas({
257+
title,
258+
tone,
259+
matches,
260+
emptyText,
261+
}: {
262+
title: string;
263+
tone: "good" | "bad";
264+
matches: ReturnType<typeof pairByPrompt>;
265+
emptyText: string;
266+
}) {
267+
const color =
268+
tone === "good"
269+
? "border-emerald-200 bg-emerald-50"
270+
: "border-rose-200 bg-rose-50";
271+
return (
272+
<div className={`rounded-lg border ${color} p-4`}>
273+
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-700">
274+
{title}
275+
</div>
276+
{matches.length === 0 ? (
277+
<p className="text-xs text-zinc-500">{emptyText}</p>
278+
) : (
279+
<ul className="space-y-1.5">
280+
{matches.map((m, i) => (
281+
<li key={i} className="text-xs text-zinc-700">
282+
<span className="font-mono">
283+
<DeltaCell delta={m.delta ?? 0} />
284+
</span>{" "}
285+
<span>{truncate(m.prompt, 90)}</span>
286+
</li>
287+
))}
288+
</ul>
289+
)}
290+
</div>
291+
);
292+
}
293+
294+
function ScorePill({ score }: { score: number }) {
295+
return (
296+
<span
297+
className={`inline-block rounded border px-2 py-0.5 font-mono text-xs ${scoreColor(
298+
score
299+
)}`}
300+
>
301+
{score.toFixed(2)}
302+
</span>
303+
);
304+
}
305+
306+
function DeltaCell({ delta }: { delta: number }) {
307+
if (delta === 0) return <span className="text-zinc-500">±0.00</span>;
308+
return (
309+
<span className={delta > 0 ? "text-emerald-700" : "text-rose-700"}>
310+
{delta > 0 ? "+" : ""}
311+
{delta.toFixed(2)}
312+
</span>
313+
);
314+
}
315+
316+
function Missing() {
317+
return <span className="text-zinc-300"></span>;
318+
}

web/app/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Link from "next/link";
22
import { listRuns } from "@/lib/api";
33
import { formatTimestamp, relativeTime, shortId } from "@/lib/format";
4+
import { RunCompareSelector } from "@/components/RunCompareSelector";
45

56
export const dynamic = "force-dynamic";
67

@@ -73,6 +74,10 @@ export default async function RunsPage() {
7374
</table>
7475
</div>
7576
)}
77+
78+
<RunCompareSelector
79+
runs={runs.map((r) => ({ id: r.id, name: r.name }))}
80+
/>
7681
</div>
7782
);
7883
}

web/components/JudgeHistogram.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { N_BUCKETS, type JudgeStats } from "@/lib/runStats";
2+
3+
export function JudgeHistogram({
4+
a,
5+
b,
6+
}: {
7+
a: JudgeStats | undefined;
8+
b: JudgeStats | undefined;
9+
}) {
10+
// Combine bucket counts so the bar chart axis is shared.
11+
const aDist = a?.distribution ?? new Array(N_BUCKETS).fill(0);
12+
const bDist = b?.distribution ?? new Array(N_BUCKETS).fill(0);
13+
const maxBucket = Math.max(...aDist, ...bDist, 1);
14+
15+
return (
16+
<div className="space-y-1.5">
17+
<div className="grid grid-cols-10 gap-px">
18+
{Array.from({ length: N_BUCKETS }, (_, i) => {
19+
const aCount = aDist[i];
20+
const bCount = bDist[i];
21+
const aHeight = (aCount / maxBucket) * 100;
22+
const bHeight = (bCount / maxBucket) * 100;
23+
return (
24+
<div
25+
key={i}
26+
className="relative h-14 bg-zinc-50 border border-zinc-100"
27+
title={`bucket ${(i / N_BUCKETS).toFixed(1)}${(
28+
(i + 1) /
29+
N_BUCKETS
30+
).toFixed(1)} · A=${aCount} · B=${bCount}`}
31+
>
32+
<div
33+
className="absolute bottom-0 left-0 w-1/2 bg-zinc-400"
34+
style={{ height: `${aHeight}%` }}
35+
/>
36+
<div
37+
className="absolute bottom-0 right-0 w-1/2 bg-indigo-500"
38+
style={{ height: `${bHeight}%` }}
39+
/>
40+
</div>
41+
);
42+
})}
43+
</div>
44+
<div className="flex justify-between text-[10px] font-mono text-zinc-400">
45+
<span>0.0</span>
46+
<span>0.5</span>
47+
<span>1.0</span>
48+
</div>
49+
</div>
50+
);
51+
}

0 commit comments

Comments
 (0)