diff --git a/apps/web/app/api/timing/route.ts b/apps/web/app/api/timing/route.ts index b690e2a..38ba12f 100644 --- a/apps/web/app/api/timing/route.ts +++ b/apps/web/app/api/timing/route.ts @@ -1,7 +1,11 @@ import { filterRunArtifacts, loadRunArtifacts } from "../../../lib/data"; -import { agentTimingToCsv } from "../../../lib/listExport"; +import { + agentTimingToCsv, + slowestRunTimingToCsv, +} from "../../../lib/listExport"; import { buildAgentTimingStats, + buildSlowestRunRows, summarizeStepTiming, } from "../../../lib/stepTiming"; @@ -24,14 +28,22 @@ export async function GET(request: Request) { const runs = filterRunArtifacts(allRuns, filters); const summary = summarizeStepTiming(runs); const rows = buildAgentTimingStats(runs); + const slowestRuns = buildSlowestRunRows(runs); if (format === "csv") { - const csv = agentTimingToCsv(rows); + const scope = url.searchParams.get("scope") ?? "agents"; + const csv = + scope === "runs" + ? slowestRunTimingToCsv(slowestRuns) + : agentTimingToCsv(rows); + const filename = + scope === "runs" + ? "pipeline-timing-slowest-runs.csv" + : "pipeline-timing.csv"; return new Response(csv, { headers: { "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": - 'attachment; filename="pipeline-timing.csv"', + "Content-Disposition": `attachment; filename="${filename}"`, "Cache-Control": "public, max-age=60", }, }); @@ -43,5 +55,6 @@ export async function GET(request: Request) { filters, summary, rows, + slowestRuns, }); } diff --git a/apps/web/app/counterfactual/page.tsx b/apps/web/app/counterfactual/page.tsx index f5a2cf3..0eaaa3b 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 { AnalysisFilterContextCard } from "../../components/AnalysisFilterContextCard"; import { StaleIndexBanner } from "../../components/StaleIndexBanner"; import { loadAnalysisIndex, loadRunArtifacts } from "../../lib/data"; import { @@ -83,6 +84,7 @@ export default async function CounterfactualExplorerPage({ return (
+

Counterfactual failure modes

diff --git a/apps/web/app/drift/page.tsx b/apps/web/app/drift/page.tsx index 01b8394..ed9aa6e 100644 --- a/apps/web/app/drift/page.tsx +++ b/apps/web/app/drift/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { AnalysisFilterContextCard } from "../../components/AnalysisFilterContextCard"; import { CritiqueConfidenceScatter } from "../../components/charts/CritiqueConfidenceScatter"; +import { DriftTrendCharts } from "../../components/charts/DriftTrendCharts"; import { InsightFilterCard } from "../../components/InsightFilterCard"; import { MetricCard } from "../../components/MetricCard"; import { StaleIndexBanner } from "../../components/StaleIndexBanner"; @@ -13,6 +15,7 @@ import { buildConfidenceDriftRows, summarizeConfidenceDrift, } from "../../lib/confidenceDrift"; +import { buildDriftTrendSeries } from "../../lib/driftTrends"; import { applyIndexFilters, collectIndexFacets } from "../../lib/indexFilters"; import { buildQueryString } from "../../lib/listPagination"; @@ -63,7 +66,8 @@ export default async function ConfidenceDriftPage({ const index = applyIndexFilters(rawIndex, params); const summary = summarizeConfidenceDrift(index); const rows = buildConfidenceDriftRows(index, { runs: allRuns }); - const scatterPoints = rows + const driftTrendSeries = buildDriftTrendSeries(rows); + const solverScatterPoints = rows .filter( (row) => typeof row.maxSeverity === "number" && @@ -72,12 +76,24 @@ export default async function ConfidenceDriftPage({ .map((row) => ({ runId: row.runId, maxSeverity: row.maxSeverity as number, - solverToRevisionDelta: row.solverToRevisionDelta as number, + delta: row.solverToRevisionDelta as number, + })); + const revisionScatterPoints = rows + .filter( + (row) => + typeof row.maxSeverity === "number" && + typeof row.revisionToSynthesizerDelta === "number", + ) + .map((row) => ({ + runId: row.runId, + maxSeverity: row.maxSeverity as number, + delta: row.revisionToSynthesizerDelta as number, })); return (

+

Confidence drift

@@ -162,11 +178,30 @@ export default async function ConfidenceDriftPage({ />

- {scatterPoints.length > 0 ? ( - + + + {solverScatterPoints.length > 0 || + revisionScatterPoints.length > 0 ? ( +
+ {solverScatterPoints.length > 0 ? ( + + ) : null} + {revisionScatterPoints.length > 0 ? ( + + ) : null} +
) : null} {rows.length === 0 ? ( diff --git a/apps/web/app/evidence/page.tsx b/apps/web/app/evidence/page.tsx index b7a9f59..ddbce04 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 { AnalysisFilterContextCard } from "../../components/AnalysisFilterContextCard"; import { StaleIndexBanner } from "../../components/StaleIndexBanner"; import { loadAnalysisIndex, loadRunArtifacts } from "../../lib/data"; import { @@ -90,6 +91,7 @@ export default async function EvidenceExplorerPage({ return (
+

Evidence planning

diff --git a/apps/web/app/issues/page.tsx b/apps/web/app/issues/page.tsx index 3ce0d9e..88de08a 100644 --- a/apps/web/app/issues/page.tsx +++ b/apps/web/app/issues/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { InsightFilterCard } from "../../components/InsightFilterCard"; +import { AnalysisFilterContextCard } from "../../components/AnalysisFilterContextCard"; import { StaleIndexBanner } from "../../components/StaleIndexBanner"; import { ResponsiveTable, @@ -123,6 +124,7 @@ export default async function IssuesExplorerPage({ return (

+

Critique issues

diff --git a/apps/web/app/outliers/page.tsx b/apps/web/app/outliers/page.tsx index 50c4970..77ae2f3 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 { AnalysisFilterContextCard } from "../../components/AnalysisFilterContextCard"; import { StaleIndexBanner } from "../../components/StaleIndexBanner"; import { loadAnalysisIndex } from "../../lib/data"; import { applyIndexFilters, collectIndexFacets } from "../../lib/indexFilters"; @@ -60,6 +61,7 @@ export default async function OutliersPage({ return (

+

Outlier runs

diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 8d3b704..e9d15c9 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { CollapsibleFilterCard } from "../components/CollapsibleFilterCard"; +import { AnalysisFilterContextCard } from "../components/AnalysisFilterContextCard"; import { MetricCard } from "../components/MetricCard"; import { ModelFilterSelect } from "../components/ModelFilterSelect"; import { PresetFilterSelect } from "../components/PresetFilterSelect"; @@ -285,6 +286,7 @@ export default async function OverviewPage({ const filterEntries = Object.entries(index.filterContext ?? {}).filter( ([, value]) => value !== undefined && value !== null && value !== "", ); + const hasFilterContext = filterEntries.length > 0; const { models, presets } = collectIndexFacets(index); const trendFilters = { preset: params.preset, @@ -590,25 +592,10 @@ export default async function OverviewPage({ />

- {filterEntries.length > 0 ? ( -
-

Analysis filter context

-

- This index was generated from a filtered artifact - subset. -

- ({ - key, - value: String(value), - }))} - getRowId={(row) => row.key as string} - /> -
+ {hasFilterContext ? ( + ) : null}
diff --git a/apps/web/app/pairs/page.tsx b/apps/web/app/pairs/page.tsx index cf92b4d..39af69f 100644 --- a/apps/web/app/pairs/page.tsx +++ b/apps/web/app/pairs/page.tsx @@ -9,6 +9,7 @@ import { import { collectArtifactFacets } from "../../lib/artifactFacets"; import { loadBenchmarkArtifacts, + loadDataStatus, type ArtifactFilterParams, } from "../../lib/data"; import { buildQueryString } from "../../lib/listPagination"; @@ -32,17 +33,43 @@ export default async function PairsExplorerPage({ searchParams: Promise; }) { const params = await searchParams; - const allBenchmarks = await loadBenchmarkArtifacts(); + const [allBenchmarks, status] = await Promise.all([ + loadBenchmarkArtifacts(), + loadDataStatus(), + ]); const { models, presets } = collectArtifactFacets([], allBenchmarks); const allSummaries = await buildBenchmarkPairSummaries(allBenchmarks); const summaries = filterPairSummaries(allSummaries, params); const selectedBenchmark = (params.benchmark ?? "").trim(); + const benchmarkOptions = [...allBenchmarks].sort((a, b) => + b.metadata.createdAt.localeCompare(a.metadata.createdAt), + ); const details = selectedBenchmark ? await buildBenchmarkPairDetails(selectedBenchmark, allBenchmarks) : { summary: null, pairs: [] }; return (
+ {status.artifactCounts.benchmarks > 0 && + !status.hasBenchmarkPairs ? ( +
+

+ Dedicated pairs export not found +

+

+ Benchmark artifacts exist but{" "} + analysis-benchmark-pairs.json is missing. + The explorer can fall back to in-artifact pairs, but the + dedicated export enables richer pairwise data. +

+ + Check data status + +
+ ) : null}

Benchmark pairwise similarity

@@ -65,6 +92,75 @@ export default async function PairsExplorerPage({

+
+

Benchmark scope

+

+ Jump directly to pairwise details for one benchmark while + keeping list filters applied. +

+
+ +
+ {params.q ? ( + + ) : null} + {params.model ? ( + + ) : null} + {params.preset ? ( + + ) : null} + {params.fast ? ( + + ) : null} + {params.from ? ( + + ) : null} + {params.to ? ( + + ) : null} +
+ + {selectedBenchmark ? ( + + Clear benchmark + + ) : null} +
+
+ +

Quality insights

diff --git a/apps/web/app/timing/page.tsx b/apps/web/app/timing/page.tsx index 94109e2..21a3e93 100644 --- a/apps/web/app/timing/page.tsx +++ b/apps/web/app/timing/page.tsx @@ -2,7 +2,10 @@ import type { Metadata } from "next"; import Link from "next/link"; import { InsightFilterCard } from "../../components/InsightFilterCard"; import { MetricCard } from "../../components/MetricCard"; -import { ResponsiveTable } from "../../components/ResponsiveTable"; +import { + ResponsiveTable, + TruncateText, +} from "../../components/ResponsiveTable"; import { collectArtifactFacets } from "../../lib/artifactFacets"; import { filterRunArtifacts, @@ -12,6 +15,7 @@ import { } from "../../lib/data"; import { buildAgentTimingStats, + buildSlowestRunRows, formatDurationMs, summarizeStepTiming, } from "../../lib/stepTiming"; @@ -38,6 +42,7 @@ export default async function PipelineTimingPage({ const runs = filterRunArtifacts(allRuns, params); const summary = summarizeStepTiming(runs); const rows = buildAgentTimingStats(runs); + const slowestRuns = buildSlowestRunRows(runs); const filtersActive = runs.length !== allRuns.length; if (allRuns.length === 0) { @@ -109,7 +114,14 @@ export default async function PipelineTimingPage({ className="button secondary" download="pipeline-timing.csv" > - Export CSV + Export agent CSV + + + Export slowest runs CSV

) : null} @@ -192,6 +204,98 @@ export default async function PipelineTimingPage({ )}
+ +
+

Slowest runs

+ {slowestRuns.length === 0 ? ( +

+ {runs.length === 0 + ? "No runs match the current filters." + : "No step timestamps found. Timings appear when run artifacts include createdAt and completedAt on each step."} +

+ ) : ( + ( + + ), + }, + { + key: "model", + label: "Model", + hideOnMobile: true, + }, + { + key: "pipelinePreset", + label: "Preset", + hideOnMobile: true, + helpKey: "preset", + }, + { + key: "timedStepCount", + label: "Timed steps", + hideOnMobile: true, + }, + { + key: "totalDurationMs", + label: "Total duration", + render: (row) => + formatDurationMs( + (row as { totalDurationMs: number }) + .totalDurationMs, + ), + }, + { + key: "avgStepDurationMs", + label: "Avg step", + hideOnMobile: true, + render: (row) => + formatDurationMs( + (row as { avgStepDurationMs: number }) + .avgStepDurationMs, + ), + }, + { + key: "trace", + label: "Open", + cellClass: "cell-actions", + render: (row) => ( + + Trace + + ), + }, + ]} + data={slowestRuns} + getRowId={(row) => (row as { runId: string }).runId} + renderCardActions={(row) => ( + + View trace + + )} + /> + )} +
); } diff --git a/apps/web/components/AnalysisFilterContextCard.tsx b/apps/web/components/AnalysisFilterContextCard.tsx new file mode 100644 index 0000000..7f89b20 --- /dev/null +++ b/apps/web/components/AnalysisFilterContextCard.tsx @@ -0,0 +1,64 @@ +import { ResponsiveTable } from "./ResponsiveTable"; +import type { AnalysisIndex } from "../lib/data"; + +const FILTER_LABELS: Record = { + questionContains: "Question contains", + modelContains: "Model contains", + presetEquals: "Preset", + fastMode: "Fast mode", + createdAfter: "Created after", + createdBefore: "Created before", +}; + +function formatFilterValue(key: string, value: unknown): string { + if (key === "fastMode") { + return value === true + ? "true" + : value === false + ? "false" + : String(value); + } + return String(value); +} + +export function AnalysisFilterContextCard({ + filterContext, +}: { + filterContext: AnalysisIndex["filterContext"]; +}) { + const filterEntries = Object.entries(filterContext ?? {}).filter( + ([, value]) => value !== undefined && value !== null && value !== "", + ); + + if (filterEntries.length === 0) { + return null; + } + + return ( +
+

Analysis filter context

+

+ This index was generated from a filtered artifact subset. KPIs + and tables on this page reflect that scope, not the full + repository. +

+ + FILTER_LABELS[(row as { key: string }).key] ?? + (row as { key: string }).key, + }, + { key: "value", label: "Value" }, + ]} + data={filterEntries.map(([key, value]) => ({ + key, + value: formatFilterValue(key, value), + }))} + getRowId={(row) => (row as { key: string }).key} + /> +
+ ); +} diff --git a/apps/web/components/charts/CritiqueConfidenceScatter.tsx b/apps/web/components/charts/CritiqueConfidenceScatter.tsx index cbc02ac..ac8265d 100644 --- a/apps/web/components/charts/CritiqueConfidenceScatter.tsx +++ b/apps/web/components/charts/CritiqueConfidenceScatter.tsx @@ -16,7 +16,7 @@ import { InfoTooltip } from "../InfoTooltip"; export type CritiqueConfidencePoint = { runId: string; maxSeverity: number; - solverToRevisionDelta: number; + delta: number; }; type CritiqueConfidenceScatterProps = { @@ -25,6 +25,7 @@ type CritiqueConfidenceScatterProps = { helpKey?: string; hint?: string; height?: number; + yAxisName?: string; }; export function CritiqueConfidenceScatter({ @@ -33,6 +34,7 @@ export function CritiqueConfidenceScatter({ helpKey = "severityVsConfidenceDelta", hint = "Click a point to open the run trace.", height = 360, + yAxisName = "solverToRevisionDelta", }: CritiqueConfidenceScatterProps) { const router = useRouter(); const [mounted, setMounted] = useState(false); @@ -43,7 +45,7 @@ export function CritiqueConfidenceScatter({ const scatterRows = points.map((row) => ({ severity: row.maxSeverity, - delta: row.solverToRevisionDelta, + delta: row.delta, runId: row.runId, })); @@ -91,7 +93,7 @@ export function CritiqueConfidenceScatter({ diff --git a/apps/web/components/charts/DriftTrendCharts.tsx b/apps/web/components/charts/DriftTrendCharts.tsx new file mode 100644 index 0000000..bb5a625 --- /dev/null +++ b/apps/web/components/charts/DriftTrendCharts.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { InfoTooltip } from "../InfoTooltip"; +import type { DriftTrendPoint } from "../../lib/driftTrends"; + +type DriftTrendChartsProps = { + series: DriftTrendPoint[]; +}; + +export function DriftTrendCharts({ series }: DriftTrendChartsProps) { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + if (series.length === 0) { + return null; + } + + if (!mounted) { + return
; + } + + return ( +
+

+ Confidence drift over time + +

+ + + + + + [ + typeof value === "number" + ? value.toFixed(3) + : value, + name, + ]} + /> + + + + + + +
+ ); +} diff --git a/apps/web/components/charts/OverviewCharts.tsx b/apps/web/components/charts/OverviewCharts.tsx index 019dcab..7609b43 100644 --- a/apps/web/components/charts/OverviewCharts.tsx +++ b/apps/web/components/charts/OverviewCharts.tsx @@ -48,7 +48,7 @@ export function OverviewCharts({ .map((row) => ({ runId: row.runId, maxSeverity: row.maxSeverity as number, - solverToRevisionDelta: row.solverToRevisionDelta as number, + delta: row.solverToRevisionDelta as number, })); if (!mounted) { diff --git a/apps/web/lib/confidenceDrift.ts b/apps/web/lib/confidenceDrift.ts index 659817c..a9399ec 100644 --- a/apps/web/lib/confidenceDrift.ts +++ b/apps/web/lib/confidenceDrift.ts @@ -3,6 +3,7 @@ import type { AnalysisIndex, RunArtifact } from "./data"; export type ConfidenceDriftRow = { runId: string; + createdAt: string; question: string; model: string; pipelinePreset: string; @@ -73,6 +74,7 @@ export function buildConfidenceDriftRows( return { runId: run.id, + createdAt: run.createdAt, question: run.question, model: run.model, pipelinePreset: run.pipelinePreset, diff --git a/apps/web/lib/driftTrends.test.ts b/apps/web/lib/driftTrends.test.ts new file mode 100644 index 0000000..84584e4 --- /dev/null +++ b/apps/web/lib/driftTrends.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import type { ConfidenceDriftRow } from "./confidenceDrift"; +import { buildDriftTrendSeries } from "./driftTrends"; + +describe("driftTrends", () => { + it("builds chronological drift trend series", () => { + const rows: ConfidenceDriftRow[] = [ + { + runId: "run-b", + createdAt: "2026-01-02T00:00:00.000Z", + question: "Q", + model: "m", + pipelinePreset: "standard", + solverToRevisionDelta: -0.2, + revisionToSynthesizerDelta: 0.1, + calibratedMinusSynthDelta: 0.05, + driftMagnitude: 0.3, + traceHref: "/runs/run-b", + compareHref: "/runs/compare?left=run-b", + }, + { + runId: "run-a", + createdAt: "2026-01-01T00:00:00.000Z", + question: "Q", + model: "m", + pipelinePreset: "standard", + solverToRevisionDelta: -0.1, + driftMagnitude: 0.1, + traceHref: "/runs/run-a", + compareHref: "/runs/compare?left=run-a", + }, + ]; + + const series = buildDriftTrendSeries(rows); + expect(series).toHaveLength(2); + expect(series[0]?.createdAt).toBe("2026-01-01T00:00:00.000Z"); + expect(series[1]?.createdAt).toBe("2026-01-02T00:00:00.000Z"); + expect(series[0]?.solverToRevisionDelta).toBe(-0.1); + }); +}); diff --git a/apps/web/lib/driftTrends.ts b/apps/web/lib/driftTrends.ts new file mode 100644 index 0000000..093212b --- /dev/null +++ b/apps/web/lib/driftTrends.ts @@ -0,0 +1,38 @@ +import type { ConfidenceDriftRow } from "./confidenceDrift"; + +export type DriftTrendPoint = { + label: string; + createdAt: string; + solverToRevisionDelta: number | null; + revisionToSynthesizerDelta: number | null; + calibratedMinusSynthDelta: number | null; +}; + +function shortLabel(id: string, createdAt: string, index: number): string { + const date = new Date(createdAt); + const stamp = Number.isNaN(date.getTime()) + ? `run-${index + 1}` + : `${date.getMonth() + 1}/${date.getDate()}`; + return `${stamp}-${id.slice(-6)}`; +} + +export function buildDriftTrendSeries( + rows: ConfidenceDriftRow[], +): DriftTrendPoint[] { + return rows + .filter( + (row) => + row.solverToRevisionDelta != null || + row.revisionToSynthesizerDelta != null || + row.calibratedMinusSynthDelta != null, + ) + .slice() + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)) + .map((row, index) => ({ + label: shortLabel(row.runId, row.createdAt, index), + createdAt: row.createdAt, + solverToRevisionDelta: row.solverToRevisionDelta ?? null, + revisionToSynthesizerDelta: row.revisionToSynthesizerDelta ?? null, + calibratedMinusSynthDelta: row.calibratedMinusSynthDelta ?? null, + })); +} diff --git a/apps/web/lib/glossary.ts b/apps/web/lib/glossary.ts index 68eae43..ecd95f0 100644 --- a/apps/web/lib/glossary.ts +++ b/apps/web/lib/glossary.ts @@ -148,6 +148,8 @@ export const GLOSSARY: Record = { "Failed agent steps over time — spikes often indicate model outages, parse failures, or timeouts.", qualityTrend: "Judge rubric scores (1–5) across filtered runs in chronological order.", + driftTrend: + "Stage-to-stage confidence deltas across filtered runs in chronological order.", }; export function getGlossaryEntry(key: string): string | undefined { diff --git a/apps/web/lib/listExport.test.ts b/apps/web/lib/listExport.test.ts index ff991b2..0be7d6f 100644 --- a/apps/web/lib/listExport.test.ts +++ b/apps/web/lib/listExport.test.ts @@ -192,6 +192,7 @@ describe("listExport", () => { const csv = confidenceDriftToCsv([ { runId: "run_1", + createdAt: "2026-01-01T00:00:00.000Z", question: "Topic", model: "gpt-test", pipelinePreset: "standard", diff --git a/apps/web/lib/listExport.ts b/apps/web/lib/listExport.ts index c0e6280..82387ad 100644 --- a/apps/web/lib/listExport.ts +++ b/apps/web/lib/listExport.ts @@ -19,7 +19,7 @@ import type { ModelLeaderboardRow } from "./modelLeaderboard"; import type { PresetLeaderboardRow } from "./presetLeaderboard"; import type { QualityRunRow } from "./qualityInsights"; import type { GlobalSearchResult } from "./globalSearch"; -import type { AgentTimingRow } from "./stepTiming"; +import type { AgentTimingRow, SlowestRunRow } from "./stepTiming"; function escapeCsv(value: string): string { if (/[",\n\r]/.test(value)) { @@ -295,6 +295,30 @@ export function agentTimingToCsv(rows: AgentTimingRow[]): string { return [header.join(","), ...csvRows].join("\n"); } +export function slowestRunTimingToCsv(rows: SlowestRunRow[]): string { + const header = [ + "runId", + "question", + "model", + "pipelinePreset", + "timedStepCount", + "totalDurationMs", + "avgStepDurationMs", + ]; + const csvRows = rows.map((entry) => + row([ + entry.runId, + entry.question, + entry.model, + entry.pipelinePreset, + entry.timedStepCount, + entry.totalDurationMs, + entry.avgStepDurationMs, + ]), + ); + return [header.join(","), ...csvRows].join("\n"); +} + export function confidenceDriftToCsv(rows: ConfidenceDriftRow[]): string { const header = [ "runId", diff --git a/apps/web/lib/stepTiming.test.ts b/apps/web/lib/stepTiming.test.ts index 64c8dbd..4c7fbf7 100644 --- a/apps/web/lib/stepTiming.test.ts +++ b/apps/web/lib/stepTiming.test.ts @@ -3,6 +3,7 @@ import type { RunArtifact } from "./data"; import { buildAgentTimingStats, buildRunStepTiming, + buildSlowestRunRows, formatDurationMs, summarizeRunStepTiming, } from "./stepTiming"; @@ -69,4 +70,30 @@ describe("stepTiming", () => { expect(summary.totalDurationMs).toBe(5000); expect(summary.avgStepDurationMs).toBe(2500); }); + + it("ranks slowest runs by total step duration", () => { + const slowRun = sampleRun(); + const fastRun: RunArtifact = { + ...sampleRun(), + id: "run-fast", + run: { + ...sampleRun().run, + id: "run-fast", + steps: [ + { + id: "s1", + agentName: "SolverAgent", + role: "Solver", + createdAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + }, + ], + }, + }; + + const rows = buildSlowestRunRows([fastRun, slowRun]); + expect(rows[0]?.runId).toBe("run-1"); + expect(rows[0]?.totalDurationMs).toBe(5000); + expect(rows[1]?.runId).toBe("run-fast"); + }); }); diff --git a/apps/web/lib/stepTiming.ts b/apps/web/lib/stepTiming.ts index 0dd152f..4a741ea 100644 --- a/apps/web/lib/stepTiming.ts +++ b/apps/web/lib/stepTiming.ts @@ -138,6 +138,51 @@ export function summarizeStepTiming(runs: RunArtifact[]) { }; } +export type SlowestRunRow = { + runId: string; + question: string; + model: string; + pipelinePreset: string; + timedStepCount: number; + totalDurationMs: number; + avgStepDurationMs: number; + traceHref: string; +}; + +export function buildSlowestRunRows( + runs: RunArtifact[], + limit = 15, +): SlowestRunRow[] { + const rows: SlowestRunRow[] = []; + + for (const run of runs) { + const stepRows = buildRunStepTiming(run); + const summary = summarizeRunStepTiming(stepRows); + if (summary.totalDurationMs == null || summary.timedStepCount === 0) { + continue; + } + + rows.push({ + runId: run.id, + question: run.question, + model: run.metadata.model, + pipelinePreset: run.metadata.pipelinePreset, + timedStepCount: summary.timedStepCount, + totalDurationMs: summary.totalDurationMs, + avgStepDurationMs: summary.avgStepDurationMs ?? 0, + traceHref: `/runs/${run.id}`, + }); + } + + return rows + .sort( + (a, b) => + b.totalDurationMs - a.totalDurationMs || + a.runId.localeCompare(b.runId), + ) + .slice(0, limit); +} + export function formatDurationMs(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; const seconds = ms / 1000;