Spaces:
Running
view-data: paranoid CAST every output column to a primitive type
Browse filesThe previous patch left simple scalars (VARCHAR / BOOLEAN / DOUBLE /
INTEGER) untouched, on the assumption the binding handles those
natively. The deployed log still surfaces "Invalid Error: don't know
what type:" on the new explicit-column SELECT, which means whatever
the binding is choking on is reachable even without r.* expansion
and even with every nested type wrapped. The only thing left to vary
is whether the binding ever sees the parquet's native logical types
at all.
Force every output column through `CAST(... AS <primitive>)`. The
binding now only ever sees VARCHAR / BOOLEAN / INTEGER / DOUBLE in
the result schema regardless of what type the underlying parquet
column declared. Local prod build keeps returning 200 on
/api/model-summary with model_info correctly nested.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- app/developers/[...id]/page (1).tsx +244 -0
- app/embed/eval/distribution/[...id]/page.tsx +228 -0
- app/evals/[...id]/page (1).tsx +734 -0
- app/models/[...id]/page (1).tsx +355 -0
- lib/view-data.ts +37 -37
- output/hierarchy_explorer.html +0 -0
- public/peer-ranks.json +0 -0
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useCallback, useEffect, useMemo, useState } from "react"
|
| 4 |
+
import { useParams, useRouter } from "next/navigation"
|
| 5 |
+
import { ArrowLeft, ArrowUpDown, Search, Tag } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { BenchmarkEvaluationCard, type BenchmarkEvaluationCardData } from "@/components/benchmark-evaluation-card"
|
| 8 |
+
import { ListPagination } from "@/components/list-pagination"
|
| 9 |
+
import { Navigation } from "@/components/navigation"
|
| 10 |
+
import { PageHeader } from "@/components/page-header"
|
| 11 |
+
import { Button } from "@/components/ui/button"
|
| 12 |
+
import { Input } from "@/components/ui/input"
|
| 13 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
| 14 |
+
import type { BenchmarkCard } from "@/lib/benchmark-schema"
|
| 15 |
+
import { lookupBenchmarkCard } from "@/lib/benchmark-metadata-utils"
|
| 16 |
+
import { fetchDeveloperSummary, fetchBenchmarkMetadata } from "@/lib/dashboard-data-client"
|
| 17 |
+
|
| 18 |
+
const PAGE_SIZE = 40
|
| 19 |
+
|
| 20 |
+
export default function DeveloperDetailPage() {
|
| 21 |
+
const params = useParams()
|
| 22 |
+
const router = useRouter()
|
| 23 |
+
const [developer, setDeveloper] = useState<string>("")
|
| 24 |
+
const [models, setModels] = useState<BenchmarkEvaluationCardData[]>([])
|
| 25 |
+
const [benchmarkCards, setBenchmarkCards] = useState<Record<string, BenchmarkCard>>({})
|
| 26 |
+
const [loading, setLoading] = useState(true)
|
| 27 |
+
const [error, setError] = useState<string | null>(null)
|
| 28 |
+
const [searchQuery, setSearchQuery] = useState("")
|
| 29 |
+
const [sortBy, setSortBy] = useState<"date" | "name" | "benchmarks">("date")
|
| 30 |
+
const [page, setPage] = useState(1)
|
| 31 |
+
|
| 32 |
+
const routeId = params.id as string
|
| 33 |
+
|
| 34 |
+
const handleBack = useCallback(() => {
|
| 35 |
+
router.push("/developers")
|
| 36 |
+
}, [router])
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
Promise.all([
|
| 40 |
+
fetchDeveloperSummary(routeId),
|
| 41 |
+
fetchBenchmarkMetadata(),
|
| 42 |
+
])
|
| 43 |
+
.then(([summary, cards]) => {
|
| 44 |
+
setDeveloper(summary.developer)
|
| 45 |
+
setModels(summary.models)
|
| 46 |
+
setBenchmarkCards(cards)
|
| 47 |
+
})
|
| 48 |
+
.catch((err) => {
|
| 49 |
+
console.error(err)
|
| 50 |
+
setError("Developer not found")
|
| 51 |
+
})
|
| 52 |
+
.finally(() => setLoading(false))
|
| 53 |
+
}, [routeId])
|
| 54 |
+
|
| 55 |
+
// Collect all unique domains from benchmarks this developer's models are evaluated on
|
| 56 |
+
const domainCoverage = useMemo(() => {
|
| 57 |
+
const domainMap = new Map<string, Set<string>>() // domain → set of benchmark names
|
| 58 |
+
for (const model of models) {
|
| 59 |
+
for (const { benchmark } of model.top_scores) {
|
| 60 |
+
const card = lookupBenchmarkCard(benchmarkCards, benchmark)
|
| 61 |
+
for (const domain of card?.benchmark_details?.domains ?? []) {
|
| 62 |
+
const existing = domainMap.get(domain) ?? new Set()
|
| 63 |
+
existing.add(benchmark)
|
| 64 |
+
domainMap.set(domain, existing)
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
return Array.from(domainMap.entries())
|
| 69 |
+
.map(([domain, benchmarks]) => ({ domain, count: benchmarks.size }))
|
| 70 |
+
.sort((a, b) => b.count - a.count)
|
| 71 |
+
}, [models, benchmarkCards])
|
| 72 |
+
|
| 73 |
+
const filteredModels = useMemo(() => {
|
| 74 |
+
const query = searchQuery.trim().toLowerCase()
|
| 75 |
+
const filtered = query
|
| 76 |
+
? models.filter((model) => {
|
| 77 |
+
const haystacks = [
|
| 78 |
+
model.model_name,
|
| 79 |
+
model.canonical_model_name,
|
| 80 |
+
model.developer,
|
| 81 |
+
...model.top_scores.map((score) => score.benchmark),
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
return haystacks.some((value) => value?.toLowerCase().includes(query))
|
| 85 |
+
})
|
| 86 |
+
: [...models]
|
| 87 |
+
|
| 88 |
+
switch (sortBy) {
|
| 89 |
+
case "date":
|
| 90 |
+
filtered.sort(
|
| 91 |
+
(a, b) =>
|
| 92 |
+
new Date(b.latest_timestamp).getTime() -
|
| 93 |
+
new Date(a.latest_timestamp).getTime()
|
| 94 |
+
)
|
| 95 |
+
break
|
| 96 |
+
case "name":
|
| 97 |
+
filtered.sort((a, b) => a.model_name.localeCompare(b.model_name))
|
| 98 |
+
break
|
| 99 |
+
case "benchmarks":
|
| 100 |
+
filtered.sort((a, b) => b.benchmarks_count - a.benchmarks_count)
|
| 101 |
+
break
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
return filtered
|
| 105 |
+
}, [models, searchQuery, sortBy])
|
| 106 |
+
|
| 107 |
+
useEffect(() => {
|
| 108 |
+
setPage(1)
|
| 109 |
+
}, [searchQuery, sortBy])
|
| 110 |
+
|
| 111 |
+
const pagedModels = useMemo(
|
| 112 |
+
() => filteredModels.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
| 113 |
+
[filteredModels, page]
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
if (loading) {
|
| 117 |
+
return (
|
| 118 |
+
<div className="min-h-screen bg-background">
|
| 119 |
+
<Navigation />
|
| 120 |
+
<main className="container mx-auto px-4 py-8">
|
| 121 |
+
<div className="flex h-96 items-center justify-center">
|
| 122 |
+
<div className="text-lg text-muted-foreground">Loading developer...</div>
|
| 123 |
+
</div>
|
| 124 |
+
</main>
|
| 125 |
+
</div>
|
| 126 |
+
)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
if (error) {
|
| 130 |
+
return (
|
| 131 |
+
<div className="min-h-screen bg-background">
|
| 132 |
+
<Navigation />
|
| 133 |
+
<main className="container mx-auto px-4 py-8">
|
| 134 |
+
<div className="flex flex-col items-center justify-center h-96 space-y-4">
|
| 135 |
+
<div className="text-lg text-muted-foreground">{error}</div>
|
| 136 |
+
<Button onClick={handleBack}>
|
| 137 |
+
<ArrowLeft className="mr-2 h-4 w-4" />
|
| 138 |
+
Back to Developers
|
| 139 |
+
</Button>
|
| 140 |
+
</div>
|
| 141 |
+
</main>
|
| 142 |
+
</div>
|
| 143 |
+
)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
return (
|
| 147 |
+
<div className="min-h-screen bg-background">
|
| 148 |
+
<Navigation />
|
| 149 |
+
<main className="container mx-auto px-4 py-8">
|
| 150 |
+
<PageHeader
|
| 151 |
+
eyebrow="Developer"
|
| 152 |
+
title={developer}
|
| 153 |
+
description={`Evaluation coverage across ${models.length} model${models.length !== 1 ? "s" : ""} from this developer, including benchmark domain coverage where metadata is available.`}
|
| 154 |
+
metaItems={[
|
| 155 |
+
{ label: "Models", value: models.length.toString() },
|
| 156 |
+
{
|
| 157 |
+
label: "Reported Results",
|
| 158 |
+
value: models.reduce((sum, model) => sum + model.evaluations_count, 0).toString(),
|
| 159 |
+
},
|
| 160 |
+
...(domainCoverage.length > 0
|
| 161 |
+
? [{ label: "Domains covered", value: domainCoverage.length.toString() }]
|
| 162 |
+
: []),
|
| 163 |
+
]}
|
| 164 |
+
>
|
| 165 |
+
<Button variant="outline" onClick={handleBack}>
|
| 166 |
+
<ArrowLeft className="mr-2 h-4 w-4" />
|
| 167 |
+
Back
|
| 168 |
+
</Button>
|
| 169 |
+
</PageHeader>
|
| 170 |
+
|
| 171 |
+
{/* Domain coverage strip */}
|
| 172 |
+
{domainCoverage.length > 0 && (
|
| 173 |
+
<div className="mb-4 mt-6 rounded-[1.5rem] border border-border/70 bg-muted/10 p-4">
|
| 174 |
+
<div className="mb-2 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
| 175 |
+
<Tag className="h-3.5 w-3.5" />
|
| 176 |
+
Benchmark domain coverage
|
| 177 |
+
</div>
|
| 178 |
+
<div className="flex flex-wrap gap-2">
|
| 179 |
+
{domainCoverage.map(({ domain, count }) => (
|
| 180 |
+
<span
|
| 181 |
+
key={domain}
|
| 182 |
+
className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-background px-3 py-1 text-xs font-medium capitalize"
|
| 183 |
+
>
|
| 184 |
+
{domain}
|
| 185 |
+
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
|
| 186 |
+
{count}
|
| 187 |
+
</span>
|
| 188 |
+
</span>
|
| 189 |
+
))}
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
)}
|
| 193 |
+
|
| 194 |
+
<div className="mb-8 mt-8 flex flex-col gap-4 border-b border-border/50 pb-6 sm:flex-row">
|
| 195 |
+
<div className="relative w-full sm:max-w-sm">
|
| 196 |
+
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
| 197 |
+
<Input
|
| 198 |
+
value={searchQuery}
|
| 199 |
+
onChange={(event) => setSearchQuery(event.target.value)}
|
| 200 |
+
placeholder="Search models or benchmarks"
|
| 201 |
+
className="pl-9"
|
| 202 |
+
/>
|
| 203 |
+
</div>
|
| 204 |
+
<Select value={sortBy} onValueChange={(value) => setSortBy(value as typeof sortBy)}>
|
| 205 |
+
<SelectTrigger className="w-[180px]">
|
| 206 |
+
<ArrowUpDown className="mr-2 h-4 w-4" />
|
| 207 |
+
<SelectValue placeholder="Sort by" />
|
| 208 |
+
</SelectTrigger>
|
| 209 |
+
<SelectContent>
|
| 210 |
+
<SelectItem value="date">Latest First</SelectItem>
|
| 211 |
+
<SelectItem value="name">Name (A-Z)</SelectItem>
|
| 212 |
+
<SelectItem value="benchmarks">Most Benchmark Coverage</SelectItem>
|
| 213 |
+
</SelectContent>
|
| 214 |
+
</Select>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
{filteredModels.length === 0 ? (
|
| 218 |
+
<div className="py-12 text-center text-lg text-muted-foreground">
|
| 219 |
+
No models found matching your filters
|
| 220 |
+
</div>
|
| 221 |
+
) : (
|
| 222 |
+
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-2">
|
| 223 |
+
{pagedModels.map((model, index) => (
|
| 224 |
+
<BenchmarkEvaluationCard
|
| 225 |
+
key={model.id}
|
| 226 |
+
data={model}
|
| 227 |
+
benchmarkCards={benchmarkCards}
|
| 228 |
+
delayMs={Math.min(index * 45, 240)}
|
| 229 |
+
/>
|
| 230 |
+
))}
|
| 231 |
+
</div>
|
| 232 |
+
)}
|
| 233 |
+
|
| 234 |
+
<ListPagination
|
| 235 |
+
page={page}
|
| 236 |
+
pageSize={PAGE_SIZE}
|
| 237 |
+
totalItems={filteredModels.length}
|
| 238 |
+
itemLabel="models"
|
| 239 |
+
onPageChange={setPage}
|
| 240 |
+
/>
|
| 241 |
+
</main>
|
| 242 |
+
</div>
|
| 243 |
+
)
|
| 244 |
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useEffect, useMemo, useState } from "react"
|
| 4 |
+
import { useParams, useSearchParams } from "next/navigation"
|
| 5 |
+
import { ScoreDistribution } from "@/components/score-distribution"
|
| 6 |
+
import { fetchEvalSummary } from "@/lib/dashboard-data-client"
|
| 7 |
+
import { getMetricChipLabel } from "@/lib/metric-labels"
|
| 8 |
+
import { routeIdFromSegments } from "@/lib/utils"
|
| 9 |
+
import type { BenchmarkEvalSummary } from "@/lib/eval-processing"
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Embed-only render of the score-distribution histogram for one eval.
|
| 13 |
+
* Designed for iframes: no nav, no audience bar, no surrounding chrome.
|
| 14 |
+
*
|
| 15 |
+
* Slice-aware: when the eval has subtask slices (e.g. Global MMLU's
|
| 16 |
+
* per-language splits), this page renders a SPLIT dropdown above the
|
| 17 |
+
* plot so the embedded viewer can switch slices the same way the parent
|
| 18 |
+
* eval page does. When there's no slice axis, falls back to building
|
| 19 |
+
* one series per metric (e.g. agentharm's per-category histogram).
|
| 20 |
+
*
|
| 21 |
+
* Query params:
|
| 22 |
+
* ?view=distribution (default) — lock to distribution, hide toggle
|
| 23 |
+
* ?view=frontier — lock to frontier, hide toggle
|
| 24 |
+
* ?view=both — show the Distribution/Frontier toggle
|
| 25 |
+
* ?slice=<subtask_key> — start with this slice selected
|
| 26 |
+
*/
|
| 27 |
+
export default function EmbedEvalDistribution() {
|
| 28 |
+
const params = useParams()
|
| 29 |
+
const searchParams = useSearchParams()
|
| 30 |
+
const evalId = routeIdFromSegments(params.id)
|
| 31 |
+
const viewParam = (searchParams.get("view") || "distribution").toLowerCase()
|
| 32 |
+
const sliceParam = searchParams.get("slice")
|
| 33 |
+
const showToggle = viewParam === "both"
|
| 34 |
+
const defaultView: "distribution" | "frontier" =
|
| 35 |
+
viewParam === "frontier" ? "frontier" : "distribution"
|
| 36 |
+
|
| 37 |
+
const [summary, setSummary] = useState<BenchmarkEvalSummary | null>(null)
|
| 38 |
+
const [error, setError] = useState<string | null>(null)
|
| 39 |
+
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
let cancelled = false
|
| 42 |
+
fetchEvalSummary(evalId)
|
| 43 |
+
.then((s) => {
|
| 44 |
+
if (!cancelled) setSummary(s)
|
| 45 |
+
})
|
| 46 |
+
.catch((err) => {
|
| 47 |
+
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
| 48 |
+
})
|
| 49 |
+
return () => {
|
| 50 |
+
cancelled = true
|
| 51 |
+
}
|
| 52 |
+
}, [evalId])
|
| 53 |
+
|
| 54 |
+
// Slice axis — present when the eval has multiple subtask-scope metrics
|
| 55 |
+
// sharing a primary root metric (e.g. Global MMLU's 24 language splits).
|
| 56 |
+
const sliceAxis = useMemo(() => {
|
| 57 |
+
if (!summary) return null
|
| 58 |
+
const metrics = summary.leaderboard_metrics ?? []
|
| 59 |
+
const primary = metrics.find((m) => m.scope !== "subtask")
|
| 60 |
+
if (!primary?.column_key) return null
|
| 61 |
+
const seen = new Map<string, string>()
|
| 62 |
+
for (const m of metrics) {
|
| 63 |
+
if (m.scope === "subtask" && m.subtask_key && !seen.has(m.subtask_key)) {
|
| 64 |
+
seen.set(m.subtask_key, m.subtask_name ?? m.subtask_key)
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
if (seen.size <= 1) return null
|
| 68 |
+
return {
|
| 69 |
+
primaryColumn: primary.column_key,
|
| 70 |
+
primaryLabel: getMetricChipLabel(primary),
|
| 71 |
+
unit: primary.unit ?? summary.metric_config.unit,
|
| 72 |
+
lowerIsBetter: Boolean(primary.lower_is_better ?? summary.metric_config.lower_is_better),
|
| 73 |
+
slices: Array.from(seen, ([key, label]) => ({ key, label })),
|
| 74 |
+
}
|
| 75 |
+
}, [summary])
|
| 76 |
+
|
| 77 |
+
const ALL_SLICE_KEY = "__all__"
|
| 78 |
+
const [activeSlice, setActiveSlice] = useState<string>(() => {
|
| 79 |
+
if (sliceParam && sliceParam.trim()) return sliceParam.trim()
|
| 80 |
+
return ALL_SLICE_KEY
|
| 81 |
+
})
|
| 82 |
+
// If the URL named a slice the eval doesn't carry, fall back to Overall
|
| 83 |
+
// once the summary loads.
|
| 84 |
+
useEffect(() => {
|
| 85 |
+
if (!sliceAxis) return
|
| 86 |
+
if (activeSlice === ALL_SLICE_KEY) return
|
| 87 |
+
if (!sliceAxis.slices.some((s) => s.key === activeSlice)) {
|
| 88 |
+
setActiveSlice(ALL_SLICE_KEY)
|
| 89 |
+
}
|
| 90 |
+
}, [activeSlice, sliceAxis])
|
| 91 |
+
|
| 92 |
+
const series = useMemo(() => {
|
| 93 |
+
if (!summary) return null
|
| 94 |
+
const rows = summary.leaderboard_rows ?? []
|
| 95 |
+
|
| 96 |
+
// Slice-axis path: render one series for the active slice (Overall or
|
| 97 |
+
// a specific subtask). Drives the SPLIT dropdown UX.
|
| 98 |
+
if (sliceAxis) {
|
| 99 |
+
const columnKey =
|
| 100 |
+
activeSlice === ALL_SLICE_KEY
|
| 101 |
+
? sliceAxis.primaryColumn
|
| 102 |
+
: `${sliceAxis.primaryColumn}::${activeSlice}`
|
| 103 |
+
const points: { score: number; releaseDate: string | null; modelName: string }[] = []
|
| 104 |
+
for (const row of rows) {
|
| 105 |
+
const raw = (row.values as Record<string, unknown> | undefined)?.[columnKey]
|
| 106 |
+
const numeric = typeof raw === "number" ? raw : Number(raw)
|
| 107 |
+
if (!Number.isFinite(numeric)) continue
|
| 108 |
+
const modelInfo = (row as { model_info?: { name?: string; release_date?: string | null } }).model_info
|
| 109 |
+
points.push({
|
| 110 |
+
score: numeric,
|
| 111 |
+
modelName: modelInfo?.name ?? "",
|
| 112 |
+
releaseDate: modelInfo?.release_date ?? null,
|
| 113 |
+
})
|
| 114 |
+
}
|
| 115 |
+
if (points.length < 3) return null
|
| 116 |
+
const sliceLabel =
|
| 117 |
+
activeSlice === ALL_SLICE_KEY
|
| 118 |
+
? "Overall"
|
| 119 |
+
: sliceAxis.slices.find((s) => s.key === activeSlice)?.label ?? activeSlice
|
| 120 |
+
return [
|
| 121 |
+
{
|
| 122 |
+
key: `${sliceAxis.primaryColumn}::${activeSlice}`,
|
| 123 |
+
label: `${sliceAxis.primaryLabel} · ${sliceLabel}`,
|
| 124 |
+
values: points.map((p) => p.score),
|
| 125 |
+
unit: sliceAxis.unit,
|
| 126 |
+
lowerIsBetter: sliceAxis.lowerIsBetter,
|
| 127 |
+
points,
|
| 128 |
+
},
|
| 129 |
+
]
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// Non-slice path: one series per metric (e.g. agentharm's multi-metric
|
| 133 |
+
// histogram). ScoreDistribution surfaces a metric chip picker.
|
| 134 |
+
const metrics = summary.leaderboard_metrics ?? []
|
| 135 |
+
const built = metrics
|
| 136 |
+
.map((metric) => {
|
| 137 |
+
const columnKey = metric.column_key ?? metric.metric_summary_id
|
| 138 |
+
if (!columnKey) return null
|
| 139 |
+
const points: { score: number; releaseDate: string | null; modelName: string }[] = []
|
| 140 |
+
for (const row of rows) {
|
| 141 |
+
const raw = (row.values as Record<string, unknown> | undefined)?.[columnKey]
|
| 142 |
+
const numeric = typeof raw === "number" ? raw : Number(raw)
|
| 143 |
+
if (!Number.isFinite(numeric)) continue
|
| 144 |
+
const modelInfo = (row as { model_info?: { name?: string; release_date?: string | null } }).model_info
|
| 145 |
+
points.push({
|
| 146 |
+
score: numeric,
|
| 147 |
+
modelName: modelInfo?.name ?? "",
|
| 148 |
+
releaseDate: modelInfo?.release_date ?? null,
|
| 149 |
+
})
|
| 150 |
+
}
|
| 151 |
+
if (points.length < 3) return null
|
| 152 |
+
return {
|
| 153 |
+
key: columnKey,
|
| 154 |
+
label: getMetricChipLabel(metric),
|
| 155 |
+
values: points.map((p) => p.score),
|
| 156 |
+
unit: metric.unit ?? summary.metric_config.unit,
|
| 157 |
+
lowerIsBetter: Boolean(metric.lower_is_better ?? summary.metric_config.lower_is_better),
|
| 158 |
+
points,
|
| 159 |
+
}
|
| 160 |
+
})
|
| 161 |
+
.filter((s): s is NonNullable<typeof s> => s !== null)
|
| 162 |
+
return built.length > 0 ? built : null
|
| 163 |
+
}, [summary, sliceAxis, activeSlice])
|
| 164 |
+
|
| 165 |
+
if (error) {
|
| 166 |
+
return (
|
| 167 |
+
<div className="font-mono" style={{ fontSize: 12, color: "var(--fg-muted)" }}>
|
| 168 |
+
Failed to load: {error}
|
| 169 |
+
</div>
|
| 170 |
+
)
|
| 171 |
+
}
|
| 172 |
+
if (!summary) {
|
| 173 |
+
return (
|
| 174 |
+
<div
|
| 175 |
+
className="font-mono uppercase"
|
| 176 |
+
style={{ fontSize: 10, letterSpacing: "0.18em", color: "var(--fg-subtle)" }}
|
| 177 |
+
>
|
| 178 |
+
Loading…
|
| 179 |
+
</div>
|
| 180 |
+
)
|
| 181 |
+
}
|
| 182 |
+
if (!series) {
|
| 183 |
+
return (
|
| 184 |
+
<div className="font-mono" style={{ fontSize: 12, color: "var(--fg-muted)" }}>
|
| 185 |
+
No score data available for this evaluation yet.
|
| 186 |
+
</div>
|
| 187 |
+
)
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
return (
|
| 191 |
+
<div>
|
| 192 |
+
<div
|
| 193 |
+
className="font-mono uppercase mb-2"
|
| 194 |
+
style={{ fontSize: 10, letterSpacing: "0.14em", color: "var(--fg-subtle)" }}
|
| 195 |
+
>
|
| 196 |
+
{summary.evaluation_name} ·{" "}
|
| 197 |
+
{defaultView === "frontier" ? "Pareto frontier" : "Score distribution"}
|
| 198 |
+
</div>
|
| 199 |
+
{sliceAxis && (
|
| 200 |
+
<div className="mb-3 flex items-center gap-3">
|
| 201 |
+
<span
|
| 202 |
+
className="font-mono uppercase shrink-0"
|
| 203 |
+
style={{ fontSize: 10, letterSpacing: "0.14em", color: "var(--fg-subtle)" }}
|
| 204 |
+
>
|
| 205 |
+
Split
|
| 206 |
+
</span>
|
| 207 |
+
<select
|
| 208 |
+
className="ec-select"
|
| 209 |
+
value={activeSlice}
|
| 210 |
+
onChange={(e) => setActiveSlice(e.target.value)}
|
| 211 |
+
>
|
| 212 |
+
<option value={ALL_SLICE_KEY}>Overall</option>
|
| 213 |
+
{sliceAxis.slices.map((s) => (
|
| 214 |
+
<option key={s.key} value={s.key}>
|
| 215 |
+
{s.label}
|
| 216 |
+
</option>
|
| 217 |
+
))}
|
| 218 |
+
</select>
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
<ScoreDistribution
|
| 222 |
+
series={series}
|
| 223 |
+
defaultView={defaultView}
|
| 224 |
+
showViewToggle={showToggle}
|
| 225 |
+
/>
|
| 226 |
+
</div>
|
| 227 |
+
)
|
| 228 |
+
}
|
|
@@ -0,0 +1,734 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useCallback, useEffect, useMemo, useState } from "react"
|
| 4 |
+
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation"
|
| 5 |
+
import Link from "next/link"
|
| 6 |
+
import { Button } from "@/components/ui/button"
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
| 8 |
+
import { Badge } from "@/components/ui/badge"
|
| 9 |
+
import { Input } from "@/components/ui/input"
|
| 10 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 11 |
+
import { ArrowLeft, BarChart3, Grid3X3, Search } from "lucide-react"
|
| 12 |
+
import { Navigation } from "@/components/navigation"
|
| 13 |
+
import { EvalDetail } from "@/components/eval-detail"
|
| 14 |
+
import type { BenchmarkEvalSummary } from "@/lib/eval-processing"
|
| 15 |
+
import { fetchEvalSummary } from "@/lib/dashboard-data-client"
|
| 16 |
+
import { getCategoryColor } from "@/lib/benchmark-schema"
|
| 17 |
+
|
| 18 |
+
const PARAM_RANGE_VALUES = [1, 2, 3, 4, 6, 8, 10, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 500] as const
|
| 19 |
+
const PARAM_RANGE_MARKERS = [
|
| 20 |
+
{ label: "< 1B", step: 0 },
|
| 21 |
+
{ label: "6B", step: PARAM_RANGE_VALUES.indexOf(6) },
|
| 22 |
+
{ label: "12B", step: PARAM_RANGE_VALUES.indexOf(12) },
|
| 23 |
+
{ label: "32B", step: PARAM_RANGE_VALUES.indexOf(32) },
|
| 24 |
+
{ label: "128B", step: PARAM_RANGE_VALUES.indexOf(128) },
|
| 25 |
+
{ label: "> 500B", step: PARAM_RANGE_VALUES.length - 1 },
|
| 26 |
+
] as const
|
| 27 |
+
|
| 28 |
+
function formatParamBoundLabel(step: number, bound: "min" | "max") {
|
| 29 |
+
const maxStepIndex = PARAM_RANGE_VALUES.length - 1
|
| 30 |
+
if (bound === "min" && step <= 0) return "< 1B"
|
| 31 |
+
if (bound === "max" && step >= maxStepIndex) return "> 500B"
|
| 32 |
+
const value = PARAM_RANGE_VALUES[step]
|
| 33 |
+
return value != null ? `${value}B` : "Not reported"
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function normalizeMetadataList(value: unknown): string[] {
|
| 37 |
+
if (Array.isArray(value)) {
|
| 38 |
+
return value
|
| 39 |
+
.filter((item): item is string => typeof item === "string")
|
| 40 |
+
.map((item) => item.trim())
|
| 41 |
+
.filter(Boolean)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
if (typeof value !== "string") return []
|
| 45 |
+
|
| 46 |
+
const normalized = value.trim()
|
| 47 |
+
if (!normalized) return []
|
| 48 |
+
|
| 49 |
+
const looksDelimited = /[,;|]/.test(normalized)
|
| 50 |
+
if (looksDelimited) {
|
| 51 |
+
return normalized
|
| 52 |
+
.split(/[,;|]/)
|
| 53 |
+
.map((item) => item.trim())
|
| 54 |
+
.filter(Boolean)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Treat long prose values as invalid list data rather than rendering oversized chips.
|
| 58 |
+
if (normalized.length > 40 || /\s/.test(normalized)) return []
|
| 59 |
+
|
| 60 |
+
return [normalized]
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export default function EvalDetailPage() {
|
| 64 |
+
const params = useParams()
|
| 65 |
+
const pathname = usePathname()
|
| 66 |
+
const router = useRouter()
|
| 67 |
+
const searchParams = useSearchParams()
|
| 68 |
+
const [summary, setSummary] = useState<BenchmarkEvalSummary | null>(null)
|
| 69 |
+
const [subSummaries, setSubSummaries] = useState<BenchmarkEvalSummary[]>([])
|
| 70 |
+
const [loading, setLoading] = useState(true)
|
| 71 |
+
const [error, setError] = useState<string | null>(null)
|
| 72 |
+
const [matrixSearch, setMatrixSearch] = useState("")
|
| 73 |
+
const returnTo = searchParams.get("from")
|
| 74 |
+
const currentDetailHref = useMemo(() => {
|
| 75 |
+
const params = new URLSearchParams(searchParams.toString())
|
| 76 |
+
params.delete("from")
|
| 77 |
+
const query = params.toString()
|
| 78 |
+
return query ? `${pathname}?${query}` : pathname
|
| 79 |
+
}, [pathname, searchParams])
|
| 80 |
+
|
| 81 |
+
const handleBack = useCallback(() => {
|
| 82 |
+
if (returnTo?.startsWith("/")) {
|
| 83 |
+
router.push(returnTo)
|
| 84 |
+
return
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
if (typeof window !== "undefined" && window.history.length > 1) {
|
| 88 |
+
router.back()
|
| 89 |
+
return
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
router.push("/evals")
|
| 93 |
+
}, [returnTo, router])
|
| 94 |
+
|
| 95 |
+
useEffect(() => {
|
| 96 |
+
const load = async () => {
|
| 97 |
+
try {
|
| 98 |
+
const evalId = decodeURIComponent(params.id as string)
|
| 99 |
+
const found = await fetchEvalSummary(evalId)
|
| 100 |
+
setSummary(found)
|
| 101 |
+
document.title = `${found.evaluation_name} | Benchmark`
|
| 102 |
+
|
| 103 |
+
// If aggregated, fetch each sub-eval for the matrix view
|
| 104 |
+
if (found.is_aggregated && found.aggregate_sources?.length) {
|
| 105 |
+
const subs = await Promise.all(
|
| 106 |
+
found.aggregate_sources.map(async (source) => {
|
| 107 |
+
try {
|
| 108 |
+
return await fetchEvalSummary(source.evaluation_id)
|
| 109 |
+
} catch {
|
| 110 |
+
return null
|
| 111 |
+
}
|
| 112 |
+
})
|
| 113 |
+
)
|
| 114 |
+
setSubSummaries(subs.filter((s): s is BenchmarkEvalSummary => s !== null))
|
| 115 |
+
}
|
| 116 |
+
} catch (err) {
|
| 117 |
+
console.error(err)
|
| 118 |
+
setError("Evaluation not found")
|
| 119 |
+
} finally {
|
| 120 |
+
setLoading(false)
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
load()
|
| 124 |
+
}, [params.id])
|
| 125 |
+
|
| 126 |
+
if (loading) {
|
| 127 |
+
return (
|
| 128 |
+
<div className="min-h-screen bg-background">
|
| 129 |
+
<Navigation />
|
| 130 |
+
<main className="container mx-auto px-4 py-8">
|
| 131 |
+
<div className="flex items-center justify-center h-96">
|
| 132 |
+
<div className="text-lg text-muted-foreground">Loading evaluation details...</div>
|
| 133 |
+
</div>
|
| 134 |
+
</main>
|
| 135 |
+
</div>
|
| 136 |
+
)
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (error || !summary) {
|
| 140 |
+
return (
|
| 141 |
+
<div className="min-h-screen bg-background">
|
| 142 |
+
<Navigation />
|
| 143 |
+
<main className="container mx-auto px-4 py-8">
|
| 144 |
+
<div className="flex flex-col items-center justify-center h-96 space-y-4">
|
| 145 |
+
<div className="text-lg text-muted-foreground">{error ?? "Evaluation not found"}</div>
|
| 146 |
+
<Button onClick={handleBack}>
|
| 147 |
+
<ArrowLeft className="mr-2 h-4 w-4" />
|
| 148 |
+
Back to Evaluations
|
| 149 |
+
</Button>
|
| 150 |
+
</div>
|
| 151 |
+
</main>
|
| 152 |
+
</div>
|
| 153 |
+
)
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const isComposite = summary.is_aggregated && (summary.aggregate_sources?.length ?? 0) > 1
|
| 157 |
+
|
| 158 |
+
return (
|
| 159 |
+
<div className="min-h-screen bg-background">
|
| 160 |
+
<Navigation />
|
| 161 |
+
<div className="border-b bg-muted/30">
|
| 162 |
+
<div className="container mx-auto px-4 sm:px-6 py-4 sm:py-6">
|
| 163 |
+
<div className="hidden sm:grid sm:grid-cols-[auto_1fr_auto] sm:items-center sm:gap-4">
|
| 164 |
+
<Button variant="ghost" onClick={handleBack}>
|
| 165 |
+
<ArrowLeft className="mr-2 h-4 w-4" />
|
| 166 |
+
Back
|
| 167 |
+
</Button>
|
| 168 |
+
<div className="text-center">
|
| 169 |
+
<h2 className="text-xl font-medium tracking-tight text-foreground/90 md:text-2xl">
|
| 170 |
+
{isComposite ? "Suite" : "Single benchmark details"}
|
| 171 |
+
</h2>
|
| 172 |
+
</div>
|
| 173 |
+
<div />
|
| 174 |
+
</div>
|
| 175 |
+
<div className="flex items-center gap-3 sm:hidden">
|
| 176 |
+
<Button variant="ghost" size="sm" onClick={handleBack} className="shrink-0">
|
| 177 |
+
<ArrowLeft className="h-4 w-4" />
|
| 178 |
+
</Button>
|
| 179 |
+
<div className="flex-1 text-center">
|
| 180 |
+
<h2 className="text-base font-medium tracking-tight text-foreground/90">
|
| 181 |
+
{isComposite ? "Suite" : "Single benchmark details"}
|
| 182 |
+
</h2>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
<main className="container mx-auto px-4 py-8">
|
| 188 |
+
{isComposite ? (
|
| 189 |
+
<CompositeEvalView
|
| 190 |
+
summary={summary}
|
| 191 |
+
subSummaries={subSummaries}
|
| 192 |
+
matrixSearch={matrixSearch}
|
| 193 |
+
onMatrixSearchChange={setMatrixSearch}
|
| 194 |
+
currentDetailHref={currentDetailHref}
|
| 195 |
+
/>
|
| 196 |
+
) : (
|
| 197 |
+
<EvalDetail summary={summary} />
|
| 198 |
+
)}
|
| 199 |
+
</main>
|
| 200 |
+
</div>
|
| 201 |
+
)
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// ---------------------------------------------------------------------------
|
| 205 |
+
// Suite view (paper §3.2 — composite reporting unit)
|
| 206 |
+
// ---------------------------------------------------------------------------
|
| 207 |
+
|
| 208 |
+
function CompositeEvalView({
|
| 209 |
+
summary,
|
| 210 |
+
subSummaries,
|
| 211 |
+
matrixSearch,
|
| 212 |
+
onMatrixSearchChange,
|
| 213 |
+
currentDetailHref,
|
| 214 |
+
}: {
|
| 215 |
+
summary: BenchmarkEvalSummary
|
| 216 |
+
subSummaries: BenchmarkEvalSummary[]
|
| 217 |
+
matrixSearch: string
|
| 218 |
+
onMatrixSearchChange: (v: string) => void
|
| 219 |
+
currentDetailHref: string
|
| 220 |
+
}) {
|
| 221 |
+
return (
|
| 222 |
+
<div className="space-y-6">
|
| 223 |
+
{/* Header */}
|
| 224 |
+
<Card>
|
| 225 |
+
<CardContent className="p-5 sm:p-6 space-y-4">
|
| 226 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 227 |
+
<Badge variant="outline" className="text-[11px] uppercase tracking-[0.18em]">
|
| 228 |
+
Suite
|
| 229 |
+
</Badge>
|
| 230 |
+
<Badge variant="secondary">
|
| 231 |
+
{summary.aggregate_sources?.length ?? 0} metrics
|
| 232 |
+
</Badge>
|
| 233 |
+
<Badge variant="secondary">{summary.models_count.toLocaleString()} models</Badge>
|
| 234 |
+
<Badge className={getCategoryColor(summary.category)}>
|
| 235 |
+
{summary.category}
|
| 236 |
+
</Badge>
|
| 237 |
+
</div>
|
| 238 |
+
<div>
|
| 239 |
+
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
|
| 240 |
+
{summary.evaluation_name}
|
| 241 |
+
</h1>
|
| 242 |
+
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
| 243 |
+
{summary.benchmark_card?.purpose_and_intended_users?.goal
|
| 244 |
+
?? `Suite aggregating ${summary.aggregate_sources?.length ?? 0} metrics across ${summary.models_count.toLocaleString()} models.`}
|
| 245 |
+
</p>
|
| 246 |
+
</div>
|
| 247 |
+
</CardContent>
|
| 248 |
+
</Card>
|
| 249 |
+
|
| 250 |
+
<Tabs defaultValue="metrics">
|
| 251 |
+
<TabsList>
|
| 252 |
+
<TabsTrigger value="metrics" className="gap-2">
|
| 253 |
+
<BarChart3 className="h-4 w-4" />
|
| 254 |
+
Sub-Benchmarks
|
| 255 |
+
</TabsTrigger>
|
| 256 |
+
<TabsTrigger value="matrix" className="gap-2">
|
| 257 |
+
<Grid3X3 className="h-4 w-4" />
|
| 258 |
+
Score breakdown
|
| 259 |
+
</TabsTrigger>
|
| 260 |
+
</TabsList>
|
| 261 |
+
|
| 262 |
+
<TabsContent value="metrics" className="mt-6">
|
| 263 |
+
<SubBenchmarkCards
|
| 264 |
+
sources={summary.aggregate_sources ?? []}
|
| 265 |
+
subSummaries={subSummaries}
|
| 266 |
+
currentDetailHref={currentDetailHref}
|
| 267 |
+
/>
|
| 268 |
+
</TabsContent>
|
| 269 |
+
|
| 270 |
+
<TabsContent value="matrix" className="mt-6">
|
| 271 |
+
<MatrixLeaderboard
|
| 272 |
+
summary={summary}
|
| 273 |
+
subSummaries={subSummaries}
|
| 274 |
+
search={matrixSearch}
|
| 275 |
+
onSearchChange={onMatrixSearchChange}
|
| 276 |
+
/>
|
| 277 |
+
</TabsContent>
|
| 278 |
+
</Tabs>
|
| 279 |
+
</div>
|
| 280 |
+
)
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// ---------------------------------------------------------------------------
|
| 284 |
+
// Sub-benchmark cards
|
| 285 |
+
// ---------------------------------------------------------------------------
|
| 286 |
+
|
| 287 |
+
function SubBenchmarkCards({
|
| 288 |
+
sources,
|
| 289 |
+
subSummaries,
|
| 290 |
+
currentDetailHref,
|
| 291 |
+
}: {
|
| 292 |
+
sources: NonNullable<BenchmarkEvalSummary["aggregate_sources"]>
|
| 293 |
+
subSummaries: BenchmarkEvalSummary[]
|
| 294 |
+
currentDetailHref: string
|
| 295 |
+
}) {
|
| 296 |
+
const subMap = useMemo(
|
| 297 |
+
() => new Map(subSummaries.map((s) => [s.evaluation_id, s])),
|
| 298 |
+
[subSummaries]
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
return (
|
| 302 |
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
| 303 |
+
{sources.map((source) => {
|
| 304 |
+
const sub = subMap.get(source.evaluation_id)
|
| 305 |
+
const card = sub?.benchmark_card
|
| 306 |
+
const overview = card?.benchmark_details?.overview ?? sub?.metric_config?.evaluation_description
|
| 307 |
+
const domains = normalizeMetadataList(card?.benchmark_details?.domains)
|
| 308 |
+
const goal = card?.purpose_and_intended_users?.goal
|
| 309 |
+
|
| 310 |
+
return (
|
| 311 |
+
<Link
|
| 312 |
+
key={source.evaluation_id}
|
| 313 |
+
href={`/evals/${source.evaluation_id}?from=${encodeURIComponent(currentDetailHref)}`}
|
| 314 |
+
className="group"
|
| 315 |
+
>
|
| 316 |
+
<Card className="h-full transition-all hover:-translate-y-0.5 hover:shadow-lg">
|
| 317 |
+
<CardHeader className="pb-3">
|
| 318 |
+
<div className="flex items-center justify-between gap-2">
|
| 319 |
+
<CardTitle className="text-base font-semibold transition-colors group-hover:text-primary">
|
| 320 |
+
{card?.benchmark_details?.name ?? source.composite_benchmark_name}
|
| 321 |
+
</CardTitle>
|
| 322 |
+
<Badge variant="outline" className="text-xs shrink-0">
|
| 323 |
+
{source.models_count} models
|
| 324 |
+
</Badge>
|
| 325 |
+
</div>
|
| 326 |
+
</CardHeader>
|
| 327 |
+
<CardContent className="pt-0 space-y-3">
|
| 328 |
+
{overview && (
|
| 329 |
+
<p className="text-sm text-muted-foreground line-clamp-3">
|
| 330 |
+
{overview}
|
| 331 |
+
</p>
|
| 332 |
+
)}
|
| 333 |
+
|
| 334 |
+
{sub?.best_model && (
|
| 335 |
+
<div className="text-sm">
|
| 336 |
+
<span className="text-muted-foreground">Top: </span>
|
| 337 |
+
<span className="font-medium">{sub.best_model.name}</span>
|
| 338 |
+
<span className="ml-1 text-muted-foreground">
|
| 339 |
+
({(sub.best_model.score * 100).toFixed(1)}%)
|
| 340 |
+
</span>
|
| 341 |
+
</div>
|
| 342 |
+
)}
|
| 343 |
+
|
| 344 |
+
{domains.length > 0 && (
|
| 345 |
+
<div className="flex flex-wrap gap-1.5">
|
| 346 |
+
{domains.slice(0, 3).map((domain) => (
|
| 347 |
+
<span
|
| 348 |
+
key={domain}
|
| 349 |
+
className="rounded-full border border-border/60 bg-muted/30 px-2 py-0.5 text-[10px] font-medium capitalize text-muted-foreground"
|
| 350 |
+
>
|
| 351 |
+
{domain}
|
| 352 |
+
</span>
|
| 353 |
+
))}
|
| 354 |
+
{domains.length > 3 && (
|
| 355 |
+
<span className="rounded-full border border-border/60 bg-muted/30 px-2 py-0.5 text-[10px] text-muted-foreground">
|
| 356 |
+
+{domains.length - 3}
|
| 357 |
+
</span>
|
| 358 |
+
)}
|
| 359 |
+
</div>
|
| 360 |
+
)}
|
| 361 |
+
|
| 362 |
+
<div className="flex flex-wrap items-center gap-1.5">
|
| 363 |
+
{sub?.category && (
|
| 364 |
+
<Badge className={`${getCategoryColor(sub.category)} text-[10px]`}>
|
| 365 |
+
{sub.category}
|
| 366 |
+
</Badge>
|
| 367 |
+
)}
|
| 368 |
+
</div>
|
| 369 |
+
</CardContent>
|
| 370 |
+
</Card>
|
| 371 |
+
</Link>
|
| 372 |
+
)
|
| 373 |
+
})}
|
| 374 |
+
</div>
|
| 375 |
+
)
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
// ---------------------------------------------------------------------------
|
| 379 |
+
// Matrix leaderboard (models × metrics)
|
| 380 |
+
// ---------------------------------------------------------------------------
|
| 381 |
+
|
| 382 |
+
function MatrixLeaderboard({
|
| 383 |
+
summary,
|
| 384 |
+
subSummaries,
|
| 385 |
+
search,
|
| 386 |
+
onSearchChange,
|
| 387 |
+
}: {
|
| 388 |
+
summary: BenchmarkEvalSummary
|
| 389 |
+
subSummaries: BenchmarkEvalSummary[]
|
| 390 |
+
search: string
|
| 391 |
+
onSearchChange: (v: string) => void
|
| 392 |
+
}) {
|
| 393 |
+
const [sortCol, setSortCol] = useState<string>("avg")
|
| 394 |
+
const [sortAsc, setSortAsc] = useState(false)
|
| 395 |
+
const [page, setPage] = useState(1)
|
| 396 |
+
const [hiddenCols, setHiddenCols] = useState<Set<string>>(new Set())
|
| 397 |
+
const [minParamStep, setMinParamStep] = useState(0)
|
| 398 |
+
const [maxParamStep, setMaxParamStep] = useState(PARAM_RANGE_VALUES.length - 1)
|
| 399 |
+
const PAGE_SIZE = 50
|
| 400 |
+
|
| 401 |
+
const metricDirection = useMemo(() => {
|
| 402 |
+
const map = new Map<string, boolean>()
|
| 403 |
+
for (const sub of subSummaries) {
|
| 404 |
+
map.set(sub.evaluation_name, sub.metric_config.lower_is_better)
|
| 405 |
+
}
|
| 406 |
+
return map
|
| 407 |
+
}, [subSummaries])
|
| 408 |
+
|
| 409 |
+
const { models, metrics } = useMemo(() => {
|
| 410 |
+
const metricNames = subSummaries.map((s) => s.evaluation_name)
|
| 411 |
+
const modelScores = new Map<string, { name: string; developer: string; scores: Map<string, number | null> }>()
|
| 412 |
+
|
| 413 |
+
for (const sub of subSummaries) {
|
| 414 |
+
for (const result of sub.model_results) {
|
| 415 |
+
const id = result.model_info.id
|
| 416 |
+
const existing = modelScores.get(id) ?? {
|
| 417 |
+
name: result.model_info.name,
|
| 418 |
+
developer: result.model_info.developer ?? "",
|
| 419 |
+
scores: new Map<string, number | null>(),
|
| 420 |
+
}
|
| 421 |
+
existing.scores.set(sub.evaluation_name, result.score)
|
| 422 |
+
modelScores.set(id, existing)
|
| 423 |
+
}
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
const modelList = Array.from(modelScores.entries())
|
| 427 |
+
.map(([id, data]) => {
|
| 428 |
+
const validScores = Array.from(data.scores.values()).filter(
|
| 429 |
+
(s): s is number => s != null && Number.isFinite(s) && s > -99
|
| 430 |
+
)
|
| 431 |
+
const avg = validScores.length > 0
|
| 432 |
+
? validScores.reduce((a, b) => a + b, 0) / validScores.length
|
| 433 |
+
: 0
|
| 434 |
+
// Parse model size from name (e.g., "70B", "8b", "1.5B", "405b")
|
| 435 |
+
let sizeB: number | null = null
|
| 436 |
+
const sizeMatch = (data.name + " " + id).match(/\b(\d+(?:\.\d+)?)\s*[bB]\b/)
|
| 437 |
+
if (sizeMatch) sizeB = parseFloat(sizeMatch[1])
|
| 438 |
+
|
| 439 |
+
return { id, name: data.name, developer: data.developer, avg, scores: data.scores, sizeB }
|
| 440 |
+
})
|
| 441 |
+
|
| 442 |
+
return { models: modelList, metrics: metricNames }
|
| 443 |
+
}, [subSummaries])
|
| 444 |
+
|
| 445 |
+
const visibleMetrics = useMemo(
|
| 446 |
+
() => metrics.filter((m) => !hiddenCols.has(m)),
|
| 447 |
+
[metrics, hiddenCols]
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
const sortedModels = useMemo(() => {
|
| 451 |
+
return [...models].sort((a, b) => {
|
| 452 |
+
if (sortCol === "name") {
|
| 453 |
+
return sortAsc ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
|
| 454 |
+
}
|
| 455 |
+
const aVal = sortCol === "avg" ? a.avg : (a.scores.get(sortCol) ?? -Infinity)
|
| 456 |
+
const bVal = sortCol === "avg" ? b.avg : (b.scores.get(sortCol) ?? -Infinity)
|
| 457 |
+
return sortAsc ? aVal - bVal : bVal - aVal
|
| 458 |
+
})
|
| 459 |
+
}, [models, sortCol, sortAsc])
|
| 460 |
+
|
| 461 |
+
const maxStepIndex = PARAM_RANGE_VALUES.length - 1
|
| 462 |
+
const numericMinParams = minParamStep <= 0 ? null : (PARAM_RANGE_VALUES[minParamStep] ?? null)
|
| 463 |
+
const numericMaxParams = maxParamStep >= maxStepIndex ? null : (PARAM_RANGE_VALUES[maxParamStep] ?? null)
|
| 464 |
+
|
| 465 |
+
const query = search.trim().toLowerCase()
|
| 466 |
+
const filteredModels = sortedModels.filter((m) => {
|
| 467 |
+
if (query && !(
|
| 468 |
+
m.name.toLowerCase().includes(query) ||
|
| 469 |
+
m.developer.toLowerCase().includes(query) ||
|
| 470 |
+
m.id.toLowerCase().includes(query)
|
| 471 |
+
)) return false
|
| 472 |
+
if (numericMinParams != null && (m.sizeB == null || m.sizeB < numericMinParams)) return false
|
| 473 |
+
if (numericMaxParams != null && (m.sizeB == null || m.sizeB > numericMaxParams)) return false
|
| 474 |
+
return true
|
| 475 |
+
})
|
| 476 |
+
|
| 477 |
+
const pagedModels = filteredModels.slice(0, page * PAGE_SIZE)
|
| 478 |
+
const hasMore = pagedModels.length < filteredModels.length
|
| 479 |
+
|
| 480 |
+
// Color coding per column
|
| 481 |
+
const metricRanges = useMemo(() => {
|
| 482 |
+
const ranges = new Map<string, { min: number; max: number }>()
|
| 483 |
+
for (const metric of visibleMetrics) {
|
| 484 |
+
const scores = models.map((m) => m.scores.get(metric)).filter(
|
| 485 |
+
(s): s is number => s != null && Number.isFinite(s) && s > -99
|
| 486 |
+
)
|
| 487 |
+
if (scores.length > 0) {
|
| 488 |
+
ranges.set(metric, { min: Math.min(...scores), max: Math.max(...scores) })
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
return ranges
|
| 492 |
+
}, [models, visibleMetrics])
|
| 493 |
+
|
| 494 |
+
function isValidScore(score: number | null | undefined): score is number {
|
| 495 |
+
return score != null && Number.isFinite(score) && score > -99
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
function scoreColor(metric: string, score: number): string {
|
| 499 |
+
const range = metricRanges.get(metric)
|
| 500 |
+
if (!range || range.max === range.min) return ""
|
| 501 |
+
const lower = metricDirection.get(metric) ?? false
|
| 502 |
+
const pct = lower
|
| 503 |
+
? (range.max - score) / (range.max - range.min)
|
| 504 |
+
: (score - range.min) / (range.max - range.min)
|
| 505 |
+
if (pct >= 0.8) return "bg-emerald-100 dark:bg-emerald-950/40 text-emerald-800 dark:text-emerald-200"
|
| 506 |
+
if (pct >= 0.6) return "bg-sky-50 dark:bg-sky-950/30 text-sky-800 dark:text-sky-200"
|
| 507 |
+
if (pct <= 0.2) return "bg-red-50 dark:bg-red-950/30 text-red-800 dark:text-red-200"
|
| 508 |
+
return ""
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
function formatScore(score: number): string {
|
| 512 |
+
if (Math.abs(score) >= 100) return score.toFixed(1)
|
| 513 |
+
if (Math.abs(score) >= 10) return score.toFixed(2)
|
| 514 |
+
return score.toFixed(3).replace(/0+$/g, "").replace(/\.$/, "")
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
function handleSort(col: string) {
|
| 518 |
+
if (sortCol === col) setSortAsc(!sortAsc)
|
| 519 |
+
else { setSortCol(col); setSortAsc(false) }
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
const sortIndicator = (col: string) =>
|
| 523 |
+
sortCol === col ? (sortAsc ? " ▲" : " ▼") : ""
|
| 524 |
+
|
| 525 |
+
function toggleCol(metric: string) {
|
| 526 |
+
setHiddenCols((prev) => {
|
| 527 |
+
const next = new Set(prev)
|
| 528 |
+
if (next.has(metric)) next.delete(metric)
|
| 529 |
+
else next.add(metric)
|
| 530 |
+
return next
|
| 531 |
+
})
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
if (subSummaries.length === 0) {
|
| 535 |
+
return (
|
| 536 |
+
<div className="py-12 text-center text-muted-foreground">
|
| 537 |
+
Loading sub-benchmark data for the matrix view...
|
| 538 |
+
</div>
|
| 539 |
+
)
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
return (
|
| 543 |
+
<div className="space-y-4">
|
| 544 |
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
| 545 |
+
<div className="relative w-full max-w-sm">
|
| 546 |
+
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
| 547 |
+
<Input
|
| 548 |
+
value={search}
|
| 549 |
+
onChange={(e) => onSearchChange(e.target.value)}
|
| 550 |
+
placeholder="Search models..."
|
| 551 |
+
className="pl-9"
|
| 552 |
+
/>
|
| 553 |
+
</div>
|
| 554 |
+
<div className="rounded-xl border border-border/70 bg-muted/15 px-4 py-2">
|
| 555 |
+
<div className="flex items-center gap-3">
|
| 556 |
+
<span className="shrink-0 text-sm font-medium text-foreground">Parameters</span>
|
| 557 |
+
|
| 558 |
+
<div className="min-w-0 flex-1 w-[min(92vw,300px)]">
|
| 559 |
+
<div className="relative mb-1 h-4 text-[11px] text-muted-foreground">
|
| 560 |
+
{PARAM_RANGE_MARKERS.map((marker) => (
|
| 561 |
+
<span
|
| 562 |
+
key={marker.label}
|
| 563 |
+
className="absolute top-0 whitespace-nowrap"
|
| 564 |
+
style={{
|
| 565 |
+
left: `${(marker.step / maxStepIndex) * 100}%`,
|
| 566 |
+
transform:
|
| 567 |
+
marker.step === 0 ? "translateX(0)"
|
| 568 |
+
: marker.step === maxStepIndex ? "translateX(-100%)"
|
| 569 |
+
: "translateX(-50%)",
|
| 570 |
+
}}
|
| 571 |
+
>
|
| 572 |
+
{marker.label}
|
| 573 |
+
</span>
|
| 574 |
+
))}
|
| 575 |
+
</div>
|
| 576 |
+
|
| 577 |
+
<div className="relative h-4">
|
| 578 |
+
<div className="absolute inset-x-1.5 top-1/2 h-[3px] -translate-y-1/2 rounded-full bg-border/80" />
|
| 579 |
+
<div className="absolute inset-x-1.5 top-1/2 h-[3px] -translate-y-1/2">
|
| 580 |
+
<div
|
| 581 |
+
className="absolute inset-y-0 rounded-full bg-foreground transition-[left,right] duration-300 ease-[var(--ease-out-quint)]"
|
| 582 |
+
style={{
|
| 583 |
+
left: `${(minParamStep / maxStepIndex) * 100}%`,
|
| 584 |
+
right: `${Math.max(100 - (maxParamStep / maxStepIndex) * 100, 0)}%`,
|
| 585 |
+
}}
|
| 586 |
+
/>
|
| 587 |
+
</div>
|
| 588 |
+
|
| 589 |
+
<div className="absolute inset-x-1.5 top-1/2 -translate-y-1/2">
|
| 590 |
+
{PARAM_RANGE_VALUES.map((_, stepIndex) => (
|
| 591 |
+
<span
|
| 592 |
+
key={`param-tick-${stepIndex}`}
|
| 593 |
+
className="absolute top-0 h-2 w-px -translate-x-1/2 rounded-full bg-border"
|
| 594 |
+
style={{ left: `${(stepIndex / maxStepIndex) * 100}%` }}
|
| 595 |
+
aria-hidden="true"
|
| 596 |
+
/>
|
| 597 |
+
))}
|
| 598 |
+
</div>
|
| 599 |
+
|
| 600 |
+
<input
|
| 601 |
+
type="range"
|
| 602 |
+
min={0}
|
| 603 |
+
max={maxStepIndex}
|
| 604 |
+
step={1}
|
| 605 |
+
value={minParamStep}
|
| 606 |
+
onChange={(e) => {
|
| 607 |
+
const v = Number(e.target.value)
|
| 608 |
+
setMinParamStep(Math.min(v, maxParamStep))
|
| 609 |
+
}}
|
| 610 |
+
className="param-range-input"
|
| 611 |
+
aria-label="Minimum parameter filter"
|
| 612 |
+
/>
|
| 613 |
+
<input
|
| 614 |
+
type="range"
|
| 615 |
+
min={0}
|
| 616 |
+
max={maxStepIndex}
|
| 617 |
+
step={1}
|
| 618 |
+
value={maxParamStep}
|
| 619 |
+
onChange={(e) => {
|
| 620 |
+
const v = Number(e.target.value)
|
| 621 |
+
setMaxParamStep(Math.max(v, minParamStep))
|
| 622 |
+
}}
|
| 623 |
+
className="param-range-input"
|
| 624 |
+
aria-label="Maximum parameter filter"
|
| 625 |
+
/>
|
| 626 |
+
</div>
|
| 627 |
+
</div>
|
| 628 |
+
|
| 629 |
+
<span className="shrink-0 text-[11px] text-muted-foreground">
|
| 630 |
+
{formatParamBoundLabel(minParamStep, "min")} to {formatParamBoundLabel(maxParamStep, "max")}
|
| 631 |
+
</span>
|
| 632 |
+
</div>
|
| 633 |
+
</div>
|
| 634 |
+
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
| 635 |
+
{filteredModels.length} models × {visibleMetrics.length} metrics
|
| 636 |
+
</div>
|
| 637 |
+
</div>
|
| 638 |
+
|
| 639 |
+
{/* Column toggles */}
|
| 640 |
+
<div className="flex flex-wrap gap-1.5">
|
| 641 |
+
{metrics.map((metric) => (
|
| 642 |
+
<button
|
| 643 |
+
key={metric}
|
| 644 |
+
type="button"
|
| 645 |
+
onClick={() => toggleCol(metric)}
|
| 646 |
+
className={`rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
| 647 |
+
hiddenCols.has(metric)
|
| 648 |
+
? "border-border/40 bg-muted/20 text-muted-foreground/50 line-through"
|
| 649 |
+
: "border-border/70 bg-background text-foreground hover:border-primary/50"
|
| 650 |
+
}`}
|
| 651 |
+
>
|
| 652 |
+
{metric}
|
| 653 |
+
</button>
|
| 654 |
+
))}
|
| 655 |
+
</div>
|
| 656 |
+
|
| 657 |
+
<div className="overflow-x-auto rounded-lg border">
|
| 658 |
+
<table className="w-full text-sm" style={{ tableLayout: "fixed" }}>
|
| 659 |
+
<colgroup>
|
| 660 |
+
<col style={{ width: 48 }} />
|
| 661 |
+
<col style={{ width: 220 }} />
|
| 662 |
+
<col style={{ width: 90 }} />
|
| 663 |
+
{visibleMetrics.map((m) => (
|
| 664 |
+
<col key={m} style={{ width: 110 }} />
|
| 665 |
+
))}
|
| 666 |
+
</colgroup>
|
| 667 |
+
<thead>
|
| 668 |
+
<tr className="border-b bg-muted/30">
|
| 669 |
+
<th className="sticky left-0 z-10 bg-muted/30 px-3 py-2 text-left font-semibold">#</th>
|
| 670 |
+
<th
|
| 671 |
+
className="sticky left-[48px] z-10 bg-muted/30 px-3 py-2 text-left font-semibold cursor-pointer select-none hover:text-primary"
|
| 672 |
+
onClick={() => handleSort("name")}
|
| 673 |
+
>
|
| 674 |
+
Model{sortIndicator("name")}
|
| 675 |
+
</th>
|
| 676 |
+
<th
|
| 677 |
+
className="px-3 py-2 text-right font-semibold cursor-pointer select-none hover:text-primary"
|
| 678 |
+
onClick={() => handleSort("avg")}
|
| 679 |
+
>
|
| 680 |
+
Avg{sortIndicator("avg")}
|
| 681 |
+
</th>
|
| 682 |
+
{visibleMetrics.map((metric) => (
|
| 683 |
+
<th
|
| 684 |
+
key={metric}
|
| 685 |
+
className="px-3 py-2 text-right font-semibold cursor-pointer select-none hover:text-primary truncate"
|
| 686 |
+
onClick={() => handleSort(metric)}
|
| 687 |
+
title={metric}
|
| 688 |
+
>
|
| 689 |
+
{metric}{sortIndicator(metric)}
|
| 690 |
+
</th>
|
| 691 |
+
))}
|
| 692 |
+
</tr>
|
| 693 |
+
</thead>
|
| 694 |
+
<tbody>
|
| 695 |
+
{pagedModels.map((model, idx) => (
|
| 696 |
+
<tr key={model.id} className="border-b hover:bg-muted/20 transition-colors">
|
| 697 |
+
<td className="sticky left-0 z-10 bg-background px-3 py-2 text-muted-foreground tabular-nums">
|
| 698 |
+
{idx + 1}
|
| 699 |
+
</td>
|
| 700 |
+
<td className="sticky left-[48px] z-10 bg-background px-3 py-2">
|
| 701 |
+
<div className="font-medium truncate">{model.name}</div>
|
| 702 |
+
<div className="text-xs text-muted-foreground truncate">{model.developer}</div>
|
| 703 |
+
</td>
|
| 704 |
+
<td className="px-3 py-2 text-right font-semibold tabular-nums">
|
| 705 |
+
{formatScore(model.avg)}
|
| 706 |
+
</td>
|
| 707 |
+
{visibleMetrics.map((metric) => {
|
| 708 |
+
const score = model.scores.get(metric)
|
| 709 |
+
const valid = isValidScore(score)
|
| 710 |
+
return (
|
| 711 |
+
<td
|
| 712 |
+
key={metric}
|
| 713 |
+
className={`px-3 py-2 text-right tabular-nums ${valid ? scoreColor(metric, score) : "text-muted-foreground"}`}
|
| 714 |
+
>
|
| 715 |
+
{valid ? formatScore(score) : "—"}
|
| 716 |
+
</td>
|
| 717 |
+
)
|
| 718 |
+
})}
|
| 719 |
+
</tr>
|
| 720 |
+
))}
|
| 721 |
+
</tbody>
|
| 722 |
+
</table>
|
| 723 |
+
</div>
|
| 724 |
+
|
| 725 |
+
{hasMore && (
|
| 726 |
+
<div className="text-center">
|
| 727 |
+
<Button variant="outline" onClick={() => setPage((p) => p + 1)}>
|
| 728 |
+
Load more ({filteredModels.length - pagedModels.length} remaining)
|
| 729 |
+
</Button>
|
| 730 |
+
</div>
|
| 731 |
+
)}
|
| 732 |
+
</div>
|
| 733 |
+
)
|
| 734 |
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { startTransition, useCallback, useEffect, useMemo, useState } from "react"
|
| 4 |
+
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
| 5 |
+
import { Button } from "@/components/ui/button"
|
| 6 |
+
import { ArrowLeft } from "lucide-react"
|
| 7 |
+
import { Navigation } from "@/components/navigation"
|
| 8 |
+
import { BenchmarkDetail } from "@/components/benchmark-detail"
|
| 9 |
+
import type { BenchmarkCard, ModelEvaluationSummary } from "@/lib/eval-processing"
|
| 10 |
+
import {
|
| 11 |
+
fetchBenchmarkMetadata,
|
| 12 |
+
fetchComparisonIndex,
|
| 13 |
+
fetchEvalHierarchy,
|
| 14 |
+
fetchModelSummary,
|
| 15 |
+
fetchModelCards,
|
| 16 |
+
} from "@/lib/dashboard-data-client"
|
| 17 |
+
import type { BenchmarkEvaluationCardData } from "@/components/benchmark-evaluation-card"
|
| 18 |
+
import type { ComparisonIndex, EvalHierarchy } from "@/lib/backend-artifacts"
|
| 19 |
+
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
| 20 |
+
|
| 21 |
+
export default function ModelDetailPage() {
|
| 22 |
+
const params = useParams()
|
| 23 |
+
const router = useRouter()
|
| 24 |
+
const searchParams = useSearchParams()
|
| 25 |
+
const [summary, setSummary] = useState<ModelEvaluationSummary | null>(null)
|
| 26 |
+
const [benchmarkCards, setBenchmarkCards] = useState<Record<string, BenchmarkCard>>({})
|
| 27 |
+
const [modelCards, setModelCards] = useState<BenchmarkEvaluationCardData[]>([])
|
| 28 |
+
const [evalHierarchy, setEvalHierarchy] = useState<EvalHierarchy | null>(null)
|
| 29 |
+
const [comparisonIndex, setComparisonIndex] = useState<ComparisonIndex | null>(null)
|
| 30 |
+
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null)
|
| 31 |
+
const [loading, setLoading] = useState(true)
|
| 32 |
+
const [error, setError] = useState<string | null>(null)
|
| 33 |
+
const routeId = params.id as string
|
| 34 |
+
|
| 35 |
+
const getVariantFromQuery = useCallback(
|
| 36 |
+
(modelSummary: ModelEvaluationSummary) => {
|
| 37 |
+
const requestedVersion = searchParams.get("version")
|
| 38 |
+
|
| 39 |
+
if (!requestedVersion || modelSummary.variants.length === 0) {
|
| 40 |
+
return modelSummary.variants[0] ?? null
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
modelSummary.variants.find(
|
| 45 |
+
(variant) =>
|
| 46 |
+
variant.variant_key === requestedVersion ||
|
| 47 |
+
variant.variant_id === requestedVersion
|
| 48 |
+
) ?? modelSummary.variants[0] ?? null
|
| 49 |
+
)
|
| 50 |
+
},
|
| 51 |
+
[searchParams]
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
const handleBack = useCallback(() => {
|
| 55 |
+
if (typeof window !== "undefined") {
|
| 56 |
+
const referrer = document.referrer
|
| 57 |
+
|
| 58 |
+
if (referrer) {
|
| 59 |
+
try {
|
| 60 |
+
const referrerUrl = new URL(referrer)
|
| 61 |
+
if (referrerUrl.origin === window.location.origin) {
|
| 62 |
+
router.back()
|
| 63 |
+
return
|
| 64 |
+
}
|
| 65 |
+
} catch {
|
| 66 |
+
// Fall through to a deterministic in-app destination.
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
router.push("/models")
|
| 72 |
+
}, [router])
|
| 73 |
+
|
| 74 |
+
const handleVariantChange = useCallback(
|
| 75 |
+
(nextVariantId: string) => {
|
| 76 |
+
setSelectedVariantId(nextVariantId)
|
| 77 |
+
|
| 78 |
+
if (!summary || summary.variants.length <= 1 || !routeId) {
|
| 79 |
+
return
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const nextVariant = summary.variants.find((variant) => variant.variant_id === nextVariantId)
|
| 83 |
+
if (!nextVariant) {
|
| 84 |
+
return
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const nextParams = new URLSearchParams(searchParams.toString())
|
| 88 |
+
const currentVersion = nextParams.get("version")
|
| 89 |
+
const nextVersion = nextVariant.variant_key
|
| 90 |
+
|
| 91 |
+
if (currentVersion === nextVersion) {
|
| 92 |
+
return
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
nextParams.set("version", nextVersion)
|
| 96 |
+
const nextQuery = nextParams.toString()
|
| 97 |
+
router.replace(
|
| 98 |
+
nextQuery ? `/models/${routeId}?${nextQuery}` : `/models/${routeId}`,
|
| 99 |
+
{ scroll: false }
|
| 100 |
+
)
|
| 101 |
+
},
|
| 102 |
+
[routeId, router, searchParams, summary]
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
useEffect(() => {
|
| 106 |
+
let isCancelled = false
|
| 107 |
+
|
| 108 |
+
const loadCoreData = async () => {
|
| 109 |
+
try {
|
| 110 |
+
const [modelSummary, cards, hierarchy] = await Promise.all([
|
| 111 |
+
fetchModelSummary(routeId),
|
| 112 |
+
fetchBenchmarkMetadata(),
|
| 113 |
+
fetchEvalHierarchy().catch((err) => {
|
| 114 |
+
console.warn("Failed to load eval-hierarchy:", err)
|
| 115 |
+
return null as EvalHierarchy | null
|
| 116 |
+
}),
|
| 117 |
+
])
|
| 118 |
+
if (isCancelled) {
|
| 119 |
+
return
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
setSummary(modelSummary)
|
| 123 |
+
setBenchmarkCards(cards)
|
| 124 |
+
setEvalHierarchy(hierarchy)
|
| 125 |
+
setSelectedVariantId((current) => current ?? modelSummary.variants[0]?.variant_id ?? null)
|
| 126 |
+
} catch (err) {
|
| 127 |
+
if (isCancelled) {
|
| 128 |
+
return
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
console.error("Failed to load model:", err)
|
| 132 |
+
setError("Failed to load model data")
|
| 133 |
+
} finally {
|
| 134 |
+
if (!isCancelled) {
|
| 135 |
+
setLoading(false)
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
loadCoreData()
|
| 141 |
+
|
| 142 |
+
return () => {
|
| 143 |
+
isCancelled = true
|
| 144 |
+
}
|
| 145 |
+
}, [routeId])
|
| 146 |
+
|
| 147 |
+
useEffect(() => {
|
| 148 |
+
let isCancelled = false
|
| 149 |
+
|
| 150 |
+
const loadAuxiliaryData = async () => {
|
| 151 |
+
const [allModelCards, compIndex] = await Promise.all([
|
| 152 |
+
fetchModelCards().catch((err) => {
|
| 153 |
+
console.warn("Failed to load model cards:", err)
|
| 154 |
+
return [] as BenchmarkEvaluationCardData[]
|
| 155 |
+
}),
|
| 156 |
+
fetchComparisonIndex().catch((err) => {
|
| 157 |
+
console.warn("Failed to load comparison-index:", err)
|
| 158 |
+
return null as ComparisonIndex | null
|
| 159 |
+
}),
|
| 160 |
+
])
|
| 161 |
+
|
| 162 |
+
if (isCancelled) {
|
| 163 |
+
return
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
startTransition(() => {
|
| 167 |
+
setModelCards(allModelCards)
|
| 168 |
+
setComparisonIndex(compIndex)
|
| 169 |
+
})
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
loadAuxiliaryData()
|
| 173 |
+
|
| 174 |
+
return () => {
|
| 175 |
+
isCancelled = true
|
| 176 |
+
}
|
| 177 |
+
}, [routeId])
|
| 178 |
+
|
| 179 |
+
useEffect(() => {
|
| 180 |
+
if (!summary?.variants.length) {
|
| 181 |
+
return
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
const requestedVariant = getVariantFromQuery(summary)
|
| 185 |
+
if (requestedVariant && requestedVariant.variant_id !== selectedVariantId) {
|
| 186 |
+
setSelectedVariantId(requestedVariant.variant_id)
|
| 187 |
+
}
|
| 188 |
+
}, [getVariantFromQuery, searchParams, summary])
|
| 189 |
+
|
| 190 |
+
useEffect(() => {
|
| 191 |
+
if (!summary?.variants.length || !routeId) {
|
| 192 |
+
return
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
const requestedVersion = searchParams.get("version")
|
| 196 |
+
if (!requestedVersion) {
|
| 197 |
+
return
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
const matchesKnownVariant = summary.variants.some(
|
| 201 |
+
(variant) =>
|
| 202 |
+
variant.variant_key === requestedVersion ||
|
| 203 |
+
variant.variant_id === requestedVersion
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
if (matchesKnownVariant) {
|
| 207 |
+
return
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
const nextParams = new URLSearchParams(searchParams.toString())
|
| 211 |
+
nextParams.delete("version")
|
| 212 |
+
const nextQuery = nextParams.toString()
|
| 213 |
+
|
| 214 |
+
router.replace(
|
| 215 |
+
nextQuery ? `/models/${routeId}?${nextQuery}` : `/models/${routeId}`,
|
| 216 |
+
{ scroll: false }
|
| 217 |
+
)
|
| 218 |
+
}, [routeId, router, searchParams, summary])
|
| 219 |
+
|
| 220 |
+
const selectedVariant = useMemo(() => {
|
| 221 |
+
if (!summary) {
|
| 222 |
+
return null
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
if (!summary.variants.length) {
|
| 226 |
+
return summary
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
return (
|
| 230 |
+
summary.variants.find((variant) => variant.variant_id === selectedVariantId) ??
|
| 231 |
+
summary.variants[0]
|
| 232 |
+
)
|
| 233 |
+
}, [selectedVariantId, summary])
|
| 234 |
+
|
| 235 |
+
useEffect(() => {
|
| 236 |
+
if (!summary) {
|
| 237 |
+
return
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
const titleParts = [summary.model_family_name]
|
| 241 |
+
if (selectedVariant && "variant_key" in selectedVariant && selectedVariant.variant_key !== "base") {
|
| 242 |
+
titleParts.push(selectedVariant.variant_label)
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
document.title = `${titleParts.join(" · ")} - AI Evaluation Dashboard`
|
| 246 |
+
}, [selectedVariant, summary])
|
| 247 |
+
|
| 248 |
+
if (loading) {
|
| 249 |
+
return (
|
| 250 |
+
<div className="min-h-screen bg-background">
|
| 251 |
+
<Navigation />
|
| 252 |
+
<main className="container mx-auto px-4 py-8">
|
| 253 |
+
<div className="flex items-center justify-center h-96">
|
| 254 |
+
<div className="text-lg text-muted-foreground">Loading model details...</div>
|
| 255 |
+
</div>
|
| 256 |
+
</main>
|
| 257 |
+
</div>
|
| 258 |
+
)
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
if (error || !summary) {
|
| 262 |
+
return (
|
| 263 |
+
<div className="min-h-screen bg-background">
|
| 264 |
+
<Navigation />
|
| 265 |
+
<main className="container mx-auto px-4 py-8">
|
| 266 |
+
<div className="flex flex-col items-center justify-center h-96 space-y-4">
|
| 267 |
+
<div className="text-lg text-muted-foreground">{error || "Model not found"}</div>
|
| 268 |
+
<Button onClick={handleBack}>
|
| 269 |
+
<ArrowLeft className="mr-2 h-4 w-4" />
|
| 270 |
+
Back
|
| 271 |
+
</Button>
|
| 272 |
+
</div>
|
| 273 |
+
</main>
|
| 274 |
+
</div>
|
| 275 |
+
)
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
const detailSummary = selectedVariant ?? summary
|
| 279 |
+
const hasVariantTabs = summary.variants.length > 1
|
| 280 |
+
|
| 281 |
+
return (
|
| 282 |
+
<div className="min-h-screen bg-background">
|
| 283 |
+
<Navigation />
|
| 284 |
+
<div className="border-b bg-muted/30">
|
| 285 |
+
<div className="container mx-auto px-4 sm:px-6 py-4 sm:py-6">
|
| 286 |
+
<div className="flex items-center gap-3 sm:hidden">
|
| 287 |
+
<Button
|
| 288 |
+
variant="ghost"
|
| 289 |
+
size="sm"
|
| 290 |
+
onClick={handleBack}
|
| 291 |
+
className="shrink-0"
|
| 292 |
+
>
|
| 293 |
+
<ArrowLeft className="h-4 w-4" />
|
| 294 |
+
</Button>
|
| 295 |
+
<div className="flex-1 text-center">
|
| 296 |
+
<h2 className="text-base font-medium tracking-tight text-foreground/90 sm:text-lg">
|
| 297 |
+
Model details
|
| 298 |
+
</h2>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
|
| 302 |
+
<div className="hidden sm:grid sm:grid-cols-[auto_1fr_auto] sm:items-center sm:gap-4">
|
| 303 |
+
<Button
|
| 304 |
+
variant="ghost"
|
| 305 |
+
onClick={handleBack}
|
| 306 |
+
>
|
| 307 |
+
<ArrowLeft className="mr-2 h-4 w-4" />
|
| 308 |
+
Back
|
| 309 |
+
</Button>
|
| 310 |
+
<div className="text-center">
|
| 311 |
+
<h2 className="text-xl font-medium tracking-tight text-foreground/90 md:text-2xl">
|
| 312 |
+
Model details
|
| 313 |
+
</h2>
|
| 314 |
+
</div>
|
| 315 |
+
<div />
|
| 316 |
+
</div>
|
| 317 |
+
|
| 318 |
+
{hasVariantTabs ? (
|
| 319 |
+
<div className="mt-4 rounded-2xl border border-border/70 bg-background/80 px-3 py-3 shadow-sm">
|
| 320 |
+
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
| 321 |
+
Versions
|
| 322 |
+
</div>
|
| 323 |
+
<Tabs
|
| 324 |
+
value={selectedVariantId ?? summary.variants[0].variant_id}
|
| 325 |
+
onValueChange={handleVariantChange}
|
| 326 |
+
className="gap-0"
|
| 327 |
+
>
|
| 328 |
+
<TabsList className="flex w-full flex-wrap gap-2">
|
| 329 |
+
{summary.variants.map((variant) => (
|
| 330 |
+
<TabsTrigger
|
| 331 |
+
key={variant.variant_id}
|
| 332 |
+
value={variant.variant_id}
|
| 333 |
+
className="h-auto rounded-full border border-border/80 bg-muted/30 px-3 py-1.5 text-xs sm:text-sm data-[state=active]:border-foreground/20 data-[state=active]:bg-background"
|
| 334 |
+
>
|
| 335 |
+
{variant.variant_label}
|
| 336 |
+
</TabsTrigger>
|
| 337 |
+
))}
|
| 338 |
+
</TabsList>
|
| 339 |
+
</Tabs>
|
| 340 |
+
</div>
|
| 341 |
+
) : null}
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
<main className="container mx-auto px-4 py-8">
|
| 345 |
+
<BenchmarkDetail
|
| 346 |
+
summary={detailSummary}
|
| 347 |
+
benchmarkCards={benchmarkCards}
|
| 348 |
+
modelCards={modelCards}
|
| 349 |
+
evalHierarchy={evalHierarchy}
|
| 350 |
+
comparisonIndex={comparisonIndex}
|
| 351 |
+
/>
|
| 352 |
+
</main>
|
| 353 |
+
</div>
|
| 354 |
+
)
|
| 355 |
+
}
|
|
@@ -97,56 +97,56 @@ const EVAL_LIST_COLUMNS = `
|
|
| 97 |
// the binding only ever sees primitive types; parseMaybeJson restores
|
| 98 |
// the shape on the JS side.
|
| 99 |
const CELL_JOIN_COLUMNS = `
|
| 100 |
-
r.evaluation_id,
|
| 101 |
-
r.metric_summary_id,
|
| 102 |
-
r.composite_slug,
|
| 103 |
-
r.composite_display_name,
|
| 104 |
-
r.benchmark_id,
|
| 105 |
-
r.family_id,
|
| 106 |
-
r.family_display_name,
|
| 107 |
-
r.is_slice,
|
| 108 |
-
r.parent_benchmark_id,
|
| 109 |
-
r.metric_id,
|
| 110 |
-
r.model_key,
|
| 111 |
-
r.model_id,
|
| 112 |
-
r.model_route_id,
|
| 113 |
CAST(to_json(r.model_info) AS VARCHAR) AS model_info,
|
| 114 |
CAST(to_json(r.generation_config) AS VARCHAR) AS generation_config,
|
| 115 |
-
r.metric_display_name,
|
| 116 |
-
r.metric_unit,
|
| 117 |
-
r.lower_is_better,
|
| 118 |
-
r.category,
|
| 119 |
-
r.score,
|
| 120 |
CAST(to_json(r.score_details) AS VARCHAR) AS score_details,
|
| 121 |
-
r.position,
|
| 122 |
CAST(r.evaluation_timestamp AS VARCHAR) AS evaluation_timestamp,
|
| 123 |
CAST(to_json(r.source_metadata) AS VARCHAR) AS source_metadata,
|
| 124 |
CAST(to_json(r.source_data) AS VARCHAR) AS source_data,
|
| 125 |
-
r.source_record_url,
|
| 126 |
-
r.is_summary_score,
|
| 127 |
CAST(to_json(r.aggregate_components) AS VARCHAR) AS aggregate_components,
|
| 128 |
CAST(to_json(r.evalcards_annotations) AS VARCHAR) AS evalcards_annotations,
|
| 129 |
CAST(to_json(r.eval_library) AS VARCHAR) AS eval_library,
|
| 130 |
-
r.instance_file_path,
|
| 131 |
-
e.evaluation_name AS eval_evaluation_name,
|
| 132 |
-
e.canonical_display_name AS eval_canonical_display_name,
|
| 133 |
-
e.benchmark_id AS eval_benchmark_id,
|
| 134 |
-
e.composite_slug AS eval_composite_slug,
|
| 135 |
-
e.composite_display_name AS eval_composite_display_name,
|
| 136 |
-
e.family_id AS eval_family_id,
|
| 137 |
-
e.family_display_name AS eval_family_display_name,
|
| 138 |
-
e.is_slice AS eval_is_slice,
|
| 139 |
-
e.parent_benchmark_id AS eval_parent_benchmark_id,
|
| 140 |
-
e.composite_slug AS eval_composite_benchmark_key,
|
| 141 |
-
e.composite_display_name AS eval_composite_benchmark_name,
|
| 142 |
-
e.family_display_name AS eval_benchmark_family_name,
|
| 143 |
-
e.category AS eval_category,
|
| 144 |
CAST(to_json(e.metric_config) AS VARCHAR) AS eval_metric_config,
|
| 145 |
CAST(to_json(e.source_data) AS VARCHAR) AS eval_source_data,
|
| 146 |
CAST(to_json(e.benchmark_card) AS VARCHAR) AS eval_benchmark_card,
|
| 147 |
CAST(to_json(e.tags) AS VARCHAR) AS eval_tags,
|
| 148 |
-
e.is_summary_score AS eval_is_summary_score,
|
| 149 |
-
e.summary_eval_ids AS eval_summary_eval_ids
|
| 150 |
`
|
| 151 |
|
| 152 |
function normalizeDuckDBValue(value: unknown): unknown {
|
|
|
|
| 97 |
// the binding only ever sees primitive types; parseMaybeJson restores
|
| 98 |
// the shape on the JS side.
|
| 99 |
const CELL_JOIN_COLUMNS = `
|
| 100 |
+
CAST(r.evaluation_id AS VARCHAR) AS evaluation_id,
|
| 101 |
+
CAST(r.metric_summary_id AS VARCHAR) AS metric_summary_id,
|
| 102 |
+
CAST(r.composite_slug AS VARCHAR) AS composite_slug,
|
| 103 |
+
CAST(r.composite_display_name AS VARCHAR) AS composite_display_name,
|
| 104 |
+
CAST(r.benchmark_id AS VARCHAR) AS benchmark_id,
|
| 105 |
+
CAST(r.family_id AS VARCHAR) AS family_id,
|
| 106 |
+
CAST(r.family_display_name AS VARCHAR) AS family_display_name,
|
| 107 |
+
CAST(r.is_slice AS BOOLEAN) AS is_slice,
|
| 108 |
+
CAST(r.parent_benchmark_id AS VARCHAR) AS parent_benchmark_id,
|
| 109 |
+
CAST(r.metric_id AS VARCHAR) AS metric_id,
|
| 110 |
+
CAST(r.model_key AS VARCHAR) AS model_key,
|
| 111 |
+
CAST(r.model_id AS VARCHAR) AS model_id,
|
| 112 |
+
CAST(r.model_route_id AS VARCHAR) AS model_route_id,
|
| 113 |
CAST(to_json(r.model_info) AS VARCHAR) AS model_info,
|
| 114 |
CAST(to_json(r.generation_config) AS VARCHAR) AS generation_config,
|
| 115 |
+
CAST(r.metric_display_name AS VARCHAR) AS metric_display_name,
|
| 116 |
+
CAST(r.metric_unit AS VARCHAR) AS metric_unit,
|
| 117 |
+
CAST(r.lower_is_better AS BOOLEAN) AS lower_is_better,
|
| 118 |
+
CAST(r.category AS VARCHAR) AS category,
|
| 119 |
+
CAST(r.score AS DOUBLE) AS score,
|
| 120 |
CAST(to_json(r.score_details) AS VARCHAR) AS score_details,
|
| 121 |
+
CAST(r.position AS INTEGER) AS position,
|
| 122 |
CAST(r.evaluation_timestamp AS VARCHAR) AS evaluation_timestamp,
|
| 123 |
CAST(to_json(r.source_metadata) AS VARCHAR) AS source_metadata,
|
| 124 |
CAST(to_json(r.source_data) AS VARCHAR) AS source_data,
|
| 125 |
+
CAST(r.source_record_url AS VARCHAR) AS source_record_url,
|
| 126 |
+
CAST(r.is_summary_score AS BOOLEAN) AS is_summary_score,
|
| 127 |
CAST(to_json(r.aggregate_components) AS VARCHAR) AS aggregate_components,
|
| 128 |
CAST(to_json(r.evalcards_annotations) AS VARCHAR) AS evalcards_annotations,
|
| 129 |
CAST(to_json(r.eval_library) AS VARCHAR) AS eval_library,
|
| 130 |
+
CAST(r.instance_file_path AS VARCHAR) AS instance_file_path,
|
| 131 |
+
CAST(e.evaluation_name AS VARCHAR) AS eval_evaluation_name,
|
| 132 |
+
CAST(e.canonical_display_name AS VARCHAR) AS eval_canonical_display_name,
|
| 133 |
+
CAST(e.benchmark_id AS VARCHAR) AS eval_benchmark_id,
|
| 134 |
+
CAST(e.composite_slug AS VARCHAR) AS eval_composite_slug,
|
| 135 |
+
CAST(e.composite_display_name AS VARCHAR) AS eval_composite_display_name,
|
| 136 |
+
CAST(e.family_id AS VARCHAR) AS eval_family_id,
|
| 137 |
+
CAST(e.family_display_name AS VARCHAR) AS eval_family_display_name,
|
| 138 |
+
CAST(e.is_slice AS BOOLEAN) AS eval_is_slice,
|
| 139 |
+
CAST(e.parent_benchmark_id AS VARCHAR) AS eval_parent_benchmark_id,
|
| 140 |
+
CAST(e.composite_slug AS VARCHAR) AS eval_composite_benchmark_key,
|
| 141 |
+
CAST(e.composite_display_name AS VARCHAR) AS eval_composite_benchmark_name,
|
| 142 |
+
CAST(e.family_display_name AS VARCHAR) AS eval_benchmark_family_name,
|
| 143 |
+
CAST(e.category AS VARCHAR) AS eval_category,
|
| 144 |
CAST(to_json(e.metric_config) AS VARCHAR) AS eval_metric_config,
|
| 145 |
CAST(to_json(e.source_data) AS VARCHAR) AS eval_source_data,
|
| 146 |
CAST(to_json(e.benchmark_card) AS VARCHAR) AS eval_benchmark_card,
|
| 147 |
CAST(to_json(e.tags) AS VARCHAR) AS eval_tags,
|
| 148 |
+
CAST(e.is_summary_score AS BOOLEAN) AS eval_is_summary_score,
|
| 149 |
+
CAST(to_json(e.summary_eval_ids) AS VARCHAR) AS eval_summary_eval_ids
|
| 150 |
`
|
| 151 |
|
| 152 |
function normalizeDuckDBValue(value: unknown): unknown {
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
The diff for this file is too large to render.
See raw diff
|
|
|