diff --git a/apps/web/app/benchmarks/[id]/page.tsx b/apps/web/app/benchmarks/[id]/page.tsx index cf3ab1e..f79dd98 100644 --- a/apps/web/app/benchmarks/[id]/page.tsx +++ b/apps/web/app/benchmarks/[id]/page.tsx @@ -1,7 +1,11 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { loadBenchmarkById, loadBenchmarkPairsById } from "../../../lib/data"; +import { + loadBenchmarkById, + loadBenchmarkPairsById, + loadAnalysisIndex, +} from "../../../lib/data"; import { buildBenchmarkRunRoster, sortBenchmarkRunRoster, @@ -14,6 +18,11 @@ import { ResponsiveTable } from "../../../components/ResponsiveTable"; import { TruncateText } from "../../../components/ResponsiveTable"; import { findMostSimilarPeerRunId } from "../../../lib/benchmarkPeers"; import { inferModeLabel } from "../../../lib/modeLabeler"; +import { + buildBenchmarkIndexLookup, + resolveIndexedModeLabel, +} from "../../../lib/benchmarkIndexLookup"; +import { buildBenchmarkOutlierLookup } from "../../../lib/outlierLookup"; import { questionHubHref } from "../../../lib/questionGroups"; import { extractBenchmarkSummaryDisplay, @@ -46,12 +55,21 @@ export default async function BenchmarkDetailPage({ params: Promise<{ id: string }>; }) { const { id } = await params; - const benchmark = await loadBenchmarkById(id); + const [benchmark, index] = await Promise.all([ + loadBenchmarkById(id), + loadAnalysisIndex(), + ]); if (!benchmark) { notFound(); } + const benchmarkIndex = index + ? buildBenchmarkIndexLookup(index).get(benchmark.id) + : null; + const outlierLookup = buildBenchmarkOutlierLookup(index, benchmark.id); + const outlierCount = outlierLookup.size; + const runIds = benchmark.payload.runIds ?? []; const pairsData = await loadBenchmarkPairsById(id); const pairs = @@ -78,6 +96,7 @@ export default async function BenchmarkDetailPage({ peerCompareHref: peerRunId ? `/runs/compare?left=${row.runId}&right=${peerRunId}` : null, + outlier: outlierLookup.get(row.runId) ?? null, }; }); const thresholdCounts = [ @@ -140,6 +159,20 @@ export default async function BenchmarkDetailPage({ > Compare as right + + Pairwise explorer + + {outlierCount > 0 ? ( + + Outlier runs ({outlierCount}) + + ) : null} @@ -351,6 +384,7 @@ export default async function BenchmarkDetailPage({ thresholdCounts={thresholdCounts} similarityPairs={pairs} runs={benchmark.payload.runs} + runIds={rosterRunIds} />
@@ -405,6 +439,29 @@ export default async function BenchmarkDetailPage({ : value.toFixed(3); }, }, + { + key: "outlier", + label: "Outlier", + helpKey: "zScore", + hideOnMobile: true, + render: (row) => { + const outlier = ( + row as { + outlier: { + zScore: number; + } | null; + } + ).outlier; + if (!outlier) return "—"; + return ( + + Yes + + ); + }, + }, { key: "open", label: "Open", @@ -567,7 +624,11 @@ export default async function BenchmarkDetailPage({ ]} data={modes.map((mode, idx) => ({ modeIndex: idx, - label: inferModeLabel(mode.exemplarPreview), + label: resolveIndexedModeLabel( + benchmarkIndex?.modeLabels ?? [], + idx, + inferModeLabel(mode.exemplarPreview), + ), size: mode.size, exemplarPreview: mode.exemplarPreview, memberRunIds: mode.members diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 671ca84..d2d7506 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -2013,6 +2013,21 @@ pre { transform: scale(1.05); } +.benchmark-heatmap-run-link, +.benchmark-heatmap-cell-link { + color: inherit; + text-decoration: none; +} + +.benchmark-heatmap-cell-link { + display: block; +} + +.benchmark-heatmap-run-link:hover, +.benchmark-heatmap-cell-link:hover { + text-decoration: underline; +} + @media (max-width: 600px) { .trace-timeline::before { left: 0.95rem; diff --git a/apps/web/app/questions/view/page.tsx b/apps/web/app/questions/view/page.tsx index bb58dbf..343efb7 100644 --- a/apps/web/app/questions/view/page.tsx +++ b/apps/web/app/questions/view/page.tsx @@ -18,7 +18,10 @@ import { loadRunsByQuestion, } from "../../../lib/data"; import { buildQueryString } from "../../../lib/listPagination"; -import { questionHubHref } from "../../../lib/questionGroups"; +import { + questionHubHref, + listQuestionInsightLinks, +} from "../../../lib/questionGroups"; import { buildQuestionExperimentMatrix, lookupMatrixCell, @@ -410,7 +413,28 @@ export default async function QuestionHubPage({ critique rollups for this question.

- ) : null} + ) : ( +
+

Research insights

+

+ Open filtered insight explorers scoped to this question. +

+
+ {listQuestionInsightLinks(question).map((link) => ( + + {link.label} + + ))} +
+
+ )} {experimentMatrix.models.length > 0 && experimentMatrix.presets.length > 0 ? ( @@ -495,24 +519,6 @@ export default async function QuestionHubPage({ - {indexMetrics?.runsWithQualityScores ? ( -
- - Quality insights for this question - -
- ) : null} ) : null} diff --git a/apps/web/components/charts/BenchmarkDetailCharts.tsx b/apps/web/components/charts/BenchmarkDetailCharts.tsx index 61d3c38..abd55ac 100644 --- a/apps/web/components/charts/BenchmarkDetailCharts.tsx +++ b/apps/web/components/charts/BenchmarkDetailCharts.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { Bar, @@ -18,8 +19,13 @@ type BenchmarkDetailChartsProps = { thresholdCounts: Array<{ threshold: string; modeCount: number }>; similarityPairs?: Array<{ i: number; j: number; similarity: number }>; runs: number; + runIds?: string[]; }; +function buildPairCompareHref(runIdA: string, runIdB: string): string { + return `/runs/compare?left=${runIdA}&right=${runIdB}`; +} + function similarityColor(value: number) { if (value >= 0.95) return "rgba(34, 197, 94, 0.5)"; if (value >= 0.9) return "rgba(34, 197, 94, 0.4)"; @@ -35,6 +41,7 @@ export function BenchmarkDetailCharts({ thresholdCounts, similarityPairs = [], runs, + runIds = [], }: BenchmarkDetailChartsProps) { const [pairs, setPairs] = useState(similarityPairs); const [pairsSource, setPairsSource] = useState<"artifact" | "chunk">( @@ -176,41 +183,93 @@ export function BenchmarkDetailCharts({

- source: {pairsSource} ({pairs.length} pairwise entries) + source: {pairsSource} ({pairs.length} pairwise entries). + Click off-diagonal cells to compare runs.

- ))} + {Array.from({ length: runs }, (_, idx) => { + const runId = runIds[idx]; + const label = `r${idx}`; + return ( + + ); + })} {matrix.map((row, i) => ( - - {row.map((value, j) => ( - - ))} + + {row.map((value, j) => { + const runIdA = runIds[i]; + const runIdB = runIds[j]; + const compareHref = + i !== j && runIdA && runIdB + ? buildPairCompareHref( + runIdA, + runIdB, + ) + : null; + const cellContent = value.toFixed(3); + const cellStyle = { + background: + i === j + ? "var(--color-bg-elevated)" + : similarityColor(value), + color: "var(--color-text-primary)", + minWidth: 44, + }; + const title = `r${i} vs r${j}: ${value.toFixed(3)}`; + + return ( + + ); + })} ))} diff --git a/apps/web/lib/benchmarkIndexLookup.test.ts b/apps/web/lib/benchmarkIndexLookup.test.ts index e479fb8..adc71d1 100644 --- a/apps/web/lib/benchmarkIndexLookup.test.ts +++ b/apps/web/lib/benchmarkIndexLookup.test.ts @@ -3,6 +3,7 @@ import type { AnalysisIndex } from "./data"; import { buildBenchmarkIndexLookup, formatTopModeLabel, + resolveIndexedModeLabel, } from "./benchmarkIndexLookup"; function sampleIndex(): AnalysisIndex { @@ -65,4 +66,13 @@ describe("benchmarkIndexLookup", () => { const labels = lookup.get("bench-1")!.modeLabels; expect(formatTopModeLabel(labels)).toBe("Yes with safeguards"); }); + + it("prefers indexed mode labels over fallback text", () => { + const lookup = buildBenchmarkIndexLookup(sampleIndex()); + const labels = lookup.get("bench-1")!.modeLabels; + expect(resolveIndexedModeLabel(labels, 0, "fallback")).toBe( + "Yes with safeguards", + ); + expect(resolveIndexedModeLabel(labels, 9, "fallback")).toBe("fallback"); + }); }); diff --git a/apps/web/lib/benchmarkIndexLookup.ts b/apps/web/lib/benchmarkIndexLookup.ts index 4fbccfd..2cb6c01 100644 --- a/apps/web/lib/benchmarkIndexLookup.ts +++ b/apps/web/lib/benchmarkIndexLookup.ts @@ -20,6 +20,17 @@ export function buildBenchmarkIndexLookup( return lookup; } +export function resolveIndexedModeLabel( + modeLabels: BenchmarkIndexSnapshot["modeLabels"], + modeIndex: number, + fallbackLabel: string, +): string { + const indexed = modeLabels.find((row) => row.modeIndex === modeIndex); + const label = indexed?.label?.trim(); + if (label) return label; + return fallbackLabel; +} + export function formatTopModeLabel( modeLabels: BenchmarkIndexSnapshot["modeLabels"], maxLength = 48, diff --git a/apps/web/lib/outlierLookup.test.ts b/apps/web/lib/outlierLookup.test.ts index 61f4341..bf1f499 100644 --- a/apps/web/lib/outlierLookup.test.ts +++ b/apps/web/lib/outlierLookup.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import type { AnalysisIndex } from "./data"; import { buildOutlierRunIdSet, + buildBenchmarkOutlierLookup, buildRunOutlierContext, findRunOutlierEntry, + listOutliersForBenchmark, } from "./outlierLookup"; vi.mock("./data", async (importOriginal) => { @@ -40,6 +42,12 @@ function sampleIndex(): AnalysisIndex { avgSimilarity: 0.42, zScore: -2.1, }, + { + benchmarkId: "bench_2", + runId: "run_other", + avgSimilarity: 0.5, + zScore: -1.8, + }, ], }, skipped: [], @@ -77,4 +85,21 @@ describe("outlierLookup", () => { peerCompareHref: "/runs/compare?left=run_outlier&right=run_b", }); }); + + it("lists outliers for a benchmark", () => { + const rows = listOutliersForBenchmark(sampleIndex(), "bench_1"); + expect(rows).toEqual([ + { + runId: "run_outlier", + avgSimilarity: 0.42, + zScore: -2.1, + }, + ]); + }); + + it("builds a benchmark outlier lookup map", () => { + const lookup = buildBenchmarkOutlierLookup(sampleIndex(), "bench_1"); + expect(lookup.get("run_outlier")?.zScore).toBe(-2.1); + expect(lookup.has("run_other")).toBe(false); + }); }); diff --git a/apps/web/lib/outlierLookup.ts b/apps/web/lib/outlierLookup.ts index 2e93de3..574fc69 100644 --- a/apps/web/lib/outlierLookup.ts +++ b/apps/web/lib/outlierLookup.ts @@ -30,6 +30,39 @@ export function buildOutlierRunIdSet(index: AnalysisIndex | null): Set { return new Set(index.aggregates.outlierRuns?.map((row) => row.runId) ?? []); } +export type BenchmarkOutlierEntry = { + runId: string; + avgSimilarity: number; + zScore: number; +}; + +export function listOutliersForBenchmark( + index: AnalysisIndex | null, + benchmarkId: string, +): BenchmarkOutlierEntry[] { + if (!index) return []; + return ( + index.aggregates.outlierRuns + ?.filter((row) => row.benchmarkId === benchmarkId) + .map(({ runId, avgSimilarity, zScore }) => ({ + runId, + avgSimilarity, + zScore, + })) ?? [] + ); +} + +export function buildBenchmarkOutlierLookup( + index: AnalysisIndex | null, + benchmarkId: string, +): Map { + const lookup = new Map(); + for (const entry of listOutliersForBenchmark(index, benchmarkId)) { + lookup.set(entry.runId, entry); + } + return lookup; +} + export async function buildRunOutlierContext( index: AnalysisIndex | null, runId: string, diff --git a/apps/web/lib/questionGroups.test.ts b/apps/web/lib/questionGroups.test.ts index bc90f7e..c1963b5 100644 --- a/apps/web/lib/questionGroups.test.ts +++ b/apps/web/lib/questionGroups.test.ts @@ -3,7 +3,9 @@ import type { BenchmarkArtifact, RunArtifact } from "./data"; import { filterArtifactsForQuestionGroups, groupArtifactsByQuestion, + listQuestionInsightLinks, questionHubHref, + questionInsightHref, } from "./questionGroups"; function makeRun(id: string, question: string, createdAt: string): RunArtifact { @@ -58,6 +60,30 @@ describe("questionHubHref", () => { }); }); +describe("questionInsightHref", () => { + it("builds filtered insight explorer links", () => { + expect(questionInsightHref("Is AI safe?", "drift")).toBe( + "/drift?q=Is+AI+safe%3F", + ); + expect(questionInsightHref("Is AI safe?", "outliers")).toBe( + "/outliers?q=Is+AI+safe%3F", + ); + }); + + it("lists all insight shortcuts for a question", () => { + const links = listQuestionInsightLinks("Q?"); + expect(links.map((link) => link.page)).toEqual([ + "quality", + "drift", + "evidence", + "counterfactual", + "issues", + "outliers", + ]); + expect(links[0]?.href).toBe("/quality?q=Q%3F"); + }); +}); + describe("groupArtifactsByQuestion", () => { it("groups runs and benchmarks by question", () => { const groups = groupArtifactsByQuestion( diff --git a/apps/web/lib/questionGroups.ts b/apps/web/lib/questionGroups.ts index 3d6fc5a..67ce289 100644 --- a/apps/web/lib/questionGroups.ts +++ b/apps/web/lib/questionGroups.ts @@ -24,6 +24,41 @@ export function questionHubHref(question: string): string { return `/questions/view?${new URLSearchParams({ question }).toString()}`; } +export type QuestionInsightPage = + | "quality" + | "drift" + | "evidence" + | "counterfactual" + | "issues" + | "outliers"; + +const QUESTION_INSIGHT_PAGES: Array<{ + page: QuestionInsightPage; + label: string; +}> = [ + { page: "quality", label: "Quality insights" }, + { page: "drift", label: "Confidence drift" }, + { page: "evidence", label: "Evidence planning" }, + { page: "counterfactual", label: "Counterfactual modes" }, + { page: "issues", label: "Critique issues" }, + { page: "outliers", label: "Outlier runs" }, +]; + +export function questionInsightHref( + question: string, + page: QuestionInsightPage, +): string { + return `/${page}?${new URLSearchParams({ q: question }).toString()}`; +} + +export function listQuestionInsightLinks(question: string) { + return QUESTION_INSIGHT_PAGES.map(({ page, label }) => ({ + page, + label, + href: questionInsightHref(question, page), + })); +} + export function filterArtifactsForQuestionGroups( runs: RunArtifact[], benchmarks: BenchmarkArtifact[],
- {Array.from({ length: runs }, (_, idx) => ( - r{idx} + {runId ? ( + + {label} + + ) : ( + label + )} +
r{i} - {value.toFixed(3)} - + {runIds[i] ? ( + + r{i} + + ) : ( + `r${i}` + )} + + {compareHref ? ( + + {cellContent} + + ) : ( + cellContent + )} +