j-chim Claude Fable 5 commited on
Commit
d20fb23
·
1 Parent(s): 0fd9809

Route single-segment eval ids to the merged benchmark page (F2-F4)

Browse files

/evals/[...id]: one URL segment renders the new MergedBenchmarkView;
two segments keep the per-source EvalDetail path untouched.
?source=/?metric=/?slice= live in the URL query so selections survive
reload and back.

MergedBenchmarkView: observation-grain hero ("N results from K sources
- M models", best single result on the canonical scale), source
narrower that NAVIGATES to the per-source page (Q5; slice-only sources
disabled), metric switcher that re-queries via ?metric=, slice
selector + note on slice-grain pages, flat interleaved table dense-
ranked by score_canonical (flagged rows show the raw score with an
unconverted warning and rank last), 50-per-click pagination, and
per-source disclosure notes (protocol-variant-only and slice-only
sources). No hierarchyLocation lookups — merged ids aren't keyed there.

Browse-tree leaves route on the hierarchy node's new benchmark_id
field (additive HierarchyBenchmark type): when present the leaf opens
the merged page with the clicked source pre-highlighted via ?source=
(composite slug from the leaf id's first segment); nodes without it —
older snapshots, client-side re-keyed/synthetic nodes — keep today's
per-source link exactly. getFamilyNavId becomes getFamilyNavHref so
the single-leaf family shortcut follows the same rule.

generateMetadata resolves merged ids through the merged accessor for
titles/OG copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

app/evals/[...id]/layout.tsx CHANGED
@@ -1,7 +1,7 @@
1
  import type { Metadata } from "next"
2
 
3
- import { getEvalSummaryById } from "@/lib/data-backend"
4
- import { routeIdFromSegments, routeIdToPath } from "@/lib/utils"
5
 
6
  /**
7
  * Server-side metadata for the eval/benchmark detail page. Mirrors
@@ -20,15 +20,23 @@ export async function generateMetadata(props: {
20
  let modelsCount: number | null = null
21
 
22
  try {
23
- const summary = await getEvalSummaryById(routeId)
24
- if (summary) {
25
- evalName =
26
- summary.canonical_display_name ??
27
- summary.evaluation_name ??
28
- summary.composite_display_name ??
29
- evalName
30
- category = summary.derived_tags?.[0] ?? null
31
- modelsCount = summary.models_count ?? null
 
 
 
 
 
 
 
 
32
  }
33
  } catch {
34
  // Fall through to generic copy.
 
1
  import type { Metadata } from "next"
2
 
3
+ import { getEvalSummaryById, getMergedBenchmarkSummary } from "@/lib/data-backend"
4
+ import { isMergedEvalId, routeIdFromSegments, routeIdToPath } from "@/lib/utils"
5
 
6
  /**
7
  * Server-side metadata for the eval/benchmark detail page. Mirrors
 
20
  let modelsCount: number | null = null
21
 
22
  try {
23
+ // Single-segment ids are merged all-sources benchmark pages (spec
24
+ // F2); their identity lives in merged_evals_view, not evals_view.
25
+ const merged = isMergedEvalId(routeId) ? await getMergedBenchmarkSummary(routeId) : null
26
+ if (merged) {
27
+ evalName = merged.display_name
28
+ modelsCount = merged.models_count ?? null
29
+ } else {
30
+ const summary = await getEvalSummaryById(routeId)
31
+ if (summary) {
32
+ evalName =
33
+ summary.canonical_display_name ??
34
+ summary.evaluation_name ??
35
+ summary.composite_display_name ??
36
+ evalName
37
+ category = summary.derived_tags?.[0] ?? null
38
+ modelsCount = summary.models_count ?? null
39
+ }
40
  }
41
  } catch {
42
  // Fall through to generic copy.
app/evals/[...id]/page.tsx CHANGED
@@ -7,11 +7,12 @@ import { ArrowLeft, ArrowUpRight, BarChart3, Grid3X3, Search } from "lucide-reac
7
  import { Navigation } from "@/components/navigation"
8
  import { ReaderModeBar } from "@/components/reader-mode-bar"
9
  import { EvalDetail } from "@/components/eval-detail"
 
10
  import { ParamRangePicker } from "@/components/param-range-picker"
11
  import { useAudienceMode } from "@/components/audience-mode-provider"
12
  import type { BenchmarkEvalSummary } from "@/lib/eval-processing"
13
  import { fetchComparisonIndex, fetchEvalHierarchy, fetchEvalSummary } from "@/lib/dashboard-data-client"
14
- import { humanizeEvaluationId, routeIdFromSegments, routeIdToPath } from "@/lib/utils"
15
  import { PARAM_RANGE_MAX_INDEX, parseParamsBillionsFromModelName, paramStepToNumeric } from "@/lib/param-range"
16
  import type { ComparisonIndex, EvalHierarchy } from "@/lib/backend-artifacts"
17
  import {
@@ -53,6 +54,10 @@ export default function EvalDetailPage() {
53
  const [splitSummaries, setSplitSummaries] = useState<Map<string, BenchmarkEvalSummary>>(new Map())
54
  const [activeSplitId, setActiveSplitId] = useState<string | null>(null)
55
  const returnTo = searchParams.get("from")
 
 
 
 
56
  const currentDetailHref = useMemo(() => {
57
  const params = new URLSearchParams(searchParams.toString())
58
  params.delete("from")
@@ -75,14 +80,19 @@ export default function EvalDetailPage() {
75
  }, [returnTo, router])
76
 
77
  useEffect(() => {
 
 
 
 
 
78
  const load = async () => {
79
  try {
80
  // The data is keyed by percent-encoded evaluation_ids (literal
81
  // `%2F` slug form, e.g. `llm-stats%2Fdrop`). The route is
82
  // catch-all so `params.id` arrives as a path segment array
83
  // (`["llm-stats", "drop"]`) — join + re-encode for backend
84
- // lookup. Every evaluation_id in the snapshot uses `%2F`, so
85
- // this is unambiguous.
86
  const evalId = routeIdFromSegments(params.id as string | string[])
87
  const [found, evalHierarchy] = await Promise.all([
88
  fetchEvalSummary(evalId),
@@ -134,7 +144,7 @@ export default function EvalDetailPage() {
134
  }
135
  }
136
  load()
137
- }, [params.id])
138
 
139
  // Cross-suite comparability needs the full comparison-index, but it's
140
  // not on the critical path for first paint — load lazily so the page
@@ -195,6 +205,26 @@ export default function EvalDetailPage() {
195
  [splitIds, splitSummaries]
196
  )
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  if (loading) {
199
  return (
200
  <div className="min-h-screen bg-background">
 
7
  import { Navigation } from "@/components/navigation"
8
  import { ReaderModeBar } from "@/components/reader-mode-bar"
9
  import { EvalDetail } from "@/components/eval-detail"
10
+ import { MergedBenchmarkView } from "@/components/merged-benchmark-view"
11
  import { ParamRangePicker } from "@/components/param-range-picker"
12
  import { useAudienceMode } from "@/components/audience-mode-provider"
13
  import type { BenchmarkEvalSummary } from "@/lib/eval-processing"
14
  import { fetchComparisonIndex, fetchEvalHierarchy, fetchEvalSummary } from "@/lib/dashboard-data-client"
15
+ import { humanizeEvaluationId, isMergedEvalId, routeIdFromSegments, routeIdToPath } from "@/lib/utils"
16
  import { PARAM_RANGE_MAX_INDEX, parseParamsBillionsFromModelName, paramStepToNumeric } from "@/lib/param-range"
17
  import type { ComparisonIndex, EvalHierarchy } from "@/lib/backend-artifacts"
18
  import {
 
54
  const [splitSummaries, setSplitSummaries] = useState<Map<string, BenchmarkEvalSummary>>(new Map())
55
  const [activeSplitId, setActiveSplitId] = useState<string | null>(null)
56
  const returnTo = searchParams.get("from")
57
+ // Single URL segment = merged all-sources benchmark page (spec F2);
58
+ // two segments = per-source eval page (unchanged).
59
+ const routeEvalId = routeIdFromSegments(params.id as string | string[])
60
+ const isMergedRoute = isMergedEvalId(routeEvalId)
61
  const currentDetailHref = useMemo(() => {
62
  const params = new URLSearchParams(searchParams.toString())
63
  params.delete("from")
 
80
  }, [returnTo, router])
81
 
82
  useEffect(() => {
83
+ // Merged pages own their data flow (components/merged-benchmark-view).
84
+ if (isMergedRoute) {
85
+ setLoading(false)
86
+ return
87
+ }
88
  const load = async () => {
89
  try {
90
  // The data is keyed by percent-encoded evaluation_ids (literal
91
  // `%2F` slug form, e.g. `llm-stats%2Fdrop`). The route is
92
  // catch-all so `params.id` arrives as a path segment array
93
  // (`["llm-stats", "drop"]`) — join + re-encode for backend
94
+ // lookup. Every per-source evaluation_id in the snapshot uses
95
+ // `%2F`, so this is unambiguous.
96
  const evalId = routeIdFromSegments(params.id as string | string[])
97
  const [found, evalHierarchy] = await Promise.all([
98
  fetchEvalSummary(evalId),
 
144
  }
145
  }
146
  load()
147
+ }, [params.id, isMergedRoute])
148
 
149
  // Cross-suite comparability needs the full comparison-index, but it's
150
  // not on the critical path for first paint — load lazily so the page
 
205
  [splitIds, splitSummaries]
206
  )
207
 
208
+ if (isMergedRoute) {
209
+ return (
210
+ <div className="min-h-screen bg-background">
211
+ <Navigation />
212
+ <ReaderModeBar />
213
+ <main className="ec-page">
214
+ <button
215
+ type="button"
216
+ onClick={handleBack}
217
+ className="ec-crumb mb-6 inline-flex items-center gap-1.5"
218
+ >
219
+ <ArrowLeft className="h-3 w-3" />
220
+ Evaluations
221
+ </button>
222
+ <MergedBenchmarkView benchmarkId={routeEvalId} />
223
+ </main>
224
+ </div>
225
+ )
226
+ }
227
+
228
  if (loading) {
229
  return (
230
  <div className="min-h-screen bg-background">
app/evals/page.tsx CHANGED
@@ -6,7 +6,7 @@ import { Search } from "lucide-react"
6
 
7
  import { EvaluatorTable, type EvaluatorTableSortCol } from "@/components/evaluator-table"
8
  import { useOrgMetadata } from "@/components/org-metadata-provider"
9
- import { FamilyTable, getFamilyNavId, type FamilySortCol } from "@/components/family-table"
10
  import { InfiniteScrollSentinel } from "@/components/infinite-scroll"
11
  import { Navigation } from "@/components/navigation"
12
  import { PageLoadingState, type PageLoadingStage } from "@/components/page-loading-state"
@@ -144,9 +144,9 @@ function EvalsPageInner() {
144
  if (!familyParam || !hierarchy) return
145
  const fam = hierarchy.families.find((f) => f.key === familyParam)
146
  if (!fam) return
147
- const navId = getFamilyNavId(fam, benchmarkCards)
148
- if (navId) {
149
- router.replace(`/evals/${navId.replace(/%2F/g, "/")}`)
150
  return
151
  }
152
  setSearchQuery(fam.display_name || fam.key)
 
6
 
7
  import { EvaluatorTable, type EvaluatorTableSortCol } from "@/components/evaluator-table"
8
  import { useOrgMetadata } from "@/components/org-metadata-provider"
9
+ import { FamilyTable, getFamilyNavHref, type FamilySortCol } from "@/components/family-table"
10
  import { InfiniteScrollSentinel } from "@/components/infinite-scroll"
11
  import { Navigation } from "@/components/navigation"
12
  import { PageLoadingState, type PageLoadingStage } from "@/components/page-loading-state"
 
144
  if (!familyParam || !hierarchy) return
145
  const fam = hierarchy.families.find((f) => f.key === familyParam)
146
  if (!fam) return
147
+ const navHref = getFamilyNavHref(fam, benchmarkCards)
148
+ if (navHref) {
149
+ router.replace(navHref)
150
  return
151
  }
152
  setSearchQuery(fam.display_name || fam.key)
components/family-table.tsx CHANGED
@@ -8,7 +8,7 @@ import type { HierarchyBenchmark, HierarchyComposite, HierarchyFamily } from "@/
8
  import { formatTagLabel } from "@/lib/benchmark-tags"
9
  import type { BenchmarkCard } from "@/lib/benchmark-schema"
10
  import type { BenchmarkEvalListItem } from "@/lib/eval-processing"
11
- import { routeIdToPath } from "@/lib/utils"
12
 
13
  const LEAVES_INLINE_MAX = 50
14
 
@@ -65,6 +65,9 @@ interface LeafEntry {
65
  id: string
66
  /** All evaluation_ids this leaf maps to (constituent ids or fallbacks). */
67
  evalIds: string[]
 
 
 
68
  leafKey: string
69
  leafName: string
70
  evalsCount: number
@@ -112,6 +115,7 @@ function buildLeafEntry(
112
  return {
113
  id: ids[0],
114
  evalIds: ids,
 
115
  leafKey: benchmark.key,
116
  leafName: benchmark.display_name || benchmark.key,
117
  evalsCount: ids.length,
@@ -124,6 +128,32 @@ function buildLeafEntry(
124
  }
125
  }
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  function collectFamilySections(
128
  fam: HierarchyFamily,
129
  benchmarkCards?: Record<string, BenchmarkCard>,
@@ -191,12 +221,14 @@ function isFamilyDisplayNameMisleading(fam: HierarchyFamily, leafEntries: LeafEn
191
  )
192
  }
193
 
194
- export function getFamilyNavId(
 
 
195
  fam: HierarchyFamily,
196
  benchmarkCards?: Record<string, BenchmarkCard>,
197
  ): string | null {
198
  const leaves = collectLeafEntries(fam, benchmarkCards)
199
- if (leaves.length === 1) return leaves[0].id
200
  return null
201
  }
202
 
@@ -492,7 +524,7 @@ export function FamilyTable({
492
 
493
  const singleLeaf = row.leaves.length === 1 ? row.leaves[0] : null
494
  const navigateToLeaf = singleLeaf
495
- ? () => router.push(`/evals/${routeIdToPath(singleLeaf.id)}`)
496
  : null
497
  return (
498
  <Fragment key={row.key}>
@@ -644,7 +676,7 @@ export function FamilyTable({
644
  type="button"
645
  onClick={(e) => {
646
  e.stopPropagation()
647
- router.push(`/evals/${routeIdToPath(leaf.id)}`)
648
  }}
649
  className="w-full flex items-start justify-between gap-2 px-3 py-2 text-left transition-colors hover:bg-[color:var(--bg-surface)]"
650
  >
 
8
  import { formatTagLabel } from "@/lib/benchmark-tags"
9
  import type { BenchmarkCard } from "@/lib/benchmark-schema"
10
  import type { BenchmarkEvalListItem } from "@/lib/eval-processing"
11
+ import { routeIdFromSegments, routeIdToPath } from "@/lib/utils"
12
 
13
  const LEAVES_INLINE_MAX = 50
14
 
 
65
  id: string
66
  /** All evaluation_ids this leaf maps to (constituent ids or fallbacks). */
67
  evalIds: string[]
68
+ /** Canonical benchmark id when a merged all-sources page exists for this
69
+ * node; null on older snapshots / synthetic nodes → per-source link. */
70
+ benchmarkId: string | null
71
  leafKey: string
72
  leafName: string
73
  evalsCount: number
 
115
  return {
116
  id: ids[0],
117
  evalIds: ids,
118
+ benchmarkId: benchmark.benchmark_id ?? null,
119
  leafKey: benchmark.key,
120
  leafName: benchmark.display_name || benchmark.key,
121
  evalsCount: ids.length,
 
128
  }
129
  }
130
 
131
+ function decodeLoose(value: string): string {
132
+ try {
133
+ return decodeURIComponent(value)
134
+ } catch {
135
+ return value
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Where a leaf click lands (merged-benchmark-view spec F2): when the
141
+ * node carries the producer's `benchmark_id`, route to the merged
142
+ * all-sources page with the clicked leaf's source pre-highlighted via
143
+ * `?source=<composite_slug>` (derived from the leaf's per-source eval
144
+ * id's first segment). Nodes without a benchmark_id (older snapshots,
145
+ * client-side re-keyed/synthetic nodes) keep today's per-source link.
146
+ */
147
+ function leafNavHref(leaf: LeafEntry): string {
148
+ if (leaf.benchmarkId) {
149
+ const mergedPath = routeIdFromSegments(leaf.benchmarkId)
150
+ const sourceSlug = leaf.id.includes("%2F") ? decodeLoose(leaf.id.split("%2F")[0]) : ""
151
+ const query = sourceSlug ? `?source=${encodeURIComponent(sourceSlug)}` : ""
152
+ return `/evals/${mergedPath}${query}`
153
+ }
154
+ return `/evals/${routeIdToPath(leaf.id)}`
155
+ }
156
+
157
  function collectFamilySections(
158
  fam: HierarchyFamily,
159
  benchmarkCards?: Record<string, BenchmarkCard>,
 
221
  )
222
  }
223
 
224
+ /** Nav target for a single-benchmark family: the full `/evals/...` href
225
+ * (merged page when the leaf carries a benchmark_id, else per-source). */
226
+ export function getFamilyNavHref(
227
  fam: HierarchyFamily,
228
  benchmarkCards?: Record<string, BenchmarkCard>,
229
  ): string | null {
230
  const leaves = collectLeafEntries(fam, benchmarkCards)
231
+ if (leaves.length === 1) return leafNavHref(leaves[0])
232
  return null
233
  }
234
 
 
524
 
525
  const singleLeaf = row.leaves.length === 1 ? row.leaves[0] : null
526
  const navigateToLeaf = singleLeaf
527
+ ? () => router.push(leafNavHref(singleLeaf))
528
  : null
529
  return (
530
  <Fragment key={row.key}>
 
676
  type="button"
677
  onClick={(e) => {
678
  e.stopPropagation()
679
+ router.push(leafNavHref(leaf))
680
  }}
681
  className="w-full flex items-start justify-between gap-2 px-3 py-2 text-left transition-colors hover:bg-[color:var(--bg-surface)]"
682
  >
components/merged-benchmark-view.tsx ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ // Merged all-sources benchmark page (merged-benchmark-view spec F3/F4).
4
+ //
5
+ // One page per resolved canonical benchmark, at observation grain: one
6
+ // row per (model, source) score, flat-interleaved and sorted by
7
+ // score_canonical in the metric's direction (spec Q6). Echo
8
+ // republications stay visible (Q3). Controls:
9
+ // - Source narrower: NAVIGATES to the per-source eval page (Q5 —
10
+ // unlike the state-swap SplitPicker on per-source pages).
11
+ // - Metric switcher: re-queries via ?metric= without navigation.
12
+ // - Slice selector (grain='slice' pages only): ?slice=.
13
+ // ?source= pre-highlights the clicked browse-tree leaf's source rows.
14
+
15
+ import { useEffect, useMemo, useState } from "react"
16
+ import Link from "next/link"
17
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
18
+ import { AlertTriangle } from "lucide-react"
19
+
20
+ import { fetchMergedBenchmarkSummary } from "@/lib/dashboard-data-client"
21
+ import { isMergedBenchmarkSummary } from "@/lib/merged-adapter"
22
+ import type { MergedBenchmarkSummary, MergedObservationRow } from "@/lib/eval-processing"
23
+ import { formatDateISO, routeIdToPath } from "@/lib/utils"
24
+
25
+ const PAGE_SIZE = 50
26
+
27
+ /** Plain numeric formatting on the metric's canonical scale — no unit
28
+ * guessing (the whole point of score_canonical is one declared scale). */
29
+ function formatScore(value: number): string {
30
+ if (!Number.isFinite(value)) return "—"
31
+ if (Math.abs(value) >= 100) return value.toFixed(1)
32
+ if (Math.abs(value) >= 10) return value.toFixed(2)
33
+ return value.toFixed(3).replace(/0+$/g, "").replace(/\.$/, "")
34
+ }
35
+
36
+ export function MergedBenchmarkView({ benchmarkId }: { benchmarkId: string }) {
37
+ const router = useRouter()
38
+ const pathname = usePathname()
39
+ const searchParams = useSearchParams()
40
+ const sourceParam = searchParams.get("source")
41
+ const metricParam = searchParams.get("metric")
42
+ const sliceParam = searchParams.get("slice")
43
+
44
+ const [summary, setSummary] = useState<MergedBenchmarkSummary | null>(null)
45
+ const [loading, setLoading] = useState(true)
46
+ const [error, setError] = useState<string | null>(null)
47
+ const [page, setPage] = useState(1)
48
+
49
+ useEffect(() => {
50
+ let cancelled = false
51
+ setLoading(true)
52
+ fetchMergedBenchmarkSummary(benchmarkId, {
53
+ metricId: metricParam ?? undefined,
54
+ sliceId: sliceParam ?? undefined,
55
+ })
56
+ .then((payload) => {
57
+ if (cancelled) return
58
+ if (!isMergedBenchmarkSummary(payload)) {
59
+ setError("This snapshot has no merged page for this benchmark.")
60
+ return
61
+ }
62
+ setSummary(payload)
63
+ setError(null)
64
+ setPage(1)
65
+ document.title = `${payload.display_name} | Benchmark`
66
+ })
67
+ .catch((err) => {
68
+ console.error(err)
69
+ if (!cancelled) setError("Benchmark not found")
70
+ })
71
+ .finally(() => {
72
+ if (!cancelled) setLoading(false)
73
+ })
74
+ return () => {
75
+ cancelled = true
76
+ }
77
+ }, [benchmarkId, metricParam, sliceParam])
78
+
79
+ // Update a query param in place (no navigation) so metric/slice
80
+ // selections survive reload and back.
81
+ const setQueryParam = (key: string, value: string | null) => {
82
+ const params = new URLSearchParams(searchParams.toString())
83
+ if (value) params.set(key, value)
84
+ else params.delete(key)
85
+ const qs = params.toString()
86
+ router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false })
87
+ }
88
+
89
+ const selectedMetricId = summary?.selected_metric_id
90
+ const selectedMetric = useMemo(
91
+ () => summary?.metrics.find((m) => m.metric_id === selectedMetricId) ?? null,
92
+ [summary, selectedMetricId],
93
+ )
94
+ const isPreferredMetric = summary != null && selectedMetricId === summary.preferred_metric_id
95
+
96
+ // Counts at the SELECTED metric's grain (hero scalar counts are at the
97
+ // default metric's).
98
+ const resultsCount = selectedMetric?.results_count ?? summary?.results_count ?? 0
99
+ const sourcesCount = selectedMetric?.sources_count ?? summary?.sources_count ?? 0
100
+ const modelsCount = selectedMetric?.models_count ?? summary?.models_count ?? 0
101
+
102
+ const sourceDisplayBySlug = useMemo(() => {
103
+ const map = new Map<string, string>()
104
+ for (const s of summary?.aggregate_sources ?? []) {
105
+ map.set(s.composite_slug, s.composite_display_name || s.composite_slug)
106
+ }
107
+ return map
108
+ }, [summary])
109
+
110
+ // Dense rank on score_canonical: ties share a rank, the next distinct
111
+ // score gets rank+1. Rows without a canonical score (flagged) rank "—".
112
+ const rankedRows = useMemo(() => {
113
+ const rows = summary?.results ?? []
114
+ let rank = 0
115
+ let previous: number | null = null
116
+ return rows.map((row) => {
117
+ if (row.score_canonical == null) {
118
+ return { row, rank: null as number | null }
119
+ }
120
+ if (previous === null || row.score_canonical !== previous) {
121
+ rank += 1
122
+ previous = row.score_canonical
123
+ }
124
+ return { row, rank: rank as number | null }
125
+ })
126
+ }, [summary])
127
+
128
+ const pagedRows = rankedRows.slice(0, page * PAGE_SIZE)
129
+ const remaining = rankedRows.length - pagedRows.length
130
+
131
+ const disclosureSources = (summary?.aggregate_sources ?? []).filter(
132
+ (s) => !s.reports_preferred || s.slice_only,
133
+ )
134
+
135
+ if (loading) {
136
+ return (
137
+ <div className="flex items-center justify-center h-96">
138
+ <div className="kicker">Loading merged benchmark…</div>
139
+ </div>
140
+ )
141
+ }
142
+
143
+ if (error || !summary) {
144
+ return (
145
+ <div className="flex flex-col items-center justify-center h-96 space-y-4">
146
+ <div className="kicker">{error ?? "Benchmark not found"}</div>
147
+ </div>
148
+ )
149
+ }
150
+
151
+ const best = summary.best_result
152
+ const bestScore = best ? best.score_canonical ?? best.score : null
153
+ const bestSourceName = best?.composite_slug
154
+ ? sourceDisplayBySlug.get(best.composite_slug) ?? best.composite_slug
155
+ : null
156
+
157
+ const preselectedSource =
158
+ sourceParam && sourceDisplayBySlug.has(sourceParam) ? sourceParam : ""
159
+
160
+ return (
161
+ <div className="space-y-8">
162
+ {/* HERO ------------------------------------------------------------- */}
163
+ <header className="motion-academic-enter">
164
+ <div
165
+ className="mb-2 font-mono text-[10px] uppercase tracking-[0.16em]"
166
+ style={{ color: "var(--fg-subtle)" }}
167
+ >
168
+ Merged benchmark · all sources
169
+ </div>
170
+ <h1 className="ec-page-h1">{summary.display_name}</h1>
171
+ <div
172
+ className="mb-4 flex flex-wrap items-center gap-3 font-mono text-[11px] uppercase tracking-[0.12em]"
173
+ style={{ color: "var(--fg-muted)" }}
174
+ >
175
+ {summary.family_display_name && summary.family_display_name !== summary.display_name && (
176
+ <>
177
+ <span>{summary.family_display_name}</span>
178
+ <span style={{ color: "var(--fg-subtle)" }}>·</span>
179
+ </>
180
+ )}
181
+ <span>{selectedMetric?.display_name ?? summary.preferred_metric_display_name}</span>
182
+ <span style={{ color: "var(--fg-subtle)" }}>·</span>
183
+ <span>{summary.selected_lower_is_better ? "Lower is better ↓" : "Higher is better ↑"}</span>
184
+ </div>
185
+
186
+ <div className="ec-page-meta">
187
+ <div className="ec-page-meta-item">
188
+ <span className="ec-page-meta-item-l">Results</span>
189
+ <span className="ec-page-meta-item-v">
190
+ {resultsCount.toLocaleString()} from {sourcesCount.toLocaleString()}{" "}
191
+ {sourcesCount === 1 ? "source" : "sources"}
192
+ </span>
193
+ </div>
194
+ <div className="ec-page-meta-item">
195
+ <span className="ec-page-meta-item-l">Models</span>
196
+ <span className="ec-page-meta-item-v">{modelsCount.toLocaleString()}</span>
197
+ </div>
198
+ {isPreferredMetric && best?.model_name && bestScore != null && (
199
+ <div className="ec-page-meta-item">
200
+ <span className="ec-page-meta-item-l">Best</span>
201
+ <span className="ec-page-meta-item-v">
202
+ {formatScore(bestScore)} — {best.model_name}
203
+ {bestSourceName ? ` (${bestSourceName})` : ""}
204
+ </span>
205
+ </div>
206
+ )}
207
+ </div>
208
+ </header>
209
+
210
+ {/* CONTROLS ---------------------------------------------------------- */}
211
+ <div className="flex flex-wrap items-end gap-x-6 gap-y-3">
212
+ <label className="flex flex-col gap-1">
213
+ <span
214
+ className="font-mono text-[10px] uppercase tracking-[0.14em]"
215
+ style={{ color: "var(--fg-subtle)" }}
216
+ >
217
+ Source
218
+ </span>
219
+ <select
220
+ className="ec-select"
221
+ value={preselectedSource}
222
+ onChange={(e) => {
223
+ const slug = e.target.value
224
+ if (!slug) return
225
+ const source = summary.aggregate_sources.find((s) => s.composite_slug === slug)
226
+ if (source?.evaluation_id) {
227
+ // Navigate-on-select to the per-source page (spec Q5).
228
+ router.push(`/evals/${routeIdToPath(source.evaluation_id)}`)
229
+ }
230
+ }}
231
+ >
232
+ <option value="">All sources (merged)</option>
233
+ {summary.aggregate_sources.map((source) => (
234
+ <option
235
+ key={source.composite_slug}
236
+ value={source.composite_slug}
237
+ disabled={!source.evaluation_id}
238
+ >
239
+ {source.composite_display_name || source.composite_slug} ({source.models_count}{" "}
240
+ {source.models_count === 1 ? "model" : "models"})
241
+ {source.slice_only ? " — slice-level only" : ""}
242
+ </option>
243
+ ))}
244
+ </select>
245
+ </label>
246
+
247
+ {summary.metrics.length > 1 && (
248
+ <label className="flex flex-col gap-1">
249
+ <span
250
+ className="font-mono text-[10px] uppercase tracking-[0.14em]"
251
+ style={{ color: "var(--fg-subtle)" }}
252
+ >
253
+ Metric
254
+ </span>
255
+ <select
256
+ className="ec-select"
257
+ value={selectedMetricId}
258
+ onChange={(e) => {
259
+ const metricId = e.target.value
260
+ setQueryParam("metric", metricId === summary.preferred_metric_id ? null : metricId)
261
+ }}
262
+ >
263
+ {summary.metrics.map((metric) => (
264
+ <option key={metric.metric_id} value={metric.metric_id}>
265
+ {metric.display_name} ({metric.sources_count}{" "}
266
+ {metric.sources_count === 1 ? "source" : "sources"},{" "}
267
+ {metric.results_count} {metric.results_count === 1 ? "result" : "results"})
268
+ </option>
269
+ ))}
270
+ </select>
271
+ </label>
272
+ )}
273
+
274
+ {summary.grain === "slice" && (summary.slices?.length ?? 0) > 0 && (
275
+ <label className="flex flex-col gap-1">
276
+ <span
277
+ className="font-mono text-[10px] uppercase tracking-[0.14em]"
278
+ style={{ color: "var(--fg-subtle)" }}
279
+ >
280
+ Slice
281
+ </span>
282
+ <select
283
+ className="ec-select"
284
+ value={summary.selected_slice_id ?? ""}
285
+ onChange={(e) => setQueryParam("slice", e.target.value || null)}
286
+ >
287
+ {(summary.slices ?? []).map((slice) => (
288
+ <option key={slice.slice_id} value={slice.slice_id}>
289
+ {slice.display_name}
290
+ </option>
291
+ ))}
292
+ </select>
293
+ </label>
294
+ )}
295
+ </div>
296
+
297
+ {summary.grain === "slice" && (
298
+ <p className="text-[13px] leading-[1.6]" style={{ color: "var(--fg-muted)", maxWidth: 720 }}>
299
+ This benchmark reports slice-level results only; each slice merges across sources.
300
+ </p>
301
+ )}
302
+
303
+ {/* OBSERVATION TABLE ------------------------------------------------- */}
304
+ {rankedRows.length === 0 ? (
305
+ <div className="ec-card" style={{ padding: 32, textAlign: "center" }}>
306
+ <div className="kicker">No results reported for this metric</div>
307
+ </div>
308
+ ) : (
309
+ <div className="overflow-x-auto" style={{ border: "1px solid var(--border-soft)" }}>
310
+ <table className="ec-htable">
311
+ <thead>
312
+ <tr>
313
+ <th style={{ width: 48 }}>#</th>
314
+ <th>Model</th>
315
+ <th className="num">
316
+ {selectedMetric?.display_name ?? summary.preferred_metric_display_name}
317
+ </th>
318
+ <th>Source</th>
319
+ <th>Updated</th>
320
+ </tr>
321
+ </thead>
322
+ <tbody>
323
+ {pagedRows.map(({ row, rank }, idx) => (
324
+ <ObservationRow
325
+ key={`${row.evaluation_id}::${row.model_key ?? row.model_info.id}::${idx}`}
326
+ row={row}
327
+ rank={rank}
328
+ highlighted={Boolean(preselectedSource) && row.composite_slug === preselectedSource}
329
+ sourceDisplayBySlug={sourceDisplayBySlug}
330
+ />
331
+ ))}
332
+ </tbody>
333
+ </table>
334
+ </div>
335
+ )}
336
+
337
+ {remaining > 0 && (
338
+ <div className="text-center">
339
+ <button type="button" className="btn-ec outline" onClick={() => setPage((p) => p + 1)}>
340
+ Load more ({remaining} remaining)
341
+ </button>
342
+ </div>
343
+ )}
344
+
345
+ {/* DISCLOSURE NOTES -------------------------------------------------- */}
346
+ {disclosureSources.length > 0 && (
347
+ <div className="space-y-1.5">
348
+ {disclosureSources.map((source) => {
349
+ const name = source.composite_display_name || source.composite_slug
350
+ const note = source.slice_only
351
+ ? "reports slice-level results only."
352
+ : "reports only other metrics for this benchmark (see metric switcher)."
353
+ return (
354
+ <p
355
+ key={`${source.composite_slug}-${source.slice_only ? "slice" : "metric"}`}
356
+ className="text-[12px] leading-[1.6]"
357
+ style={{ color: "var(--fg-muted)" }}
358
+ >
359
+ {source.evaluation_id ? (
360
+ <Link
361
+ href={`/evals/${routeIdToPath(source.evaluation_id)}`}
362
+ className="underline underline-offset-2 hover:text-[color:var(--accent)]"
363
+ style={{ color: "var(--fg)" }}
364
+ >
365
+ {name}
366
+ </Link>
367
+ ) : (
368
+ <span style={{ color: "var(--fg)" }}>{name}</span>
369
+ )}{" "}
370
+ {note}
371
+ </p>
372
+ )
373
+ })}
374
+ </div>
375
+ )}
376
+ </div>
377
+ )
378
+ }
379
+
380
+ function ObservationRow({
381
+ row,
382
+ rank,
383
+ highlighted,
384
+ sourceDisplayBySlug,
385
+ }: {
386
+ row: MergedObservationRow
387
+ rank: number | null
388
+ highlighted: boolean
389
+ sourceDisplayBySlug: Map<string, string>
390
+ }) {
391
+ const isFlagged = row.scale_conversion === "flagged"
392
+ const sourceName =
393
+ row.composite_display_name ??
394
+ sourceDisplayBySlug.get(row.composite_slug) ??
395
+ row.composite_slug
396
+ const modelHref = row.model_route_id ? `/models/${routeIdToPath(row.model_route_id)}` : null
397
+
398
+ return (
399
+ <tr style={highlighted ? { background: "var(--bg-warm)" } : undefined}>
400
+ <td
401
+ className="font-mono tabular-nums"
402
+ style={{ color: rank != null && rank <= 3 ? "var(--accent)" : "var(--fg-muted)", fontSize: 12 }}
403
+ >
404
+ {rank ?? "—"}
405
+ </td>
406
+ <td>
407
+ {modelHref ? (
408
+ <Link
409
+ href={modelHref}
410
+ className="font-semibold text-[14px] hover:text-[color:var(--accent)] transition-colors"
411
+ style={{ color: "var(--fg)", textDecoration: "none" }}
412
+ >
413
+ {row.model_info.name}
414
+ </Link>
415
+ ) : (
416
+ <span className="font-semibold text-[14px]">{row.model_info.name}</span>
417
+ )}
418
+ {row.model_info.developer && (
419
+ <div
420
+ className="font-mono text-[10px] uppercase tracking-[0.08em] mt-0.5"
421
+ style={{ color: "var(--fg-subtle)" }}
422
+ >
423
+ {row.model_info.developer}
424
+ </div>
425
+ )}
426
+ </td>
427
+ <td className="num font-mono tabular-nums" style={{ fontWeight: 600, fontSize: 14 }}>
428
+ {isFlagged ? (
429
+ <span
430
+ title="Unconverted: this score could not be safely converted to the metric's canonical scale."
431
+ style={{ color: "var(--fg-muted)" }}
432
+ >
433
+ <AlertTriangle className="inline h-3 w-3 mr-1 align-[-1px]" aria-hidden />
434
+ {formatScore(row.score)}
435
+ </span>
436
+ ) : (
437
+ formatScore(row.score_canonical ?? row.score)
438
+ )}
439
+ </td>
440
+ <td>
441
+ <Link
442
+ href={`/evals/${routeIdToPath(row.evaluation_id)}`}
443
+ className="text-[13px] hover:text-[color:var(--accent)] transition-colors"
444
+ style={{ color: "var(--fg-muted)", textDecoration: "none" }}
445
+ >
446
+ {sourceName}
447
+ </Link>
448
+ </td>
449
+ <td className="font-mono tabular-nums" style={{ fontSize: 12, color: "var(--fg-muted)" }}>
450
+ {formatDateISO(row.evaluation_timestamp)}
451
+ </td>
452
+ </tr>
453
+ )
454
+ }
lib/backend-artifacts.ts CHANGED
@@ -315,6 +315,11 @@ export interface HierarchyBenchmark extends SignalSummaries {
315
  key: string
316
  display_name: string
317
  family_id: string
 
 
 
 
 
318
  is_slice: boolean
319
  /** True when this row IS the family/composite root (canonical_id
320
  * matches the family or composite key). For a singleton family,
 
315
  key: string
316
  display_name: string
317
  family_id: string
318
+ /** Canonical benchmark id when a merged all-sources page exists for
319
+ * this node (additive, 2026-08 producer). Null/absent on older
320
+ * snapshots and on hotfix-re-keyed/synthetic nodes — those leaves
321
+ * fall back to the per-source link (merged-benchmark-view spec F2). */
322
+ benchmark_id?: string | null
323
  is_slice: boolean
324
  /** True when this row IS the family/composite root (canonical_id
325
  * matches the family or composite key). For a singleton family,