From 9f37777776eb231e621ad64227c3f12569ce163c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 08:11:47 +0000 Subject: [PATCH] feat(web): improve compare flows and research exploration - Carry index filters through model/preset compare pages and APIs - Add L/R compare columns on leaderboard and preset tables - Link question hub matrix cells to run compare when 2+ runs exist - Add benchmark picker on outliers page and stale-index banners on insight pages Co-authored-by: Eamon Boyle --- apps/web/app/api/leaderboard/compare/route.ts | 19 +++- apps/web/app/api/presets/compare/route.ts | 26 +++--- apps/web/app/counterfactual/page.tsx | 2 + apps/web/app/evidence/page.tsx | 2 + apps/web/app/globals.css | 16 ++++ apps/web/app/leaderboard/compare/page.tsx | 49 ++++++++-- apps/web/app/leaderboard/page.tsx | 75 ++++++++++++++-- apps/web/app/outliers/page.tsx | 89 ++++++++++++++++++- apps/web/app/presets/compare/page.tsx | 76 ++++++++++------ apps/web/app/presets/page.tsx | 80 +++++++++++++++-- apps/web/app/questions/view/page.tsx | 36 ++++++-- .../components/ActiveIndexFiltersNotice.tsx | 32 +++++++ .../components/CompareFormFilterFields.tsx | 24 +++++ apps/web/lib/compareFilterParams.test.ts | 52 +++++++++++ apps/web/lib/compareFilterParams.ts | 49 ++++++++++ apps/web/lib/modelCompare.ts | 9 +- apps/web/lib/presetCompare.ts | 5 +- apps/web/lib/questionExperimentMatrix.test.ts | 40 +++++++++ apps/web/lib/questionExperimentMatrix.ts | 79 ++++++++++------ 19 files changed, 655 insertions(+), 105 deletions(-) create mode 100644 apps/web/components/ActiveIndexFiltersNotice.tsx create mode 100644 apps/web/components/CompareFormFilterFields.tsx create mode 100644 apps/web/lib/compareFilterParams.test.ts create mode 100644 apps/web/lib/compareFilterParams.ts diff --git a/apps/web/app/api/leaderboard/compare/route.ts b/apps/web/app/api/leaderboard/compare/route.ts index ed4a099..5392359 100644 --- a/apps/web/app/api/leaderboard/compare/route.ts +++ b/apps/web/app/api/leaderboard/compare/route.ts @@ -2,7 +2,9 @@ import { csvCompareResponse, modelCompareToCsv, } from "../../../../lib/compareExport"; +import { pickIndexFilterParams } from "../../../../lib/compareFilterParams"; import { loadAnalysisIndex } from "../../../../lib/data"; +import { applyIndexFilters } from "../../../../lib/indexFilters"; import { buildModelComparePayload } from "../../../../lib/modelCompare"; export async function GET(request: Request) { @@ -10,6 +12,14 @@ export async function GET(request: Request) { const format = url.searchParams.get("format") ?? "json"; const left = url.searchParams.get("left"); const right = url.searchParams.get("right"); + const filterParams = pickIndexFilterParams({ + q: url.searchParams.get("q") ?? undefined, + model: url.searchParams.get("model") ?? undefined, + preset: url.searchParams.get("preset") ?? undefined, + fast: url.searchParams.get("fast") ?? undefined, + from: url.searchParams.get("from") ?? undefined, + to: url.searchParams.get("to") ?? undefined, + }); if (!left || !right) { return Response.json( @@ -18,15 +28,18 @@ export async function GET(request: Request) { ); } - const index = await loadAnalysisIndex(); - if (!index) { + const rawIndex = await loadAnalysisIndex(); + if (!rawIndex) { return Response.json( { error: "analysis-index not found" }, { status: 404 }, ); } - const compare = buildModelComparePayload(index, left, right); + const index = applyIndexFilters(rawIndex, filterParams); + const compare = buildModelComparePayload(index, left, right, { + linkFilters: filterParams, + }); if (!compare) { return Response.json( { error: "one or both models not found in analysis index" }, diff --git a/apps/web/app/api/presets/compare/route.ts b/apps/web/app/api/presets/compare/route.ts index 83e7260..799df66 100644 --- a/apps/web/app/api/presets/compare/route.ts +++ b/apps/web/app/api/presets/compare/route.ts @@ -2,21 +2,24 @@ import { csvCompareResponse, presetCompareToCsv, } from "../../../../lib/compareExport"; +import { pickIndexFilterParams } from "../../../../lib/compareFilterParams"; import { loadAnalysisIndex } from "../../../../lib/data"; +import { applyIndexFilters } from "../../../../lib/indexFilters"; import { buildPresetComparePayload } from "../../../../lib/presetCompare"; -function resolveFastMode(value: string | null): boolean | undefined { - if (value === "true") return true; - if (value === "false") return false; - return undefined; -} - export async function GET(request: Request) { const url = new URL(request.url); const format = url.searchParams.get("format") ?? "json"; const left = url.searchParams.get("left"); const right = url.searchParams.get("right"); - const fastMode = resolveFastMode(url.searchParams.get("fast")); + const filterParams = pickIndexFilterParams({ + q: url.searchParams.get("q") ?? undefined, + model: url.searchParams.get("model") ?? undefined, + preset: url.searchParams.get("preset") ?? undefined, + fast: url.searchParams.get("fast") ?? undefined, + from: url.searchParams.get("from") ?? undefined, + to: url.searchParams.get("to") ?? undefined, + }); if (!left || !right) { return Response.json( @@ -25,15 +28,18 @@ export async function GET(request: Request) { ); } - const index = await loadAnalysisIndex(); - if (!index) { + const rawIndex = await loadAnalysisIndex(); + if (!rawIndex) { return Response.json( { error: "analysis-index not found" }, { status: 404 }, ); } - const compare = buildPresetComparePayload(index, left, right, { fastMode }); + const index = applyIndexFilters(rawIndex, filterParams); + const compare = buildPresetComparePayload(index, left, right, { + linkFilters: filterParams, + }); if (!compare) { return Response.json( { error: "one or both presets not found in analysis index" }, diff --git a/apps/web/app/counterfactual/page.tsx b/apps/web/app/counterfactual/page.tsx index a288607..f5a2cf3 100644 --- a/apps/web/app/counterfactual/page.tsx +++ b/apps/web/app/counterfactual/page.tsx @@ -6,6 +6,7 @@ import { ResponsiveTable, TruncateText, } from "../../components/ResponsiveTable"; +import { StaleIndexBanner } from "../../components/StaleIndexBanner"; import { loadAnalysisIndex, loadRunArtifacts } from "../../lib/data"; import { aggregateCounterfactualStrings, @@ -81,6 +82,7 @@ export default async function CounterfactualExplorerPage({ return (
+

Counterfactual failure modes

diff --git a/apps/web/app/evidence/page.tsx b/apps/web/app/evidence/page.tsx index c947e18..b7a9f59 100644 --- a/apps/web/app/evidence/page.tsx +++ b/apps/web/app/evidence/page.tsx @@ -7,6 +7,7 @@ import { ResponsiveTable, TruncateText, } from "../../components/ResponsiveTable"; +import { StaleIndexBanner } from "../../components/StaleIndexBanner"; import { loadAnalysisIndex, loadRunArtifacts } from "../../lib/data"; import { aggregateEvidenceStrings, @@ -88,6 +89,7 @@ export default async function EvidenceExplorerPage({ return (

+

Evidence planning

diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index d2d7506..bce90ee 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1990,6 +1990,22 @@ pre { line-height: 1.35; } +.experiment-matrix-cell-wrap { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.experiment-matrix-compare { + color: var(--color-accent); + font-size: 0.75rem; + text-decoration: none; +} + +.experiment-matrix-compare:hover { + text-decoration: underline; +} + /* Heatmap improvements */ .benchmark-heatmap-wrap { overflow-x: auto; diff --git a/apps/web/app/leaderboard/compare/page.tsx b/apps/web/app/leaderboard/compare/page.tsx index 187aa75..43fc4a9 100644 --- a/apps/web/app/leaderboard/compare/page.tsx +++ b/apps/web/app/leaderboard/compare/page.tsx @@ -1,11 +1,18 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { ActiveIndexFiltersNotice } from "../../../components/ActiveIndexFiltersNotice"; import { CompareDeltaChart } from "../../../components/charts/CompareDeltaChart"; import { CompareExportLink } from "../../../components/CompareExportLink"; +import { CompareFormFilterFields } from "../../../components/CompareFormFilterFields"; import { CompareSwapLink } from "../../../components/CompareSwapLink"; import { MetricCard } from "../../../components/MetricCard"; import { ModelFilterSelect } from "../../../components/ModelFilterSelect"; import { loadAnalysisIndex } from "../../../lib/data"; +import { + indexFilterExtraParams, + pickIndexFilterParams, +} from "../../../lib/compareFilterParams"; +import { applyIndexFilters } from "../../../lib/indexFilters"; import { buildModelLeaderboard } from "../../../lib/modelLeaderboard"; import { buildModelComparePayload } from "../../../lib/modelCompare"; import { buildLeaderboardCompareSuggestions } from "../../../lib/leaderboardCompareSuggestions"; @@ -17,6 +24,12 @@ export const metadata: Metadata = { type ModelCompareSearchParams = { left?: string; right?: string; + q?: string; + model?: string; + preset?: string; + fast?: string; + from?: string; + to?: string; }; function formatMetric(value: number | null, digits = 2) { @@ -35,9 +48,9 @@ export default async function ModelComparePage({ searchParams: Promise; }) { const params = await searchParams; - const index = await loadAnalysisIndex(); + const rawIndex = await loadAnalysisIndex(); - if (!index) { + if (!rawIndex) { return (

Compare models

@@ -52,20 +65,30 @@ export default async function ModelComparePage({ ); } - const models = buildModelLeaderboard(index).map((row) => row.model); + const filterParams = pickIndexFilterParams(params); + const filterExtraParams = indexFilterExtraParams(filterParams); + const index = applyIndexFilters(rawIndex, filterParams); + const models = buildModelLeaderboard(index, { + linkFilters: filterParams, + }).map((row) => row.model); const leftModel = (params.left ?? "").trim(); const rightModel = (params.right ?? "").trim(); const compare = leftModel && rightModel - ? buildModelComparePayload(index, leftModel, rightModel) + ? buildModelComparePayload(index, leftModel, rightModel, { + linkFilters: filterParams, + }) : null; const suggestions = buildLeaderboardCompareSuggestions( - buildModelLeaderboard(index).map((row) => ({ - key: row.model, - runCount: row.runCount, - })), + buildModelLeaderboard(index, { linkFilters: filterParams }).map( + (row) => ({ + key: row.model, + runCount: row.runCount, + }), + ), { left: leftModel, right: rightModel }, "/leaderboard/compare", + filterExtraParams, ); return ( @@ -89,7 +112,15 @@ export default async function ModelComparePage({
+ +
+
) : null} {compare ? ( @@ -120,6 +152,7 @@ export default async function ModelComparePage({ apiPath="/api/leaderboard/compare" left={leftModel} right={rightModel} + extraParams={filterExtraParams} /> ) : null}
diff --git a/apps/web/app/leaderboard/page.tsx b/apps/web/app/leaderboard/page.tsx index 792bfa2..f092680 100644 --- a/apps/web/app/leaderboard/page.tsx +++ b/apps/web/app/leaderboard/page.tsx @@ -7,6 +7,7 @@ import { loadAnalysisIndex } from "../../lib/data"; import { applyIndexFilters, collectIndexFacets } from "../../lib/indexFilters"; import { buildModelLeaderboard } from "../../lib/modelLeaderboard"; import { buildQueryString } from "../../lib/listPagination"; +import { buildLeaderboardSideCompareHref } from "../../lib/compareFilterParams"; export const metadata: Metadata = { title: "Model leaderboard", @@ -84,7 +85,7 @@ export default async function ModelLeaderboardPage({ Critique issues Compare models @@ -184,6 +185,40 @@ export default async function ModelLeaderboardPage({ ), }, + { + key: "compare", + label: "Compare", + cellClass: "cell-actions", + hideOnMobile: true, + render: (row) => { + const model = (row as { model: string }) + .model; + return ( + + + L + + + R + + + ); + }, + }, ]} data={rows.map((row) => ({ ...row, @@ -203,12 +238,38 @@ export default async function ModelLeaderboardPage({ }))} getRowId={(row) => (row as { model: string }).model} renderCardActions={(row) => ( - - View runs - + <> + + View runs + + + Set left + + + Set right + + )} /> )} diff --git a/apps/web/app/outliers/page.tsx b/apps/web/app/outliers/page.tsx index 860cb84..50c4970 100644 --- a/apps/web/app/outliers/page.tsx +++ b/apps/web/app/outliers/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { InsightFilterCard } from "../../components/InsightFilterCard"; import { MetricCard } from "../../components/MetricCard"; import { ResponsiveTable } from "../../components/ResponsiveTable"; +import { StaleIndexBanner } from "../../components/StaleIndexBanner"; import { loadAnalysisIndex } from "../../lib/data"; import { applyIndexFilters, collectIndexFacets } from "../../lib/indexFilters"; import { buildQueryString } from "../../lib/listPagination"; @@ -49,12 +50,16 @@ export default async function OutliersPage({ const { models, presets } = collectIndexFacets(rawIndex); const index = applyIndexFilters(rawIndex, params); const benchmarkId = (params.benchmark ?? "").trim(); + const benchmarkOptions = [...index.benchmarks].sort((a, b) => + b.createdAt.localeCompare(a.createdAt), + ); const rows = await buildOutlierExplorerRows(index, { benchmarkId: benchmarkId || undefined, }); return (
+

Outlier runs

@@ -75,6 +80,75 @@ export default async function OutliersPage({

+ +

Benchmark scope

+

+ Narrow outliers to a single parent benchmark, or browse all + benchmarks with outlier data. +

+
+ +
+ {params.q ? ( + + ) : null} + {params.model ? ( + + ) : null} + {params.preset ? ( + + ) : null} + {params.fast ? ( + + ) : null} + {params.from ? ( + + ) : null} + {params.to ? ( + + ) : null} +
+ + {benchmarkId ? ( + + Clear benchmark + + ) : null} +
+ + {benchmarkId} .{" "} - + Clear benchmark filter

diff --git a/apps/web/app/presets/compare/page.tsx b/apps/web/app/presets/compare/page.tsx index 4489581..a1f413b 100644 --- a/apps/web/app/presets/compare/page.tsx +++ b/apps/web/app/presets/compare/page.tsx @@ -1,12 +1,21 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { ActiveIndexFiltersNotice } from "../../../components/ActiveIndexFiltersNotice"; import { CompareDeltaChart } from "../../../components/charts/CompareDeltaChart"; import { CompareExportLink } from "../../../components/CompareExportLink"; +import { CompareFormFilterFields } from "../../../components/CompareFormFilterFields"; import { CompareSwapLink } from "../../../components/CompareSwapLink"; import { MetricCard } from "../../../components/MetricCard"; import { PresetFilterSelect } from "../../../components/PresetFilterSelect"; import { loadAnalysisIndex } from "../../../lib/data"; -import { collectIndexFacets } from "../../../lib/indexFilters"; +import { + indexFilterExtraParams, + pickIndexFilterParams, +} from "../../../lib/compareFilterParams"; +import { + applyIndexFilters, + collectIndexFacets, +} from "../../../lib/indexFilters"; import { buildPresetComparePayload } from "../../../lib/presetCompare"; import { buildPresetLeaderboard } from "../../../lib/presetLeaderboard"; import { buildLeaderboardCompareSuggestions } from "../../../lib/leaderboardCompareSuggestions"; @@ -18,15 +27,14 @@ export const metadata: Metadata = { type PresetCompareSearchParams = { left?: string; right?: string; + q?: string; + model?: string; + preset?: string; fast?: string; + from?: string; + to?: string; }; -function resolveFastMode(value: string | undefined): boolean | undefined { - if (value === "true") return true; - if (value === "false") return false; - return undefined; -} - function formatMetric(value: number | null, digits = 2) { return typeof value === "number" ? value.toFixed(digits) : "—"; } @@ -43,10 +51,9 @@ export default async function PresetComparePage({ searchParams: Promise; }) { const params = await searchParams; - const fastMode = resolveFastMode(params.fast); - const index = await loadAnalysisIndex(); + const rawIndex = await loadAnalysisIndex(); - if (!index) { + if (!rawIndex) { return (

Compare presets

@@ -61,25 +68,28 @@ export default async function PresetComparePage({ ); } - const { presets } = collectIndexFacets(index); + const filterParams = pickIndexFilterParams(params); + const filterExtraParams = indexFilterExtraParams(filterParams); + const index = applyIndexFilters(rawIndex, filterParams); + const { presets } = collectIndexFacets(rawIndex); const leftPreset = (params.left ?? "").trim(); const rightPreset = (params.right ?? "").trim(); const compare = leftPreset && rightPreset ? buildPresetComparePayload(index, leftPreset, rightPreset, { - fastMode, + linkFilters: filterParams, }) : null; - const suggestionExtraParams = - fastMode !== undefined ? { fast: String(fastMode) } : undefined; const suggestions = buildLeaderboardCompareSuggestions( - buildPresetLeaderboard(index, { fastMode }).map((row) => ({ - key: row.preset, - runCount: row.runCount, - })), + buildPresetLeaderboard(index, { linkFilters: filterParams }).map( + (row) => ({ + key: row.preset, + runCount: row.runCount, + }), + ), { left: leftPreset, right: rightPreset }, "/presets/compare", - suggestionExtraParams, + filterExtraParams, ); return ( @@ -109,7 +119,23 @@ export default async function PresetComparePage({ + +
+
) : null} {compare ? ( @@ -152,11 +174,7 @@ export default async function PresetComparePage({ apiPath="/api/presets/compare" left={leftPreset} right={rightPreset} - extraParams={ - fastMode !== undefined - ? { fast: String(fastMode) } - : undefined - } + extraParams={filterExtraParams} /> ) : null}
diff --git a/apps/web/app/presets/page.tsx b/apps/web/app/presets/page.tsx index 123ef4d..4e04917 100644 --- a/apps/web/app/presets/page.tsx +++ b/apps/web/app/presets/page.tsx @@ -2,10 +2,12 @@ import type { Metadata } from "next"; import Link from "next/link"; import { InsightFilterCard } from "../../components/InsightFilterCard"; import { ResponsiveTable } from "../../components/ResponsiveTable"; +import { StaleIndexBanner } from "../../components/StaleIndexBanner"; import { loadAnalysisIndex } from "../../lib/data"; import { applyIndexFilters, collectIndexFacets } from "../../lib/indexFilters"; import { buildPresetLeaderboard } from "../../lib/presetLeaderboard"; import { buildQueryString } from "../../lib/listPagination"; +import { buildLeaderboardSideCompareHref } from "../../lib/compareFilterParams"; export const metadata: Metadata = { title: "Preset leaderboard", @@ -54,6 +56,7 @@ export default async function PresetLeaderboardPage({ return (
+

Preset leaderboard

@@ -78,7 +81,10 @@ export default async function PresetLeaderboardPage({ Experiment catalog - + Compare presets

@@ -170,6 +176,40 @@ export default async function PresetLeaderboardPage({ ), }, + { + key: "compare", + label: "Compare", + cellClass: "cell-actions", + hideOnMobile: true, + render: (row) => { + const preset = (row as { preset: string }) + .preset; + return ( + + + L + + + R + + + ); + }, + }, ]} data={rows.map((row) => ({ preset: row.preset, @@ -186,12 +226,38 @@ export default async function PresetLeaderboardPage({ }))} getRowId={(row) => (row as { preset: string }).preset} renderCardActions={(row) => ( - - View runs - + <> + + View runs + + + Set left + + + Set right + + )} /> )} diff --git a/apps/web/app/questions/view/page.tsx b/apps/web/app/questions/view/page.tsx index 1b16dd6..f44b661 100644 --- a/apps/web/app/questions/view/page.tsx +++ b/apps/web/app/questions/view/page.tsx @@ -24,6 +24,7 @@ import { } from "../../../lib/questionGroups"; import { buildQuestionExperimentMatrix, + buildMatrixCellRunCompareHref, lookupMatrixCell, } from "../../../lib/questionExperimentMatrix"; import { @@ -472,7 +473,8 @@ export default async function QuestionHubPage({

Model × preset coverage for this question — cell counts show runs and benchmarks. Click a cell to open the - latest trace. + latest trace, or compare when two or more runs exist in + a cell.

@@ -517,6 +519,11 @@ export default async function QuestionHubPage({ ] .filter(Boolean) .join(" · "); + const compareHref = + buildMatrixCellRunCompareHref( + cell, + question, + ); const href = cell.latestRunId != null ? `/runs/${cell.latestRunId}` @@ -527,13 +534,26 @@ export default async function QuestionHubPage({ return (
{href ? ( - - {label} - +
+ + {label} + + {compareHref ? ( + + Compare + + ) : null} +
) : ( {label} diff --git a/apps/web/components/ActiveIndexFiltersNotice.tsx b/apps/web/components/ActiveIndexFiltersNotice.tsx new file mode 100644 index 0000000..0d12f3b --- /dev/null +++ b/apps/web/components/ActiveIndexFiltersNotice.tsx @@ -0,0 +1,32 @@ +import Link from "next/link"; +import type { ArtifactFilterParams } from "../lib/data"; +import { hasActiveIndexFilters } from "../lib/indexFilters"; + +type ActiveIndexFiltersNoticeProps = { + filters: ArtifactFilterParams; + filteredRunCount: number; + totalRunCount: number; + clearHref: string; +}; + +export function ActiveIndexFiltersNotice({ + filters, + filteredRunCount, + totalRunCount, + clearHref, +}: ActiveIndexFiltersNoticeProps) { + if (!hasActiveIndexFilters(filters)) { + return null; + } + + return ( +
+

+ Comparing within a filtered index — {filteredRunCount} of{" "} + {totalRunCount} indexed run{totalRunCount === 1 ? "" : "s"}. + Metrics reflect only runs matching the active filters.{" "} + Clear filters +

+
+ ); +} diff --git a/apps/web/components/CompareFormFilterFields.tsx b/apps/web/components/CompareFormFilterFields.tsx new file mode 100644 index 0000000..5ccb00d --- /dev/null +++ b/apps/web/components/CompareFormFilterFields.tsx @@ -0,0 +1,24 @@ +import type { ArtifactFilterParams } from "../lib/data"; + +type CompareFormFilterFieldsProps = { + filters: ArtifactFilterParams; +}; + +export function CompareFormFilterFields({ + filters, +}: CompareFormFilterFieldsProps) { + const entries = Object.entries(filters).filter( + (entry): entry is [string, string] => + typeof entry[1] === "string" && entry[1].length > 0, + ); + + if (entries.length === 0) return null; + + return ( + <> + {entries.map(([name, value]) => ( + + ))} + + ); +} diff --git a/apps/web/lib/compareFilterParams.test.ts b/apps/web/lib/compareFilterParams.test.ts new file mode 100644 index 0000000..7108657 --- /dev/null +++ b/apps/web/lib/compareFilterParams.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + buildLeaderboardSideCompareHref, + indexFilterExtraParams, + pickIndexFilterParams, +} from "./compareFilterParams"; + +describe("compareFilterParams", () => { + it("picks index filter fields from search params", () => { + expect( + pickIndexFilterParams({ + left: "gpt-4", + q: "threat", + model: "gpt", + preset: "standard", + fast: "true", + from: "2026-01-01", + }), + ).toEqual({ + q: "threat", + model: "gpt", + preset: "standard", + fast: "true", + from: "2026-01-01", + to: undefined, + }); + }); + + it("builds compare href with filters and side", () => { + expect( + buildLeaderboardSideCompareHref( + "/leaderboard/compare", + "left", + "gpt-a", + { q: "risk", fast: "false" }, + ), + ).toBe("/leaderboard/compare?q=risk&fast=false&left=gpt-a"); + }); + + it("omits empty filter values from extra params", () => { + expect( + indexFilterExtraParams({ + q: "ai", + model: "", + preset: "standard", + }), + ).toEqual({ + q: "ai", + preset: "standard", + }); + }); +}); diff --git a/apps/web/lib/compareFilterParams.ts b/apps/web/lib/compareFilterParams.ts new file mode 100644 index 0000000..ed07fc2 --- /dev/null +++ b/apps/web/lib/compareFilterParams.ts @@ -0,0 +1,49 @@ +import type { ArtifactFilterParams } from "./data"; +import { hasActiveIndexFilters } from "./indexFilters"; +import { buildQueryString } from "./listPagination"; + +export function pickIndexFilterParams( + params: Record, +): ArtifactFilterParams { + return { + q: params.q, + model: params.model, + preset: params.preset, + fast: params.fast, + from: params.from, + to: params.to, + }; +} + +export function indexFilterExtraParams( + filters: ArtifactFilterParams, +): Record { + const extra: Record = {}; + for (const [key, value] of Object.entries(filters)) { + if (typeof value === "string" && value.length > 0) { + extra[key] = value; + } + } + return extra; +} + +export function buildLeaderboardSideCompareHref( + basePath: "/leaderboard/compare" | "/presets/compare", + side: "left" | "right", + key: string, + filters: ArtifactFilterParams = {}, +): string { + return `${basePath}${buildQueryString( + { + ...filters, + [side]: key, + }, + {}, + )}`; +} + +export function hasCompareFilterContext( + filters: ArtifactFilterParams, +): boolean { + return hasActiveIndexFilters(filters); +} diff --git a/apps/web/lib/modelCompare.ts b/apps/web/lib/modelCompare.ts index 19fce5b..5ea6a18 100644 --- a/apps/web/lib/modelCompare.ts +++ b/apps/web/lib/modelCompare.ts @@ -1,6 +1,7 @@ import type { AnalysisIndex } from "./data"; import { buildModelLeaderboard, + type LeaderboardFilterOptions, type ModelLeaderboardRow, } from "./modelLeaderboard"; @@ -27,10 +28,11 @@ function delta(right: number | null, left: number | null): number | null { export function findModelLeaderboardRow( index: AnalysisIndex, model: string, + opts: LeaderboardFilterOptions = {}, ): ModelLeaderboardRow | null { const normalized = model.trim(); if (!normalized) return null; - const rows = buildModelLeaderboard(index); + const rows = buildModelLeaderboard(index, opts); return ( rows.find( (row) => row.model.toLowerCase() === normalized.toLowerCase(), @@ -42,9 +44,10 @@ export function buildModelComparePayload( index: AnalysisIndex, leftModel: string, rightModel: string, + opts: LeaderboardFilterOptions = {}, ): ModelComparePayload | null { - const left = findModelLeaderboardRow(index, leftModel); - const right = findModelLeaderboardRow(index, rightModel); + const left = findModelLeaderboardRow(index, leftModel, opts); + const right = findModelLeaderboardRow(index, rightModel, opts); if (!left || !right) return null; return { diff --git a/apps/web/lib/presetCompare.ts b/apps/web/lib/presetCompare.ts index db254a1..4ef135f 100644 --- a/apps/web/lib/presetCompare.ts +++ b/apps/web/lib/presetCompare.ts @@ -1,6 +1,7 @@ import type { AnalysisIndex } from "./data"; import { buildPresetLeaderboard, + type PresetLeaderboardFilterOptions, type PresetLeaderboardRow, } from "./presetLeaderboard"; @@ -25,7 +26,7 @@ function delta(right: number | null, left: number | null): number | null { export function findPresetLeaderboardRow( index: AnalysisIndex, preset: string, - opts: { fastMode?: boolean } = {}, + opts: PresetLeaderboardFilterOptions = {}, ): PresetLeaderboardRow | null { const normalized = preset.trim(); if (!normalized) return null; @@ -41,7 +42,7 @@ export function buildPresetComparePayload( index: AnalysisIndex, leftPreset: string, rightPreset: string, - opts: { fastMode?: boolean } = {}, + opts: PresetLeaderboardFilterOptions = {}, ): PresetComparePayload | null { const left = findPresetLeaderboardRow(index, leftPreset, opts); const right = findPresetLeaderboardRow(index, rightPreset, opts); diff --git a/apps/web/lib/questionExperimentMatrix.test.ts b/apps/web/lib/questionExperimentMatrix.test.ts index 20d9e99..604d400 100644 --- a/apps/web/lib/questionExperimentMatrix.test.ts +++ b/apps/web/lib/questionExperimentMatrix.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { BenchmarkArtifact, RunArtifact } from "./data"; import { + buildMatrixCellRunCompareHref, buildQuestionExperimentMatrix, lookupMatrixCell, } from "./questionExperimentMatrix"; @@ -93,6 +94,45 @@ describe("buildQuestionExperimentMatrix", () => { expect(deepGptA?.latestRunId).toBe("run_b"); }); + it("tracks second latest run for compare links", () => { + const matrix = buildQuestionExperimentMatrix( + [ + makeRun( + "run_old", + "gpt-a", + "standard", + "2026-01-01T00:00:00.000Z", + ), + makeRun( + "run_new", + "gpt-a", + "standard", + "2026-01-03T00:00:00.000Z", + ), + ], + [], + ); + + const cell = lookupMatrixCell(matrix, "gpt-a", "standard"); + expect(cell).toMatchObject({ + runCount: 2, + latestRunId: "run_new", + secondLatestRunId: "run_old", + }); + expect(buildMatrixCellRunCompareHref(cell!, "Shared?")).toBe( + "/runs/compare?left=run_old&right=run_new&question=Shared%3F", + ); + }); + + it("returns null compare href when fewer than two runs", () => { + const matrix = buildQuestionExperimentMatrix( + [makeRun("run_a", "gpt-a", "standard", "2026-01-01T00:00:00.000Z")], + [], + ); + const cell = lookupMatrixCell(matrix, "gpt-a", "standard"); + expect(buildMatrixCellRunCompareHref(cell!, "Q?")).toBeNull(); + }); + it("returns empty axes when no artifacts", () => { const matrix = buildQuestionExperimentMatrix([], []); expect(matrix.models).toEqual([]); diff --git a/apps/web/lib/questionExperimentMatrix.ts b/apps/web/lib/questionExperimentMatrix.ts index c423382..447b8b4 100644 --- a/apps/web/lib/questionExperimentMatrix.ts +++ b/apps/web/lib/questionExperimentMatrix.ts @@ -6,6 +6,7 @@ export type ExperimentMatrixCell = { runCount: number; benchmarkCount: number; latestRunId: string | null; + secondLatestRunId: string | null; latestBenchmarkId: string | null; }; @@ -19,21 +20,35 @@ function cellKey(model: string, preset: string): string { return `${model}\0${preset}`; } +type MatrixCellAccumulator = { + runCount: number; + benchmarkCount: number; + latestRunId: string | null; + latestRunAt: string; + secondLatestRunId: string | null; + secondLatestRunAt: string; + latestBenchmarkId: string | null; + latestBenchmarkAt: string; +}; + +function emptyCellAccumulator(): MatrixCellAccumulator { + return { + runCount: 0, + benchmarkCount: 0, + latestRunId: null, + latestRunAt: "", + secondLatestRunId: null, + secondLatestRunAt: "", + latestBenchmarkId: null, + latestBenchmarkAt: "", + }; +} + export function buildQuestionExperimentMatrix( runs: RunArtifact[], benchmarks: BenchmarkArtifact[], ): QuestionExperimentMatrix { - const cellMap = new Map< - string, - { - runCount: number; - benchmarkCount: number; - latestRunId: string | null; - latestRunAt: string; - latestBenchmarkId: string | null; - latestBenchmarkAt: string; - } - >(); + const cellMap = new Map(); const models = new Set(); const presets = new Set(); @@ -44,18 +59,18 @@ export function buildQuestionExperimentMatrix( models.add(model); presets.add(preset); const key = cellKey(model, preset); - const existing = cellMap.get(key) ?? { - runCount: 0, - benchmarkCount: 0, - latestRunId: null, - latestRunAt: "", - latestBenchmarkId: null, - latestBenchmarkAt: "", - }; + const existing = cellMap.get(key) ?? emptyCellAccumulator(); existing.runCount += 1; if (run.metadata.createdAt >= existing.latestRunAt) { + if (existing.latestRunId) { + existing.secondLatestRunId = existing.latestRunId; + existing.secondLatestRunAt = existing.latestRunAt; + } existing.latestRunAt = run.metadata.createdAt; existing.latestRunId = run.id; + } else if (run.metadata.createdAt >= existing.secondLatestRunAt) { + existing.secondLatestRunId = run.id; + existing.secondLatestRunAt = run.metadata.createdAt; } cellMap.set(key, existing); } @@ -66,14 +81,7 @@ export function buildQuestionExperimentMatrix( models.add(model); presets.add(preset); const key = cellKey(model, preset); - const existing = cellMap.get(key) ?? { - runCount: 0, - benchmarkCount: 0, - latestRunId: null, - latestRunAt: "", - latestBenchmarkId: null, - latestBenchmarkAt: "", - }; + const existing = cellMap.get(key) ?? emptyCellAccumulator(); existing.benchmarkCount += 1; if (benchmark.metadata.createdAt >= existing.latestBenchmarkAt) { existing.latestBenchmarkAt = benchmark.metadata.createdAt; @@ -96,6 +104,7 @@ export function buildQuestionExperimentMatrix( runCount: entry.runCount, benchmarkCount: entry.benchmarkCount, latestRunId: entry.latestRunId, + secondLatestRunId: entry.secondLatestRunId, latestBenchmarkId: entry.latestBenchmarkId, }); } @@ -119,3 +128,19 @@ export function lookupMatrixCell( ) ?? null ); } + +export function buildMatrixCellRunCompareHref( + cell: ExperimentMatrixCell, + question: string, +): string | null { + if (!cell.latestRunId || !cell.secondLatestRunId) { + return null; + } + + const params = new URLSearchParams({ + left: cell.secondLatestRunId, + right: cell.latestRunId, + question, + }); + return `/runs/compare?${params.toString()}`; +}