Improve benchmark case details and score chart scaling (#146)
Co-authored-by: Yaowei Zheng <hiyouga@buaa.edu.cn>
This commit is contained in:
+191
-94
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user