Improve benchmark case details and score chart scaling (#146)

Co-authored-by: Yaowei Zheng <hiyouga@buaa.edu.cn>
This commit is contained in:
Jingzhe Xu
2026-07-31 20:42:32 +08:00
committed by GitHub
parent 5308002d72
commit 740f101242
19 changed files with 519 additions and 212 deletions
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type {
BenchmarkCaseSummary,
CaseMaterial,
WorkspaceFileEntry,
WorkspaceFilesResponse,
} from "@prismshadow/penguin-server/api";
@@ -46,6 +47,7 @@ const EXTERNAL_REF_RE = /^[a-z][a-z0-9+.-]*:/i;
const HIGHLIGHT_LIMIT = 64 * 1024;
interface Preview {
material: CaseMaterial;
path: string;
name: string;
kind: "text" | "md" | "image" | "pdf" | "unsupported";
@@ -62,6 +64,14 @@ interface Props {
caseSummary: BenchmarkCaseSummary;
}
interface MaterialGroupProps extends Props {
material: CaseMaterial;
label: string;
hiddenLabel?: string;
defaultOpen?: boolean;
onPreview: (material: CaseMaterial, path: string) => void;
}
function extOf(name: string): string {
const index = name.lastIndexOf(".");
return index >= 0 ? name.slice(index + 1).toLowerCase() : name.toLowerCase();
@@ -113,49 +123,185 @@ function languageFor(name: string): string {
);
}
export function BenchmarkStatementBrowser({ projectId, agentId, benchmarkId, caseSummary }: Props) {
function MaterialGroup({
projectId,
agentId,
benchmarkId,
caseSummary,
material,
label,
hiddenLabel,
defaultOpen = false,
onPreview,
}: MaterialGroupProps) {
const [open, setOpen] = useState(defaultOpen);
const [path, setPath] = useState("");
const [listing, setListing] = useState<WorkspaceFilesResponse | null>(null);
const [listError, setListError] = useState<string | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const initialReadmeOpened = useRef(false);
useEffect(() => {
if (!open) return;
setListing(null);
setListError(null);
let cancelled = false;
api
.listBenchmarkCaseFiles(projectId, agentId, benchmarkId, caseSummary.id, path, material)
.then((data) => {
if (cancelled) return;
setListing(data);
if (path === "" && !initialReadmeOpened.current) {
initialReadmeOpened.current = true;
const readme = data.entries.find(
(entry) => entry.kind === "file" && entry.name.toLowerCase() === "readme.md",
);
if (readme) onPreview(material, readme.name);
}
})
.catch((error: unknown) => {
if (!cancelled) setListError(apiErrorText(error));
});
return () => {
cancelled = true;
};
}, [projectId, agentId, benchmarkId, caseSummary.id, material, onPreview, open, path]);
const crumbs = path === "" ? [] : path.split("/");
const openEntry = (entry: WorkspaceFileEntry) => {
if (entry.kind === "dir") {
setPath(joinPath(path, entry.name));
return;
}
onPreview(material, joinPath(path, entry.name));
};
return (
<div className="border-b border-gray-200 last:border-b-0 dark:border-gray-800">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-800/60"
>
<span className="text-xs text-gray-400">{open ? "▾" : "▸"}</span>
<span className="min-w-0 flex-1 text-sm font-medium">{label}</span>
{hiddenLabel && (
<span className="shrink-0 rounded bg-gray-200/70 px-1.5 py-0.5 text-[10px] text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{hiddenLabel}
</span>
)}
</button>
{open && (
<div>
{crumbs.length > 0 && (
<div className="flex flex-wrap items-center gap-1 border-t border-gray-100 px-5 py-1.5 dark:border-gray-800/70">
<button
type="button"
onClick={() => setPath("")}
className="rounded px-1 py-0.5 text-xs text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
>
{label}
</button>
{crumbs.map((segment, index) => (
<span key={`${segment}-${index}`} className="flex min-w-0 items-center gap-1">
<span className="text-gray-300 dark:text-gray-700">/</span>
<button
type="button"
onClick={() => setPath(crumbs.slice(0, index + 1).join("/"))}
className="max-w-24 truncate rounded px-1 py-0.5 text-xs text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
>
{segment}
</button>
</span>
))}
</div>
)}
{listError && <p className="px-6 py-2 text-xs text-red-500">{listError}</p>}
{!listing && !listError && <SkeletonList rows={3} />}
{listing?.entries.length === 0 && (
<p className="px-6 py-2 text-xs text-gray-400">{S.files.empty}</p>
)}
{listing?.entries.map((entry) => (
<button
key={`${entry.kind}/${entry.name}`}
type="button"
onClick={() => openEntry(entry)}
className="flex w-full items-center gap-2 border-t border-gray-100 px-6 py-2 text-left hover:bg-gray-100 dark:border-gray-800/70 dark:hover:bg-gray-800/60"
>
<span className="text-sm text-gray-400">{entry.kind === "dir" ? "▸" : "·"}</span>
<span className="min-w-0 flex-1 truncate text-sm">{entry.name}</span>
{entry.kind === "file" && (
<span className="shrink-0 text-[11px] text-gray-400">
{formatBytes(entry.sizeBytes)}
</span>
)}
</button>
))}
</div>
)}
</div>
);
}
export function BenchmarkCaseBrowser({ projectId, agentId, benchmarkId, caseSummary }: Props) {
const [preview, setPreview] = useState<Preview | null>(null);
const previewRequest = useRef(0);
const fileUrl = useCallback(
(filePath: string, options?: { download?: boolean; preview?: boolean }) =>
api.benchmarkCaseFileUrl(projectId, agentId, benchmarkId, caseSummary.id, filePath, options),
(
material: CaseMaterial,
filePath: string,
options?: { download?: boolean; preview?: boolean },
) =>
api.benchmarkCaseFileUrl(
projectId,
agentId,
benchmarkId,
caseSummary.id,
filePath,
material,
options,
),
[projectId, agentId, benchmarkId, caseSummary.id],
);
const previewPath = useCallback(
async (filePath: string) => {
async (material: CaseMaterial, filePath: string) => {
const request = ++previewRequest.current;
const name = filePath.includes("/")
? filePath.slice(filePath.lastIndexOf("/") + 1)
: filePath;
const ext = extOf(name);
if (IMAGE_EXTS.has(ext)) {
setPreview({ path: filePath, name, kind: "image" });
setPreview({ material, path: filePath, name, kind: "image" });
return;
}
if (ext === "pdf") {
setPreview({ path: filePath, name, kind: "pdf" });
setPreview({ material, path: filePath, name, kind: "pdf" });
return;
}
const isMarkdown = ext === "md";
if (!TEXT_EXTS.has(ext)) {
setPreview({ path: filePath, name, kind: "unsupported" });
setPreview({ material, path: filePath, name, kind: "unsupported" });
return;
}
setPreview({ path: filePath, name, kind: isMarkdown ? "md" : "text", loading: true });
setPreview({
material,
path: filePath,
name,
kind: isMarkdown ? "md" : "text",
loading: true,
});
try {
const response = await fetch(fileUrl(filePath, { preview: true }), {
const response = await fetch(fileUrl(material, filePath, { preview: true }), {
credentials: "same-origin",
});
if (!response.ok) throw new Error(String(response.status));
const content = await response.text();
if (request !== previewRequest.current) return;
setPreview({
material,
path: filePath,
name,
kind: isMarkdown ? "md" : "text",
@@ -165,6 +311,7 @@ export function BenchmarkStatementBrowser({ projectId, agentId, benchmarkId, cas
} catch (error) {
if (request !== previewRequest.current) return;
setPreview({
material,
path: filePath,
name,
kind: isMarkdown ? "md" : "text",
@@ -175,48 +322,19 @@ export function BenchmarkStatementBrowser({ projectId, agentId, benchmarkId, cas
[fileUrl],
);
useEffect(() => {
setListing(null);
setListError(null);
let cancelled = false;
api
.listBenchmarkCaseFiles(projectId, agentId, benchmarkId, caseSummary.id, path)
.then((data) => {
if (cancelled) return;
setListing(data);
if (path === "" && !initialReadmeOpened.current) {
initialReadmeOpened.current = true;
const readme = data.entries.find(
(entry) => entry.kind === "file" && entry.name.toLowerCase() === "readme.md",
);
if (readme) void previewPath(readme.name);
}
})
.catch((error: unknown) => {
if (!cancelled) setListError(apiErrorText(error));
});
return () => {
cancelled = true;
};
}, [projectId, agentId, benchmarkId, caseSummary.id, path, previewPath]);
const crumbs = path === "" ? [] : path.split("/");
const downloadUrl = preview ? fileUrl(preview.path, { download: true }) : null;
const openEntry = (entry: WorkspaceFileEntry) => {
if (entry.kind === "dir") {
setPath(joinPath(path, entry.name));
return;
}
void previewPath(joinPath(path, entry.name));
};
const downloadUrl = preview ? fileUrl(preview.material, preview.path, { download: true }) : null;
const previewMaterialLabel =
preview?.material === "rubric" ? S.benchmark.rubric : S.benchmark.taskMaterials;
const markdownComponents: Components = {
img: ({ src, alt }) => (
<img
src={
typeof src === "string" && !EXTERNAL_REF_RE.test(src)
? fileUrl(resolveRelative(dirOf(preview?.path ?? ""), src))
? fileUrl(
preview?.material ?? "statement",
resolveRelative(dirOf(preview?.path ?? ""), src),
)
: src
}
alt={alt ?? ""}
@@ -234,12 +352,13 @@ export function BenchmarkStatementBrowser({ projectId, agentId, benchmarkId, cas
);
}
const target = resolveRelative(dirOf(preview?.path ?? ""), href);
const material = preview?.material ?? "statement";
return (
<a
href={fileUrl(target)}
href={fileUrl(material, target)}
onClick={(event) => {
event.preventDefault();
void previewPath(target);
void previewPath(material, target);
}}
>
{children}
@@ -251,49 +370,27 @@ export function BenchmarkStatementBrowser({ projectId, agentId, benchmarkId, cas
return (
<div className="grid min-h-[58vh] overflow-hidden rounded-md border border-gray-200 md:grid-cols-[240px_minmax(0,1fr)] dark:border-gray-800">
<aside className="border-b border-gray-200 bg-gray-50/60 md:border-b-0 md:border-r dark:border-gray-800 dark:bg-gray-950/30">
<div className="flex flex-wrap items-center gap-1 border-b border-gray-200 px-2 py-2 dark:border-gray-800">
<button
type="button"
onClick={() => setPath("")}
className="rounded px-1.5 py-0.5 text-xs text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
{S.benchmark.publicMaterials}
</button>
{crumbs.map((segment, index) => (
<span key={`${segment}-${index}`} className="flex min-w-0 items-center gap-1">
<span className="text-gray-300 dark:text-gray-700">/</span>
<button
type="button"
onClick={() => setPath(crumbs.slice(0, index + 1).join("/"))}
className="max-w-24 truncate rounded px-1 py-0.5 text-xs text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
{segment}
</button>
</span>
))}
</div>
<div className="max-h-44 overflow-y-auto md:max-h-[53vh]">
{listError && <p className="px-3 py-2 text-xs text-red-500">{listError}</p>}
{!listing && !listError && <SkeletonList rows={4} />}
{listing?.entries.length === 0 && (
<p className="px-3 py-2 text-xs text-gray-400">{S.files.empty}</p>
)}
{listing?.entries.map((entry) => (
<button
key={`${entry.kind}/${entry.name}`}
type="button"
onClick={() => openEntry(entry)}
className="flex w-full items-center gap-2 border-b border-gray-100 px-3 py-2 text-left hover:bg-gray-100 dark:border-gray-800/70 dark:hover:bg-gray-800/60"
>
<span className="text-sm text-gray-400">{entry.kind === "dir" ? "▸" : "·"}</span>
<span className="min-w-0 flex-1 truncate text-sm">{entry.name}</span>
{entry.kind === "file" && (
<span className="shrink-0 text-[11px] text-gray-400">
{formatBytes(entry.sizeBytes)}
</span>
)}
</button>
))}
<MaterialGroup
projectId={projectId}
agentId={agentId}
benchmarkId={benchmarkId}
caseSummary={caseSummary}
material="statement"
label={S.benchmark.taskMaterials}
defaultOpen
onPreview={previewPath}
/>
<MaterialGroup
projectId={projectId}
agentId={agentId}
benchmarkId={benchmarkId}
caseSummary={caseSummary}
material="rubric"
label={S.benchmark.rubric}
hiddenLabel={S.benchmark.agentHidden}
onPreview={previewPath}
/>
</div>
</aside>
@@ -301,7 +398,7 @@ export function BenchmarkStatementBrowser({ projectId, agentId, benchmarkId, cas
<div className="flex min-h-11 flex-wrap items-center gap-2 border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-xs text-gray-500">
{preview?.path ?? caseSummary.id}
{preview ? `${previewMaterialLabel} / ${preview.path}` : caseSummary.id}
</p>
</div>
{downloadUrl && preview && (
@@ -316,21 +413,21 @@ export function BenchmarkStatementBrowser({ projectId, agentId, benchmarkId, cas
</div>
<div className="max-h-[52vh] min-h-[52vh] overflow-auto p-3">
{!preview ? (
<p className="text-sm text-gray-400">{S.benchmark.statementUnavailable}</p>
<p className="text-sm text-gray-400">{S.benchmark.caseFileUnavailable}</p>
) : preview.loading ? (
<SkeletonList rows={8} />
) : preview.error ? (
<p className="text-sm text-red-500">{preview.error}</p>
) : preview.kind === "image" ? (
<img
src={fileUrl(preview.path)}
src={fileUrl(preview.material, preview.path)}
alt={preview.name}
loading="lazy"
className="max-w-full rounded-md border border-gray-200 dark:border-gray-800"
/>
) : preview.kind === "pdf" ? (
<iframe
src={fileUrl(preview.path)}
src={fileUrl(preview.material, preview.path)}
title={preview.name}
className="h-[50vh] w-full rounded-md border border-gray-200 dark:border-gray-800"
/>
@@ -8,13 +8,50 @@ export interface MetricSourceLike {
score: number;
}
/** Each Evaluation's Score; non-finite malformed values become chart gaps defensively. */
/** Each Evaluation's authoritative stored Score; non-finite malformed values become chart gaps. */
export function scoreValues(evaluations: readonly MetricSourceLike[]): (number | null)[] {
return evaluations.map((e) => {
return typeof e.score === "number" && Number.isFinite(e.score) ? e.score : null;
});
}
export interface ScoreScale {
min: number;
max: number;
ticks: number[];
}
const SCORE_MIN = 0;
const SCORE_MAX = 100;
const SCORE_PADDING = 10;
const SCORE_TICK_STEPS = [1, 2, 2.5, 5, 10, 20];
/**
* Dynamic Score domain: pad the observed min/max by 10, clamp to the valid
* 0..100 Score interval, then round outward to human-friendly ticks.
*/
export function scoreScale(values: readonly (number | null)[]): ScoreScale {
const present = values.filter(
(value): value is number => value !== null && value >= SCORE_MIN && value <= SCORE_MAX,
);
if (present.length === 0) {
return { min: SCORE_MIN, max: SCORE_MAX, ticks: [0, 20, 40, 60, 80, 100] };
}
const observedMin = Math.min(...present);
const observedMax = Math.max(...present);
const paddedMin = Math.max(SCORE_MIN, observedMin - SCORE_PADDING);
const paddedMax = Math.min(SCORE_MAX, observedMax + SCORE_PADDING);
const step = SCORE_TICK_STEPS.find((candidate) => (paddedMax - paddedMin) / candidate <= 5)!;
const min = Math.max(SCORE_MIN, Math.floor(paddedMin / step) * step);
const max = Math.min(SCORE_MAX, Math.ceil(paddedMax / step) * step);
const ticks = Array.from(
{ length: Math.round((max - min) / step) + 1 },
(_, index) => min + index * step,
);
return { min, max, ticks };
}
/** A single data point on the chart: original index (x-axis position) + value. */
export interface MetricPoint {
index: number;
@@ -41,11 +78,6 @@ export function lineSegments(values: readonly (number | null)[]): MetricPoint[][
return segments;
}
/** Y-axis upper bound: the max of value-bearing points (falls back to a tiny positive number when all values are missing / zero, to avoid dividing by zero in the coordinate system). */
export function metricMax(values: readonly (number | null)[]): number {
return Math.max(1e-9, ...values.filter((v): v is number => v !== null));
}
/** Minimal evaluation shape needed for series grouping (BenchmarkEvaluation is a superset). */
export interface ModelRefLike {
modelId?: string;
@@ -31,17 +31,17 @@ import { EmptyState } from "../../components/ui/empty-state";
import { Modal } from "../../components/ui/modal";
import { SkeletonList } from "../../components/ui/skeleton";
import { seriesColor } from "../../lib/category-colors";
import { makeGeom } from "../usage/chart-geom";
import { makeRangeGeom } from "../usage/chart-geom";
import { ChartFrame, useChartWidth } from "../usage/chart-svg";
import {
lineSegments,
metricMax,
modelSeries,
scoreScale,
scoreValues,
seriesValues,
} from "./benchmark-metrics";
import type { EvaluationSeries } from "./benchmark-metrics";
import { BenchmarkStatementBrowser } from "./benchmark-statement-browser";
import { BenchmarkCaseBrowser } from "./benchmark-case-browser";
interface Selection {
agentId: string;
@@ -142,9 +142,9 @@ function AgentNode({
}
/**
* Score-over-time line chart. Every Run, Case, and Evaluation uses the fixed 0..100 scale.
* Evaluations remain grouped by model ID and thinking level so a runtime change stays visible
* without adding other metric modes.
* Score-over-time line chart. Scores remain valid on 0..100, while the visible y-axis is padded
* around the observed range and clamped to those limits. Evaluations remain grouped by model ID
* and thinking level so a runtime change stays visible without adding other metric modes.
*/
function ScoreTrendChart({
evaluations,
@@ -157,7 +157,8 @@ function ScoreTrendChart({
const [ref, width] = useChartWidth();
const values = scoreValues(evaluations);
const geom = makeGeom(evaluations.length, Math.max(metricMax(values), 100), width);
const scale = scoreScale(values);
const geom = makeRangeGeom(evaluations.length, scale.min, scale.max, width);
const dates = evaluations.map((e) => formatDateTime(e.time));
return (
@@ -169,6 +170,7 @@ function ScoreTrendChart({
dates={dates}
hover={hover}
onHover={setHover}
yTicks={scale.ticks}
bubble={(i) => {
const e = evaluations[i]!;
const v = values[i] ?? null;
@@ -650,7 +652,7 @@ export function BenchmarkPage() {
widthClass="sm:max-w-6xl"
onClose={() => setOpenCaseId(null)}
>
<BenchmarkStatementBrowser
<BenchmarkCaseBrowser
projectId={projectId}
agentId={selection.agentId}
benchmarkId={bm.id}
+18 -7
View File
@@ -28,9 +28,10 @@ export const PAD_B = 22;
/** The daily Token chart's three buckets (bottom-to-top stacking order is output → cacheWrite → cacheRead). */
export type TokenBucketKey = "cacheRead" | "cacheWrite" | "output";
/** A chart's coordinate system: canvas width w, data point count n, y-axis upper bound max, and x()/y() mapping "index / value" to canvas coordinates. */
/** A chart's coordinate system: canvas width w, data point count n, y-axis bounds, and x()/y() mapping "index / value" to canvas coordinates. */
export interface ChartGeom {
n: number;
min: number;
max: number;
/** Total canvas width (= viewBox width = CSS pixel width). */
w: number;
@@ -42,25 +43,35 @@ export interface ChartGeom {
}
/**
* Build the coordinate system: x takes each cell's midpoint, y runs
* top-to-bottom with max as the full height. w is the canvas width (pixels).
* When `max <= 0` (no data / all zero), y always takes the baseline —
* callers already guarantee max > 0, but this is an exported public pure
* function, and without this guard a single 0 would turn the entire chart's coordinates into NaN / Infinity.
* Build the zero-baseline coordinate system: x takes each cell's midpoint,
* y runs top-to-bottom with max as the full height, and w is the canvas width
* in pixels. Invalid or zero-height ranges map values to the baseline rather
* than producing NaN / Infinity.
*/
export function makeGeom(n: number, max: number, w: number): ChartGeom {
return makeRangeGeom(n, 0, max, w);
}
/**
* Build a coordinate system with an explicit y-axis range. Score charts use
* this to zoom into the observed values; zero-baseline usage charts keep
* calling makeGeom above.
*/
export function makeRangeGeom(n: number, min: number, max: number, w: number): ChartGeom {
const innerW = Math.max(0, w - PAD_L - PAD_R);
const innerH = CHART_H - PAD_T - PAD_B;
const step = n > 0 ? innerW / n : innerW;
const range = max - min;
return {
n,
min,
max,
w,
innerW,
innerH,
step,
x: (i) => PAD_L + step * i + step / 2,
y: (v) => PAD_T + innerH * (1 - (max > 0 ? v / max : 0)),
y: (v) => PAD_T + innerH * (1 - (range > 0 ? (v - min) / range : 0)),
};
}
@@ -76,6 +76,7 @@ export function ChartFrame({
bubble,
hitLayer,
labels,
yTicks,
hoverLine = true,
scrollToEnd = false,
children,
@@ -95,6 +96,8 @@ export function ChartFrame({
hitLayer?: ReactNode;
/** Indices for x-axis labels (omit for the default first/middle/last sparse labeling): the bar chart's cells are each wide, so it can label more via autoLabelIdx. */
labels?: number[];
/** Explicit y-axis ticks. Omit to divide the geom's min..max range into four equal intervals. */
yTicks?: number[];
/** Hover vertical indicator line (drawn by default): the bar chart turns it off — the bar itself already indicates the x position, so an extra line is just noise. */
hoverLine?: boolean;
/** Scroll to the far right by default when the canvas is wider than the container: the daily chart shows the most recent days first (scroll left for earlier ones). */
@@ -102,8 +105,9 @@ export function ChartFrame({
/** Data marks: bars / line / area, drawn between the grid and the hit area. */
children?: ReactNode;
}) {
const { x, y, w, innerH, step, max } = geom;
const gridLevels = [0, 0.25, 0.5, 0.75, 1].map((f) => max * f);
const { x, y, w, innerH, step, min, max } = geom;
const gridLevels =
yTicks ?? [0, 0.25, 0.5, 0.75, 1].map((fraction) => min + (max - min) * fraction);
const labelIdx = labels ?? sparseLabelIdx(dates.length);
const scrollRef = useRef<HTMLDivElement>(null);