Jenny Chim commited on
Commit
da8db3e
Β·
1 Parent(s): d3cbe09

Deploy DuckDB-backed frontend to

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .dockerignore +1 -0
  2. Dockerfile +1 -0
  3. components/page-loading-state.tsx +6 -0
  4. components/ui/alert.tsx +0 -66
  5. lib/dashboard-data-client.ts +0 -9
  6. lib/data-backend.ts +25 -96
  7. lib/duckdb-data.ts +99 -137
  8. lib/model-family.ts +0 -28
  9. lib/na-utils.ts +5 -0
  10. notes/migration-plan.md +117 -0
  11. notes/testing-strategy.md +353 -0
  12. notes/transformations/01-identity-canonicalization.md +244 -0
  13. notes/transformations/02-setup-alias-merging.md +155 -0
  14. notes/transformations/03-license-normalization.md +147 -0
  15. notes/transformations/04-dataset-url-synthesis.md +173 -0
  16. notes/transformations/05-slug-candidates.md +206 -0
  17. notes/transformations/06-developer-name-canonicalization.md +159 -0
  18. notes/transformations/07-timestamp-normalization.md +165 -0
  19. notes/transformations/08-benchmark-display-names.md +329 -0
  20. notes/transformations/09-metric-display-name-expansion.md +282 -0
  21. notes/transformations/10-params-parsing.md +376 -0
  22. notes/transformations/11-benchmark-card-attachment.md +235 -0
  23. notes/transformations/12-instance-level-data.md +258 -0
  24. notes/transformations/README.md +89 -0
  25. notes/transformations/reshape-design.md +247 -0
  26. notes/transformations/reshape/03-hierarchy-flatten.md +321 -0
  27. notes/transformations/reshape/05-composite-eval-rollup.md +230 -0
  28. notes/transformations/reshape/06-matrix-leaderboard.md +238 -0
  29. notes/transformations/reshape/14-score-summary-stats.md +194 -0
  30. notes/transformations/reshape/16-per-category-counts.md +192 -0
  31. notes/ts-to-pipeline-migration.md +285 -0
  32. scripts/_server_only_stub.cjs +4 -0
  33. scripts/dump-adapter-outputs.mts +408 -0
  34. scripts/server_only_hook.cjs +10 -0
  35. scripts/verify-benchmark-card-attachment.mjs +325 -0
  36. scripts/verify-benchmark-display-names.mjs +255 -0
  37. scripts/verify-dataset-url.mjs +51 -0
  38. scripts/verify-developer-name.mjs +73 -0
  39. scripts/verify-identity.mjs +36 -0
  40. scripts/verify-instance-level-data.mjs +194 -0
  41. scripts/verify-license.mjs +75 -0
  42. scripts/verify-params-parsing.mjs +402 -0
  43. scripts/verify-setup-alias.mjs +99 -0
  44. scripts/verify-slug-candidates.mjs +117 -0
  45. scripts/verify-timestamp.mjs +106 -0
  46. tests/fixtures/manifest.json +1 -1
  47. tests/pipeline-contract.test.ts +41 -0
  48. tests/transformations/benchmark-card-attachment.test.ts +439 -0
  49. tests/transformations/benchmark-display-names.test.ts +269 -0
  50. tests/transformations/dataset-url-synthesis.test.ts +132 -0
.dockerignore CHANGED
@@ -9,3 +9,4 @@ out
9
  .vscode
10
  .idea
11
  *.log
 
 
9
  .vscode
10
  .idea
11
  *.log
12
+ .cache
Dockerfile CHANGED
@@ -40,6 +40,7 @@ COPY --from=builder /app/node_modules ./node_modules
40
  COPY --from=builder /app/.next ./.next
41
  COPY --from=builder /app/public ./public
42
  COPY --from=builder /app/data ./data
 
43
  COPY --from=builder /app/next.config.mjs ./next.config.mjs
44
 
45
  # Expose a common port (informational). Hugging Face Spaces will inject $PORT at runtime
 
40
  COPY --from=builder /app/.next ./.next
41
  COPY --from=builder /app/public ./public
42
  COPY --from=builder /app/data ./data
43
+ COPY --from=builder /app/.cache ./.cache
44
  COPY --from=builder /app/next.config.mjs ./next.config.mjs
45
 
46
  # Expose a common port (informational). Hugging Face Spaces will inject $PORT at runtime
components/page-loading-state.tsx CHANGED
@@ -1,5 +1,11 @@
1
  "use client"
2
 
 
 
 
 
 
 
3
  import { useEffect, useMemo, useState } from "react"
4
 
5
  import { cn } from "@/lib/utils"
 
1
  "use client"
2
 
3
+ // TODO: check if deprecated. No production callers as of 2026-04-28 orphan sweep
4
+ // (zero references in app/, components/, lib/, tests/). Original author added
5
+ // this on 2026-04-15 (commit "Differentiate audience modes and tighten eval
6
+ // navigation"). No clear replacement found β€” could be intended for a future
7
+ // page/route, or could be safe to delete if the intent has changed.
8
+
9
  import { useEffect, useMemo, useState } from "react"
10
 
11
  import { cn } from "@/lib/utils"
components/ui/alert.tsx DELETED
@@ -1,66 +0,0 @@
1
- import * as React from "react"
2
- import { cva, type VariantProps } from "class-variance-authority"
3
-
4
- import { cn } from "@/lib/utils"
5
-
6
- const alertVariants = cva(
7
- "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
8
- {
9
- variants: {
10
- variant: {
11
- default: "bg-card text-card-foreground",
12
- destructive:
13
- "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
14
- },
15
- },
16
- defaultVariants: {
17
- variant: "default",
18
- },
19
- }
20
- )
21
-
22
- function Alert({
23
- className,
24
- variant,
25
- ...props
26
- }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
27
- return (
28
- <div
29
- data-slot="alert"
30
- role="alert"
31
- className={cn(alertVariants({ variant }), className)}
32
- {...props}
33
- />
34
- )
35
- }
36
-
37
- function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
38
- return (
39
- <div
40
- data-slot="alert-title"
41
- className={cn(
42
- "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
43
- className
44
- )}
45
- {...props}
46
- />
47
- )
48
- }
49
-
50
- function AlertDescription({
51
- className,
52
- ...props
53
- }: React.ComponentProps<"div">) {
54
- return (
55
- <div
56
- data-slot="alert-description"
57
- className={cn(
58
- "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
59
- className
60
- )}
61
- {...props}
62
- />
63
- )
64
- }
65
-
66
- export { Alert, AlertTitle, AlertDescription }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/dashboard-data-client.ts CHANGED
@@ -8,11 +8,6 @@ import type {
8
  ModelEvaluationSummary,
9
  } from "@/lib/eval-processing"
10
 
11
- export interface DashboardDataResponse {
12
- models: BenchmarkEvaluationCardData[]
13
- evals: BenchmarkEvalListItem[]
14
- }
15
-
16
  export interface EvalListResponse {
17
  evals: BenchmarkEvalListItem[]
18
  totalModels: number
@@ -53,10 +48,6 @@ async function fetchJson<T>(input: string): Promise<T> {
53
  return response.json() as Promise<T>
54
  }
55
 
56
- export function fetchDashboardData() {
57
- return fetchJson<DashboardDataResponse>("/api/data")
58
- }
59
-
60
  export function fetchModelCards() {
61
  return fetchJson<BenchmarkEvaluationCardData[]>("/api/model-cards-lite")
62
  }
 
8
  ModelEvaluationSummary,
9
  } from "@/lib/eval-processing"
10
 
 
 
 
 
 
11
  export interface EvalListResponse {
12
  evals: BenchmarkEvalListItem[]
13
  totalModels: number
 
48
  return response.json() as Promise<T>
49
  }
50
 
 
 
 
 
51
  export function fetchModelCards() {
52
  return fetchJson<BenchmarkEvaluationCardData[]>("/api/model-cards-lite")
53
  }
lib/data-backend.ts CHANGED
@@ -1,98 +1,27 @@
1
  import "server-only"
2
 
3
- import * as jsonData from "@/lib/model-data"
4
-
5
- function isDuckDBBackend() {
6
- return process.env.DATA_BACKEND?.trim().toLowerCase() === "duckdb"
7
- }
8
-
9
- async function duckdbData() {
10
- return import("@/lib/duckdb-data")
11
- }
12
-
13
- export async function getDashboardData() {
14
- if (isDuckDBBackend()) {
15
- return (await duckdbData()).getDashboardDataFromDuckDB()
16
- }
17
-
18
- return jsonData.getDashboardData()
19
- }
20
-
21
- export async function getModelCards() {
22
- if (isDuckDBBackend()) {
23
- return (await duckdbData()).getModelCardsFromDuckDB()
24
- }
25
-
26
- return jsonData.getModelCards()
27
- }
28
-
29
- export async function getModelCardsLite() {
30
- if (isDuckDBBackend()) {
31
- return (await duckdbData()).getModelCardsLiteFromDuckDB()
32
- }
33
-
34
- return jsonData.getModelCardsLite()
35
- }
36
-
37
- export async function getEvalListData() {
38
- if (isDuckDBBackend()) {
39
- return (await duckdbData()).getEvalListDataFromDuckDB()
40
- }
41
-
42
- return jsonData.getEvalListData()
43
- }
44
-
45
- export async function getEvalListLiteData() {
46
- if (isDuckDBBackend()) {
47
- return (await duckdbData()).getEvalListLiteDataFromDuckDB()
48
- }
49
-
50
- return jsonData.getEvalListLiteData()
51
- }
52
-
53
- export async function getEvalList() {
54
- if (isDuckDBBackend()) {
55
- return (await duckdbData()).getEvalListFromDuckDB()
56
- }
57
-
58
- return jsonData.getEvalList()
59
- }
60
-
61
- export async function getDeveloperList() {
62
- if (isDuckDBBackend()) {
63
- return (await duckdbData()).getDeveloperListFromDuckDB()
64
- }
65
-
66
- return jsonData.getDeveloperList()
67
- }
68
-
69
- export async function getDeveloperSummaryById(routeId: string) {
70
- if (isDuckDBBackend()) {
71
- return (await duckdbData()).getDeveloperSummaryByIdFromDuckDB(routeId)
72
- }
73
-
74
- return jsonData.getDeveloperSummaryById(routeId)
75
- }
76
-
77
- export async function getModelSummaryById(modelId: string) {
78
- if (isDuckDBBackend()) {
79
- return (await duckdbData()).getModelSummaryByIdFromDuckDB(modelId)
80
- }
81
-
82
- return jsonData.getModelSummaryById(modelId)
83
- }
84
-
85
- export async function getEvalSummaryById(evalId: string) {
86
- if (isDuckDBBackend()) {
87
- return (await duckdbData()).getEvalSummaryByIdFromDuckDB(evalId)
88
- }
89
-
90
- return jsonData.getEvalSummaryById(evalId)
91
- }
92
-
93
- // Metadata-style artifacts are intentionally still read through the existing
94
- // JSON/HF path during the local DuckDB experiment. They are not request-time
95
- // processing hotspots, and keeping them unchanged avoids broadening rollout.
96
- export const getBackendManifestData = jsonData.getBackendManifestData
97
- export const getBackendManifestStatusData = jsonData.getBackendManifestStatusData
98
- export const getEvalHierarchyData = jsonData.getEvalHierarchyData
 
1
  import "server-only"
2
 
3
+ import {
4
+ fetchBackendManifest,
5
+ fetchBackendManifestStatus,
6
+ fetchEvalHierarchy,
7
+ } from "@/lib/hf-data"
8
+
9
+ export {
10
+ getDashboardDataFromDuckDB as getDashboardData,
11
+ getModelCardsFromDuckDB as getModelCards,
12
+ getModelCardsLiteFromDuckDB as getModelCardsLite,
13
+ getEvalListDataFromDuckDB as getEvalListData,
14
+ getEvalListLiteDataFromDuckDB as getEvalListLiteData,
15
+ getEvalListFromDuckDB as getEvalList,
16
+ getDeveloperListFromDuckDB as getDeveloperList,
17
+ getDeveloperSummaryByIdFromDuckDB as getDeveloperSummaryById,
18
+ getModelSummaryByIdFromDuckDB as getModelSummaryById,
19
+ getEvalSummaryByIdFromDuckDB as getEvalSummaryById,
20
+ } from "@/lib/duckdb-data"
21
+
22
+ // Metadata-style artifacts are still read through the existing JSON/HF path.
23
+ // They are not request-time processing hotspots and the DuckDB shadow doesn't
24
+ // re-shape them, so calling lib/hf-data directly avoids needless indirection.
25
+ export const getBackendManifestData = fetchBackendManifest
26
+ export const getBackendManifestStatusData = fetchBackendManifestStatus
27
+ export const getEvalHierarchyData = fetchEvalHierarchy
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/duckdb-data.ts CHANGED
@@ -7,30 +7,25 @@ import { DuckDBConnection, type DuckDBValue } from "@duckdb/node-api"
7
 
8
  import type { BenchmarkEvalListItem, BenchmarkEvalSummary } from "@/lib/eval-processing"
9
  import type { EvaluationCardData, ModelEvaluationSummary } from "@/lib/benchmark-schema"
10
- import { getBenchmarkCard } from "@/lib/benchmark-metadata"
11
- import type { HFEvalDetail, HFModelCardEntry, HFModelDetail } from "@/lib/hf-data"
12
- import {
13
- attachBenchmarkCardToSummary,
14
- getBenchmarkDisplayName,
15
- getDeveloperBenchmarkStats,
16
- getDeveloperRouteId,
17
- hfDeveloperDetailToSummary,
18
- hfEvalDetailToSummary,
19
- hfEvalEntryToListItem,
20
- hfModelCardToEvaluationCardData,
21
- normalizeDeveloperName,
22
- } from "@/lib/model-data"
23
- import { createModelFamilySummary } from "@/lib/eval-processing"
24
- import { flattenModelEvaluations } from "@/lib/hf-data"
25
-
26
- const PARQUET_DIR = path.join("experimental", "parquet")
27
  const PARQUET_FILES = {
28
  modelCards: "model_cards.parquet",
29
  modelCardsLite: "model_cards_lite.parquet",
30
  evalList: "eval_list.parquet",
31
  evalListLite: "eval_list_lite.parquet",
32
  evalSummaries: "eval_summaries.parquet",
 
 
33
  modelSummaries: "model_summaries.parquet",
 
34
  developerSummaries: "developer_summaries.parquet",
35
  } as const
36
 
@@ -56,7 +51,7 @@ async function getParquetPath(key: ParquetFileKey) {
56
  await fs.access(filePath)
57
  } catch {
58
  throw new Error(
59
- `DuckDB backend expected ${filePath}. Run the backend pipeline with EXPORT_EXPERIMENTAL_PARQUET=1 first.`
60
  )
61
  }
62
 
@@ -71,10 +66,12 @@ async function getConnection() {
71
  return connectionPromise
72
  }
73
 
74
- function parsePayload<T>(row: Record<string, unknown>): T {
75
  const raw = row.payload_json
76
  if (typeof raw !== "string") {
77
- throw new Error("DuckDB payload row did not include a string payload_json field")
 
 
78
  }
79
 
80
  return JSON.parse(raw) as T
@@ -85,7 +82,7 @@ async function readPayloads<T>(key: ParquetFileKey, orderBy?: string): Promise<T
85
  const filePath = await getParquetPath(key)
86
  const sql = `SELECT payload_json FROM read_parquet(?)${orderBy ? ` ${orderBy}` : ""}`
87
  const reader = await connection.runAndReadAll(sql, [filePath])
88
- return reader.getRowObjects().map((row) => parsePayload<T>(row))
89
  }
90
 
91
  async function readPayloadById<T>(
@@ -100,7 +97,7 @@ async function readPayloadById<T>(
100
  [filePath, ...params]
101
  )
102
  const rows = reader.getRowObjects()
103
- return rows.length > 0 ? parsePayload<T>(rows[0]) : null
104
  }
105
 
106
  async function countRows(key: ParquetFileKey) {
@@ -130,95 +127,27 @@ function evalListSort(a: BenchmarkEvalListItem, b: BenchmarkEvalListItem) {
130
  return (a.evaluation_name ?? "").localeCompare(b.evaluation_name ?? "")
131
  }
132
 
133
- async function attachBenchmarkCardsToEvalListItems(items: BenchmarkEvalListItem[]) {
134
- return Promise.all(
135
- items.map(async (item) => {
136
- if (item.benchmark_card) {
137
- return item
138
- }
139
-
140
- const candidates = [
141
- item.evaluation_name,
142
- item.composite_benchmark_key,
143
- item.composite_benchmark_name,
144
- ].filter(Boolean)
145
-
146
- for (const name of candidates) {
147
- const card = await getBenchmarkCard(name)
148
- if (card) {
149
- return { ...item, benchmark_card: card }
150
- }
151
- }
152
-
153
- return item
154
- })
155
- )
156
- }
157
-
158
- function toEvaluationCard(entry: HFModelCardEntry | EvaluationCardData): EvaluationCardData {
159
- if ("evaluations_count" in entry && "benchmarks_count" in entry) {
160
- return entry as EvaluationCardData
161
- }
162
-
163
- return hfModelCardToEvaluationCardData(entry as HFModelCardEntry)
164
- }
165
-
166
- function toEvalListItem(entry: unknown): BenchmarkEvalListItem {
167
- if (entry && typeof entry === "object" && "evaluation_id" in entry && "composite_benchmark_key" in entry) {
168
- return entry as BenchmarkEvalListItem
169
- }
170
-
171
- return hfEvalEntryToListItem(entry as Parameters<typeof hfEvalEntryToListItem>[0])
172
- }
173
-
174
- async function toEvalSummary(payload: unknown): Promise<BenchmarkEvalSummary> {
175
- if (payload && typeof payload === "object" && "model_results" in payload && "evaluation_id" in payload) {
176
- return payload as BenchmarkEvalSummary
177
- }
178
-
179
- return attachBenchmarkCardToSummary(hfEvalDetailToSummary(payload as HFEvalDetail))
180
- }
181
-
182
- function toModelSummary(payload: unknown): ModelEvaluationSummary {
183
- // The pipeline payload carries `evaluations_by_category` already, but with
184
- // lowercase category keys, raw timestamps, and per-eval benchmark_card
185
- // duplicates. The JSON path always re-aggregates via flattenModelEvaluations
186
- // + createModelFamilySummary; mirror that here so parity is exact. Pushing
187
- // the post-adapter shape into the pipeline is migration item #3 (`hierarchy
188
- // β†’ flat BenchmarkEvaluation[] rebuild`).
189
- const evaluations = flattenModelEvaluations(payload as HFModelDetail)
190
- if (evaluations.length === 0) {
191
- throw new Error("DuckDB model summary payload did not contain any model evaluations")
192
- }
193
-
194
- return createModelFamilySummary(evaluations)
195
- }
196
-
197
  export async function getModelCardsFromDuckDB(): Promise<EvaluationCardData[]> {
198
- const entries = await readPayloads<HFModelCardEntry | EvaluationCardData>("modelCards")
199
- return entries.map(toEvaluationCard).sort(modelCardSort)
200
  }
201
 
202
  export async function getModelCardsLiteFromDuckDB(): Promise<EvaluationCardData[]> {
203
- const entries = await readPayloads<HFModelCardEntry | EvaluationCardData>("modelCardsLite")
204
- return entries.map(toEvaluationCard).sort(modelCardLiteSort)
205
  }
206
 
207
  export async function getEvalListDataFromDuckDB(): Promise<{
208
  evals: BenchmarkEvalListItem[]
209
  totalModels: number
210
  }> {
211
- const [entries, totalModels] = await Promise.all([
212
- readPayloads<unknown>("evalList"),
213
  countRows("modelCards"),
214
  ])
215
- const evals = entries
216
- .map(toEvalListItem)
217
- .filter((entry) => !(typeof entry.source_data?.hf_repo === "string" && entry.source_data.hf_repo.startsWith("example://")))
218
- const evalsWithCards = await attachBenchmarkCardsToEvalListItems(evals)
219
 
220
  return {
221
- evals: evalsWithCards.sort(evalListSort),
222
  totalModels,
223
  }
224
  }
@@ -227,16 +156,13 @@ export async function getEvalListLiteDataFromDuckDB(): Promise<{
227
  evals: BenchmarkEvalListItem[]
228
  totalModels: number
229
  }> {
230
- const [entries, totalModels] = await Promise.all([
231
- readPayloads<unknown>("evalListLite"),
232
  countRows("modelCardsLite"),
233
  ])
234
 
235
  return {
236
- evals: entries
237
- .map(toEvalListItem)
238
- .filter((entry) => !(typeof entry.source_data?.hf_repo === "string" && entry.source_data.hf_repo.startsWith("example://")))
239
- .sort(evalListSort),
240
  totalModels,
241
  }
242
  }
@@ -254,62 +180,98 @@ export async function getDashboardDataFromDuckDB() {
254
  return { models, evals }
255
  }
256
 
 
 
 
 
 
 
257
  export async function getEvalSummaryByIdFromDuckDB(evalId: string) {
258
- const payload = await readPayloadById<unknown>(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  "evalSummaries",
260
  "eval_summary_id = ?",
261
  [evalId]
262
  )
263
-
264
- return payload ? toEvalSummary(payload) : null
265
  }
266
 
267
  export async function getModelSummaryByIdFromDuckDB(modelId: string) {
268
- const payload = await readPayloadById<unknown>(
269
  "modelSummaries",
270
  "model_route_id = ? OR model_family_id = ?",
271
  [modelId, modelId]
272
  )
 
273
 
274
- return payload ? toModelSummary(payload) : null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  }
276
 
277
  export async function getDeveloperSummaryByIdFromDuckDB(routeId: string) {
278
- const payload = await readPayloadById<{ developer: string; models: HFModelCardEntry[] }>(
279
  "developerSummaries",
280
  "developer_route_id = ?",
281
  [routeId]
282
  )
283
-
284
- return payload ? hfDeveloperDetailToSummary(payload) : null
 
 
 
 
 
 
285
  }
286
 
287
  export async function getDeveloperListFromDuckDB() {
288
- const summaries = await readPayloads<{ developer: string; models: HFModelCardEntry[] }>("developerSummaries")
289
-
290
- return summaries
291
- .map((detail) => {
292
- const benchmarkCounts = getDeveloperBenchmarkStats(detail.models)
293
- const evaluationCount = detail.models.reduce(
294
- (sum, model) => sum + model.total_evaluations,
295
- 0
296
- )
297
- const popularEvals = Array.from(benchmarkCounts.entries())
298
- .sort((a, b) => b[1] - a[1])
299
- .slice(0, 3)
300
- .map(([benchmark, model_count]) => ({
301
- benchmark: getBenchmarkDisplayName(benchmark),
302
- model_count,
303
- }))
304
-
305
- return {
306
- developer: normalizeDeveloperName(detail.developer),
307
- route_id: getDeveloperRouteId(detail.developer),
308
- model_count: detail.models.length,
309
- benchmark_count: benchmarkCounts.size,
310
- evaluation_count: evaluationCount,
311
- popular_evals: popularEvals,
312
- }
313
- })
314
  .sort((a, b) => a.developer.localeCompare(b.developer))
315
  }
 
7
 
8
  import type { BenchmarkEvalListItem, BenchmarkEvalSummary } from "@/lib/eval-processing"
9
  import type { EvaluationCardData, ModelEvaluationSummary } from "@/lib/benchmark-schema"
10
+
11
+ // Parity parquet emitted by `eval_cards_backend_pipeline/scripts/parity_outputs.py`.
12
+ // Each table (except model_results, which is long-form relational) carries
13
+ // scalar routing columns plus a `payload_json` column whose value is already
14
+ // in the post-TS-adapter shape. This module is a strict pass-through: if a
15
+ // payload is missing fields the consumer needs, we throw with the file +
16
+ // payload key path so the backend gap is visible. We DO NOT re-run TS
17
+ // adapters or fill defaults here β€” that policy belongs upstream.
18
+ const PARQUET_DIR = path.join("duckdb", "v1")
 
 
 
 
 
 
 
 
19
  const PARQUET_FILES = {
20
  modelCards: "model_cards.parquet",
21
  modelCardsLite: "model_cards_lite.parquet",
22
  evalList: "eval_list.parquet",
23
  evalListLite: "eval_list_lite.parquet",
24
  evalSummaries: "eval_summaries.parquet",
25
+ aggregateEvalSummaries: "aggregate_eval_summaries.parquet",
26
+ matrixEvalSummaries: "matrix_eval_summaries.parquet",
27
  modelSummaries: "model_summaries.parquet",
28
+ developers: "developers.parquet",
29
  developerSummaries: "developer_summaries.parquet",
30
  } as const
31
 
 
51
  await fs.access(filePath)
52
  } catch {
53
  throw new Error(
54
+ `DuckDB backend expected ${filePath}. Re-run the backend pipeline (parity parquet is emitted on every run; no env var required).`
55
  )
56
  }
57
 
 
66
  return connectionPromise
67
  }
68
 
69
+ function parsePayload<T>(row: Record<string, unknown>, file: ParquetFileKey): T {
70
  const raw = row.payload_json
71
  if (typeof raw !== "string") {
72
+ throw new Error(
73
+ `[duckdb-data] ${PARQUET_FILES[file]} row had no payload_json string. Backend parity emitter must populate this column.`
74
+ )
75
  }
76
 
77
  return JSON.parse(raw) as T
 
82
  const filePath = await getParquetPath(key)
83
  const sql = `SELECT payload_json FROM read_parquet(?)${orderBy ? ` ${orderBy}` : ""}`
84
  const reader = await connection.runAndReadAll(sql, [filePath])
85
+ return reader.getRowObjects().map((row) => parsePayload<T>(row, key))
86
  }
87
 
88
  async function readPayloadById<T>(
 
97
  [filePath, ...params]
98
  )
99
  const rows = reader.getRowObjects()
100
+ return rows.length > 0 ? parsePayload<T>(rows[0], key) : null
101
  }
102
 
103
  async function countRows(key: ParquetFileKey) {
 
127
  return (a.evaluation_name ?? "").localeCompare(b.evaluation_name ?? "")
128
  }
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  export async function getModelCardsFromDuckDB(): Promise<EvaluationCardData[]> {
131
+ const entries = await readPayloads<EvaluationCardData>("modelCards")
132
+ return entries.sort(modelCardSort)
133
  }
134
 
135
  export async function getModelCardsLiteFromDuckDB(): Promise<EvaluationCardData[]> {
136
+ const entries = await readPayloads<EvaluationCardData>("modelCardsLite")
137
+ return entries.sort(modelCardLiteSort)
138
  }
139
 
140
  export async function getEvalListDataFromDuckDB(): Promise<{
141
  evals: BenchmarkEvalListItem[]
142
  totalModels: number
143
  }> {
144
+ const [evals, totalModels] = await Promise.all([
145
+ readPayloads<BenchmarkEvalListItem>("evalList"),
146
  countRows("modelCards"),
147
  ])
 
 
 
 
148
 
149
  return {
150
+ evals: evals.sort(evalListSort),
151
  totalModels,
152
  }
153
  }
 
156
  evals: BenchmarkEvalListItem[]
157
  totalModels: number
158
  }> {
159
+ const [evals, totalModels] = await Promise.all([
160
+ readPayloads<BenchmarkEvalListItem>("evalListLite"),
161
  countRows("modelCardsLite"),
162
  ])
163
 
164
  return {
165
+ evals: evals.sort(evalListSort),
 
 
 
166
  totalModels,
167
  }
168
  }
 
180
  return { models, evals }
181
  }
182
 
183
+ // Resolve `evalId` across the three eval-summary tables. The pipeline emits
184
+ // direct evals into `eval_summaries`, `aggregate__<suite>` rows into
185
+ // `aggregate_eval_summaries`, and `matrix__<suite>` rows into
186
+ // `matrix_eval_summaries`. The TS-side `getEvalSummaryById` (lib/model-data.ts)
187
+ // dispatches by id prefix; the DuckDB path mirrors that without re-running
188
+ // any aggregation TS β€” the parity payloads already carry the post-TS shape.
189
  export async function getEvalSummaryByIdFromDuckDB(evalId: string) {
190
+ if (evalId.startsWith("aggregate__")) {
191
+ return readPayloadById<BenchmarkEvalSummary>(
192
+ "aggregateEvalSummaries",
193
+ "eval_summary_id = ?",
194
+ [evalId]
195
+ )
196
+ }
197
+
198
+ if (evalId.startsWith("matrix__")) {
199
+ return readPayloadById<BenchmarkEvalSummary>(
200
+ "matrixEvalSummaries",
201
+ "eval_summary_id = ?",
202
+ [evalId]
203
+ )
204
+ }
205
+
206
+ return readPayloadById<BenchmarkEvalSummary>(
207
  "evalSummaries",
208
  "eval_summary_id = ?",
209
  [evalId]
210
  )
 
 
211
  }
212
 
213
  export async function getModelSummaryByIdFromDuckDB(modelId: string) {
214
+ return readPayloadById<ModelEvaluationSummary>(
215
  "modelSummaries",
216
  "model_route_id = ? OR model_family_id = ?",
217
  [modelId, modelId]
218
  )
219
+ }
220
 
221
+ // Shape contract for `developers.parquet` and `developer_summaries.parquet`
222
+ // payloads. The summary table additionally carries a `models[]` array of
223
+ // post-`hfModelCardToEvaluationCardData` rows (matching the JSON-path
224
+ // `hfDeveloperDetailToSummary` output). If the backend has not yet run the
225
+ // adapter, the parity verifier will flag the divergence.
226
+ interface DeveloperListEntry {
227
+ developer: string
228
+ route_id: string
229
+ model_count: number
230
+ benchmark_count: number
231
+ evaluation_count: number
232
+ popular_evals: Array<{ benchmark: string; model_count: number }>
233
+ }
234
+
235
+ interface DeveloperSummaryPayload extends DeveloperListEntry {
236
+ models: EvaluationCardData[]
237
+ }
238
+
239
+ function assertDeveloperListShape(payload: unknown, source: string): asserts payload is DeveloperListEntry {
240
+ if (!payload || typeof payload !== "object") {
241
+ throw new Error(`[duckdb-data] ${source}: payload was not an object.`)
242
+ }
243
+ const required = ["developer", "route_id", "model_count", "benchmark_count", "evaluation_count", "popular_evals"]
244
+ for (const key of required) {
245
+ if (!(key in payload)) {
246
+ throw new Error(
247
+ `[duckdb-data] ${source}: payload missing field \`${key}\`. Backend parity emitter must run hf_developer_detail_to_summary before writing parquet.`
248
+ )
249
+ }
250
+ }
251
  }
252
 
253
  export async function getDeveloperSummaryByIdFromDuckDB(routeId: string) {
254
+ const payload = await readPayloadById<unknown>(
255
  "developerSummaries",
256
  "developer_route_id = ?",
257
  [routeId]
258
  )
259
+ if (!payload) return null
260
+ assertDeveloperListShape(payload, `developer_summaries.parquet (route_id=${routeId})`)
261
+ if (!Array.isArray((payload as DeveloperSummaryPayload).models)) {
262
+ throw new Error(
263
+ `[duckdb-data] developer_summaries.parquet (route_id=${routeId}): payload missing \`models\` array.`
264
+ )
265
+ }
266
+ return payload as DeveloperSummaryPayload
267
  }
268
 
269
  export async function getDeveloperListFromDuckDB() {
270
+ const summaries = await readPayloads<unknown>("developers")
271
+ for (const payload of summaries) {
272
+ assertDeveloperListShape(payload, "developers.parquet")
273
+ }
274
+ return (summaries as DeveloperListEntry[])
275
+ .slice()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  .sort((a, b) => a.developer.localeCompare(b.developer))
277
  }
lib/model-family.ts CHANGED
@@ -189,34 +189,6 @@ export function getCanonicalModelIdentity(modelInfo: ModelInfo): ParsedModelIden
189
  }
190
  }
191
 
192
- export function normalizeModelInfo(modelInfo: ModelInfo): ModelInfo {
193
- const identity = getCanonicalModelIdentity(modelInfo)
194
- const additionalArchitecture =
195
- typeof modelInfo.additional_details?.architecture === "string"
196
- ? modelInfo.additional_details.architecture
197
- : undefined
198
- const rawParamsBillions = modelInfo.additional_details?.params_billions
199
- const parsedParamsBillions =
200
- typeof rawParamsBillions === "number"
201
- ? rawParamsBillions
202
- : typeof rawParamsBillions === "string"
203
- ? Number.parseFloat(rawParamsBillions)
204
- : null
205
-
206
- return {
207
- ...modelInfo,
208
- id: modelInfo.id.trim(),
209
- name: identity.variantDisplayName,
210
- architecture: modelInfo.architecture ?? additionalArchitecture,
211
- parameter_count:
212
- modelInfo.parameter_count ??
213
- (Number.isFinite(parsedParamsBillions ?? NaN) ? `${parsedParamsBillions}B` : undefined),
214
- model_version:
215
- modelInfo.model_version ??
216
- (identity.variantKey === "base" ? undefined : identity.variantLabel),
217
- }
218
- }
219
-
220
  export function getModelFamilyRouteId(model: ModelInfo | string) {
221
  const familyId =
222
  typeof model === "string" ? model.trim() : getCanonicalModelIdentity(model).familyId
 
189
  }
190
  }
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  export function getModelFamilyRouteId(model: ModelInfo | string) {
193
  const familyId =
194
  typeof model === "string" ? model.trim() : getCanonicalModelIdentity(model).familyId
lib/na-utils.ts CHANGED
@@ -2,6 +2,11 @@
2
  // It intentionally only treats the category as NA when the category-level field
3
  // `additionalAspects` explicitly marks it as not applicable. Question-level/source
4
  // markers are ignored for category selectability (they are relevant to question details).
 
 
 
 
 
5
  export function naReasonForCategoryFromEval(
6
  catEval: any,
7
  benchmarkQuestionIds: string[] = [],
 
2
  // It intentionally only treats the category as NA when the category-level field
3
  // `additionalAspects` explicitly marks it as not applicable. Question-level/source
4
  // markers are ignored for category selectability (they are relevant to question details).
5
+ //
6
+ // TODO: check if deprecated. No production callers as of 2026-04-28 orphan sweep
7
+ // (only consumer is tests/na-utils.test.ts). Original author added this with
8
+ // thorough tests on 2025-08-16 β€” possibly intended for an unshipped UI feature,
9
+ // possibly disconnected during a refactor.
10
  export function naReasonForCategoryFromEval(
11
  catEval: any,
12
  benchmarkQuestionIds: string[] = [],
notes/migration-plan.md ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TS→pipeline migration plan
2
+
3
+ Drafted 2026-04-27. Companion to `notes/ts-to-pipeline-migration.md` (the original 20-item catalog) and `notes/testing-strategy.md` (the safety net we're building before doing more deletions).
4
+
5
+ ## What's already shipped
6
+
7
+ See `notes/ts-to-pipeline-migration.md` "Status" section for full detail. Briefly:
8
+
9
+ - **#4 source-metadata synthesis fallback** β€” deleted. Pipeline emits on every row; runtime `assertSourceMetadata` guard added at read sites; 86 183/86 183 production rows now show real first/third-party badges (was the explicit goal).
10
+ - **#11 category β€” partial** β€” extended `PIPELINE_CATEGORY_MAP` with the 3 missing pipeline keys (all β†’ "General"); removed the regex fallback from `mapHFCategories`. Reverted the more-aggressive call-site changes after subagent audit found 1 500+ Safety rows would have been silently General-ified. Left as TS code path until pipeline-side category accuracy improves (84% of evals currently emit `category: "other"`).
11
+
12
+ ## Framing: TS is the current source of truth
13
+
14
+ Every TS transformation in `lib/` exists because the pipeline didn't do it (yet) β€” token canonicalization, variant grouping, source-metadata defaults, category inference, score normalization, etc. The migration is to lift each transformation upstream so the pipeline emits canonical data and every consumer reads instead of re-deriving.
15
+
16
+ Two failure modes to avoid (per the Phase 3 category regression and the 2026-04-28 v/V finding):
17
+
18
+ - **Treating a transformation as if it were a default.** TS rules that *normalize* (always overwrite) must be implemented as normalization in pipeline; rules that *fill in defaults* (only when value missing) must be defaults. Misclassifying causes silent data shifts. Each spec calls out which kind it is.
19
+ - **Deleting TS before verifying pipeline matches across the full corpus.** `pnpm audit-adapters --diff` is the verification gate. No deletion ships until pipeline-side output is byte-identical (or differences are explicitly accepted in writing).
20
+
21
+ ## Data direction: cleaning upstream, reshape in SQL, presentation stays in TS
22
+
23
+ Standing principle for this migration and beyond. Every TS transformation belongs in one of three places:
24
+
25
+ - **Data cleaning / standardization β†’ pipeline** (changes what the data *is*; canonical form every consumer would want). License strings, developer names, identity tokens, timestamp formats, category labels, source-metadata defaults. Pipeline emits canonical values; every consumer reads. The per-item workflow below is built for this class.
26
+ - **Reshape / dedup / aggregate / sort / filter β†’ DuckDB SQL** (derived view over universal data; every consumer would compute the same thing). Variant dedup, "freshest among candidates", per-category counts, hierarchy flattening, composite rollups. Two valid landing spots: materialized into the pipeline's parquet output (read by DuckDB without further computation) or expressed as SQL at query time. Favor materialization when the answer is identical for every consumer; favor query-time SQL when consumers slice differently.
27
+ - **UI / presentation policy β†’ stays in TS** (app-specific curation choices; different consumers would have different opinions). Card layout, color choices, sort orders for category displays, icon assignments, which fields to surface vs hide. These encode product judgments specific to this app β€” pipeline-emitting them would inflict this UI's choices on every other consumer of the dataset. Keep in TS, treat as out of scope for the migration.
28
+
29
+ The test for which category an item belongs in: *would every reasonable consumer want the same answer?* If yes, it's cleaning or reshape (depending on whether it's a value transform or a derived view). If no, it's UI policy and stays.
30
+
31
+ A trap to watch for: an item can *look* like UI policy because the TS code encodes opinion (e.g. a regex map that ranks things), but actually the curation already lives in the pipeline and the TS code is dead/passthrough. **Always trace which code path actually runs in production before classifying β€” grep for the function name across `app/`/`components/`/`scripts/`, then chain through caller graphs.** "TS file exists" doesn't mean "TS is doing the work."
32
+
33
+ The current DuckDB backend is mid-migration scaffolding. The parquet schema (see `pipeline.py write_experimental_parquet_table`) has 11 typed metadata columns (`record_type`, `model_route_id`, `model_family_id`, `eval_summary_id`, `developer_route_id`, `developer`, `category`, `benchmark_family_key`, `models_count`, `total_evaluations`, `last_updated`) plus a `payload_json VARCHAR` column. DuckDB queries today use the metadata columns for routing (`WHERE eval_summary_id = ?`) and always select `payload_json` for the substantive data β€” variants, model_results, scores, retrieved_timestamps are all nested inside the blob. SQL can route, but it can't reshape what it can't see.
34
+
35
+ Two open design questions for the reshape class, both legitimate, neither decided:
36
+
37
+ - **How relational does parquet need to go?** Promoting nested fields to columns (e.g. one row per `(eval_summary_id, variant_key, retrieved_timestamp, source_metadata)` for the variant dedup case) lets SQL do the work directly: `SELECT … QUALIFY ROW_NUMBER() OVER (PARTITION BY variant_key ORDER BY retrieved_timestamp DESC) = 1`. But it's a substantial schema change negotiated with the pipeline owner.
38
+ - **Materialize the answer upstream vs. compute at query time?** Materialize when the answer is identical for every consumer (variant dedup, per-category counts). Compute at query time when consumers slice differently (filtered top-N, user-selected category aggregates). The split is per-case judgment.
39
+
40
+ What the principle rules out: keeping reshape work in TS adapters after data cleaning moves upstream. "Spec done, pipeline matches, TS deleted" is only complete if the work that's left is presentation, not computation. When specing each item, classify it: cleaning (per-item workflow) or reshape (queue for the parquet-schema / SQL design conversation).
41
+
42
+ ## Per-item workflow
43
+
44
+ For each remaining item, the work is the same shape:
45
+
46
+ 1. **Spec the transformation** β€” write `notes/transformations/NN-<name>.md` per the template in `notes/transformations/README.md`. Capture every rule branch. Two required classifications: (a) default-vs-normalization (see Framing section above), (b) cleaning-vs-reshape (see Data direction section above). If it's reshape, capture the *operation* and flag for the SQL design conversation rather than writing a line-by-line TS translation. Document detected divergences against pipeline.
47
+ 2. **Test the transformation** β€” write `tests/transformations/<name>.test.ts` with parameterized tests sourced from the spec table. These double as the executable acceptance criterion for pipeline.
48
+ 3. **Hand to pipeline** β€” file the spec + tests with the pipeline owner (an issue/PR in `eval_cards_backend_pipeline`). Reference the unit tests as the contract.
49
+ 4. **Verify cross-corpus match** β€” once pipeline ships, run the verification script (`scripts/verify-identity.mjs` for #1, similar scripts per item) against the full live cache. Zero divergences before proceeding.
50
+ 5. **Pre-process in this repo (optional, if needed)** β€” only when the gap between TS and pipeline is intolerable to wait through. Not the default. A one-shot Python pre-processor at data ingestion time is acceptable; on-the-fly TS computation is what we're trying to leave behind.
51
+ 6. **Delete TS** β€” remove the implementation; update callers to read pipeline fields directly. This is its own task in the tasklist (e.g. #5b for #1), gated on step 4.
52
+
53
+ ## The 18 remaining items
54
+
55
+ | Item | Transformation (TS location) | Status | Spec |
56
+ |---|---|---|---|
57
+ | #1 | Identity canonicalization (`lib/model-family.ts`) | spec written 2026-04-28; awaiting pipeline | [01-identity-canonicalization.md](transformations/01-identity-canonicalization.md) |
58
+ | #2 | Setup-alias merging (`lib/eval-processing.ts:371-434`) | not yet specced | β€” |
59
+ | #3 | Hierarchy flatten + family summary (`lib/hf-data.ts flattenModelEvaluations`, `lib/eval-processing.ts createModelFamilySummary`) | not yet specced; structural decision pending (consumer rewrite vs pipeline-emit-flat-list) | β€” |
60
+ | #5 | Composite eval rollup (`lib/model-data.ts:874-1041 aggregateBenchmarkSummaries`, ~170 lines) | not yet specced | β€” |
61
+ | #6 | Matrix leaderboard synthesis (`lib/model-data.ts:1043-1214`, ~170 lines) | not yet specced | β€” |
62
+ | #7 | Per-instance JSONL normalization (`lib/hf-data.ts:919-1029`, ~110 lines of heuristics) | not yet specced; biggest brittleness | β€” |
63
+ | #8 | Benchmark display names (`BENCHMARK_NAMES` in `lib/model-data.ts:92`, `SUITE_DISPLAY_NAMES`, etc.) | not yet specced | β€” |
64
+ | #9 | Developer name canonicalization (`KNOWN_DEVELOPER_NAMES` in `lib/model-data.ts:201-228`) | not yet specced | β€” |
65
+ | #10 | Metric display-name expansion (`GENERIC_EVALUATION_NAMES` + `prefersBenchmarkName` heuristic) | not yet specced | β€” |
66
+ | #11 | Category inference (`inferCategoryFromBenchmark` regex in `lib/benchmark-schema.ts:182-206`) | partially handled; pipeline category is too noisy ("other" 84% of evals) β€” TS regex is the more accurate spec until pipeline improves | β€” |
67
+ | #12 | Params parsing (`parseParamsBillions` in `lib/model-data.ts:296-338` + dups) | not yet specced | β€” |
68
+ | #13 | Timestamp normalization (`toComparableTimestamp` + ~5 dups) | not yet specced; small | β€” |
69
+ | #14 | Score summary stats (`groupEvaluationsByBenchmark` finalisation) | not yet specced | β€” |
70
+ | #16 | Per-category benchmark counts (`hfModelCardToEvaluationCardData` proportional split) | not yet specced; today TS uses `Math.floor(total / categories.length)` as a fake distribution | β€” |
71
+ | #17 | Benchmark-card attachment (`attachBenchmarkCardToSummary` + 3-candidate retry) | not yet specced | β€” |
72
+ | #18 | License canonicalization (`LICENSE_COLORS`/`shortenLicense` in `components/eval-card.tsx:22-48`) | not yet specced | β€” |
73
+ | #19 | Slug candidate generation (`getModelDetailSlugCandidates`/`getDeveloperSlugCandidates`) | not yet specced; the 6-spelling retry in `getModelSummaryById` is the symptom | β€” |
74
+ | #20 | Dataset URL synthesis (`components/eval-card.tsx:81-89`) | not yet specced; tiny | β€” |
75
+
76
+ The pipeline lives at `/Users/jchim/projects/eval_cards_backend_pipeline`. See its `AGENTS.md` for Python conventions, run instructions, and the `EXPORT_EXPERIMENTAL_PARQUET=1` flag. Pipeline changes are full-rebuild β€” `output/` is wiped and rewritten each run.
77
+
78
+ ## Recommended order
79
+
80
+ 1. **Testing harness** β€” done 2026-04-27 (Tier A/B/C + fixtures + audit script).
81
+ 2. **#1 identity canonicalization** β€” spec written 2026-04-28, awaiting pipeline implementation.
82
+ 3. **#2 setup-alias merging** β€” same shape as #1; spec next.
83
+ 4. **Small wins #16, #18, #19, #20** β€” each is a contained transformation. Spec, hand off, batch them on the pipeline side.
84
+ 5. **#3 hierarchy flatten** β€” structural decision needed first (consumer-rewrite vs pipeline-emit-flat-list). Bigger work.
85
+ 6. **#5, #6, #14** β€” composites + matrix + summary stats. Bigger pipeline work.
86
+ 7. **#11 category** β€” pipeline must improve `category` accuracy (84% currently emit `other`); until then TS regex is the spec.
87
+ 8. **#7 per-instance JSONL** β€” biggest brittleness, biggest payoff. Defer until others are done.
88
+
89
+ Items #8, #9, #10, #12, #13, #17 fold into the natural sweep around #3 or #14 β€” they share consumers.
90
+
91
+ ## What can be parallelized right now
92
+
93
+ If multiple agents/sessions run in parallel:
94
+
95
+ - **Agent 1 (this repo):** build `tests/fixtures/` + `tests/pipeline-contract.test.ts` (Tier A from testing-strategy.md)
96
+ - **Agent 2 (this repo):** build `scripts/audit-adapters.mjs` (Tier C from testing-strategy.md)
97
+ - **Agent 3 (pipeline repo):** sweep through #16, #18, #19, #20 β€” small Python emissions
98
+
99
+ Tier B (snapshot tests) needs the fixture set first, so it serializes after Agent 1.
100
+
101
+ After tests are in: TS deletions #1 and #2 can each be a parallel agent.
102
+
103
+ ## Cross-repo coordination
104
+
105
+ When pipeline-side work happens, it changes the contract our TS depends on. Sequence:
106
+
107
+ 1. Pipeline emits new field, runs `EXPORT_EXPERIMENTAL_PARQUET=1` locally to materialize.
108
+ 2. Pipeline ships; user re-publishes to HF.
109
+ 3. Our repo: `pnpm cache-hf-data` to refresh local cache.
110
+ 4. Our repo: `pnpm refresh-fixtures` to pin new shape.
111
+ 5. Our repo: `pnpm test` to confirm contracts pass.
112
+ 6. Our repo: do the TS deletion.
113
+ 7. Our repo: `pnpm test` again β€” snapshots will diff if behavior changes.
114
+
115
+ For the local development loop, both repos can be exercised against `eval_cards_backend_pipeline/output/` directly using the `HF_DATA_LOCAL_DIR` + `HF_DATA_OFFLINE` env vars from `notes/ts-to-pipeline-migration.md`. No HF round-trip needed for testing.
116
+
117
+ For the full picture of how upstream changes propagate (drift detection, scenario matrix, cross-repo workflow), see `notes/testing-strategy.md` Β§ "How upstream changes propagate" and Β§ "Workflows".
notes/testing-strategy.md ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Testing strategy for the TS→pipeline migration
2
+
3
+ Drafted 2026-04-27. The motivation is the 2026-04-27 review session: subagent audits caught two regressions the parity harness missed (a 22% category change and a `coding: "Reasoning"` mistake based on a substring fallacy). Both required full-production-cache analysis to surface. Subagent audits are not a sustainable workflow.
4
+
5
+ ## Design principle: separate code drift from upstream drift
6
+
7
+ Upstream data (the published `evaleval/card_backend` HF dataset) is our best guess at a source of truth, but it isn't immutable. Pipeline-side relabeling, registry updates, and schema changes happen. If our regression tests run against live data, every upstream update lights up the test suite and we can't tell "I broke something" from "upstream changed something I happen to consume."
8
+
9
+ The fix: **pin tests to a committed snapshot of upstream data**. Refresh the snapshot deliberately (script + commit), and the snapshot diff + test diff arrive together for review. Live-data drift detection is a separate, opt-in concern.
10
+
11
+ ```
12
+ β”Œβ”€β”€ tests run against ──┐
13
+ live cache ──── β”œβ”€β”€β†’ pinned fixtures ──→ tests
14
+ └── refresh script β”€β”€β”€β”€β”€β”˜ (committed)
15
+ (manual, reviewed)
16
+ ```
17
+
18
+ Live cache drift is checked by an opt-in audit, not by the test suite.
19
+
20
+ ## The three tiers
21
+
22
+ ### Tier A β€” Pipeline contract tests
23
+ **Catches:** "pipeline upstream silently dropped a field we depend on." Three repeated manual checks (`source_metadata`, `category`, hierarchy keys) motivated automating this.
24
+
25
+ **Mechanic:** vitest file that walks every fixture file and asserts presence/shape of fields the TS code depends on. Each contract is a field-level invariant.
26
+
27
+ **File:** `tests/pipeline-contract.test.ts`
28
+
29
+ **Initial contract set** (every one corresponds to a real failure mode):
30
+ - `every model_result has source_metadata` (we deleted the synthesis fallback assuming this)
31
+ - `every model_result.source_metadata has evaluator_relationship in {first_party, third_party, other}`
32
+ - `every eval-detail has category as a non-empty string`
33
+ - `every eval-detail has eval_summary_id, benchmark, benchmark_leaf_name`
34
+ - `every model card has model_family_id matching pipelineSlugify(model_family_id)`
35
+ - `every hierarchy_by_category key is one of the 9 known pipeline categories`
36
+ - `every BenchmarkEvaluation produced by flattenModelEvaluations has source_metadata` (cross-check: contract + adapter together)
37
+ - `every model card has total_evaluations as a number`
38
+ - `every model_result.retrieved_timestamp parses as a valid Date`
39
+
40
+ **Exit criteria:** all contracts pass against pinned fixtures. Each contract should fail loudly with the offending file path + key path when violated.
41
+
42
+ **Acceptance:** runs in `pnpm test`. Takes <2s. Adding a new contract is 5 lines.
43
+
44
+ ### Tier B β€” Adapter snapshot tests
45
+ **Catches:** "I changed TS code and didn't realize it changes the output for some input shape." This is the bulk of regression-detection.
46
+
47
+ **Mechanic:** vitest snapshot tests. Each adapter Γ— each fixture β†’ snapshot. Regenerate via `vitest --update-snapshots` when changes are intentional; review the snapshot diff alongside the code diff.
48
+
49
+ **Files:**
50
+ - `tests/adapters/hf-eval-detail-to-summary.test.ts`
51
+ - `tests/adapters/hf-model-card-to-evaluation-card-data.test.ts`
52
+ - `tests/adapters/flatten-model-evaluations.test.ts`
53
+ - `tests/adapters/hf-developer-detail-to-summary.test.ts`
54
+ - `tests/adapters/hf-eval-entry-to-list-item.test.ts`
55
+ - `tests/adapters/build-benchmark-leaderboard-matrix.test.ts`
56
+ - `tests/adapters/build-single-metric-suite-matrix-summary.test.ts`
57
+ - `tests/adapters/aggregate-benchmark-summaries.test.ts`
58
+
59
+ **Snapshot format:** `tests/__snapshots__/<test>.snap` (vitest default). Commit them.
60
+
61
+ **Acceptance:** `pnpm test` runs all snapshots, reports any diff, exit non-zero on diff. Adding a new fixture is one line of `test.each`.
62
+
63
+ ### Tier C β€” Full-cache differential audit
64
+ **Catches:** "what is the *full* impact of my code change across all 5 830 production models?" Used for big migration items where snapshot fixtures can't enumerate every shape.
65
+
66
+ **Mechanic:** a Node script that runs all adapters against either pinned fixtures or the live cache, produces a deterministic JSON digest (per-output hash + value distributions + invariant violation counts), and supports diff mode.
67
+
68
+ **File:** `scripts/audit-adapters.mjs`
69
+
70
+ **Output digest shape:**
71
+ ```json
72
+ {
73
+ "version": 1,
74
+ "source": ".cache/hf-data",
75
+ "generated_at": "2026-04-27T22:00:00Z",
76
+ "adapters": {
77
+ "hfModelCardToEvaluationCardData": {
78
+ "outputs_count": 5830,
79
+ "outputs_hash": "sha256:...", // hash of all outputs concatenated
80
+ "field_distributions": {
81
+ "developer": { "OpenAI": 12, "Anthropic": 8, ... },
82
+ "categories.length": { "1": 100, "2": 2000, "3": 3000, ... },
83
+ "evaluator_count": { "0": 200, "1": 1500, ... }
84
+ }
85
+ },
86
+ "flattenModelEvaluations": {
87
+ "outputs_count": 86183,
88
+ "outputs_hash": "sha256:...",
89
+ "invariant_violations": []
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ **Modes:**
96
+ - `node scripts/audit-adapters.mjs --output baseline.json` β†’ write digest
97
+ - `node scripts/audit-adapters.mjs --output candidate.json` β†’ write digest after change
98
+ - `node scripts/audit-adapters.mjs --diff baseline.json candidate.json` β†’ human-readable diff
99
+ - `node scripts/audit-adapters.mjs --against tests/fixtures` β†’ use pinned set instead of live cache
100
+ - `node scripts/audit-adapters.mjs --live --against .cache/hf-data` β†’ drift check against live data
101
+
102
+ **Acceptance:** runs in <30s against full live cache. Diff mode highlights field-distribution shifts, output-hash changes, and new invariant violations with sample paths.
103
+
104
+ ## Fixture management
105
+
106
+ ### Source
107
+
108
+ Fixtures are pinned copies of files in `.cache/hf-data/` at a moment in time. They are committed JSON. Reviewers can see them in PR diffs.
109
+
110
+ ### Layout
111
+
112
+ ```
113
+ tests/fixtures/
114
+ manifest.json ← list of fixture IDs + source-cache snapshot ts
115
+ evals/
116
+ helm_classic_truthfulqa.json
117
+ helm_safety.json
118
+ apex_v1.json ← first-party (Mercor)
119
+ artificial_analysis_*_aime.json ← third-party (AA)
120
+ helm_capabilities.json ← composite
121
+ helm_lite_narrativeqa.json ← subtask
122
+ rewardbench2_chat.json ← coding key in hierarchy
123
+ ...
124
+ models/
125
+ openai__gpt-5.json ← multiple variants
126
+ anthropic__claude-opus-4-5.json ← typical
127
+ google__gemini-3-flash.json ← already in the parity test
128
+ ...
129
+ developers/
130
+ openai.json
131
+ anthropic.json
132
+ ...
133
+ ```
134
+
135
+ ### Curation criteria
136
+
137
+ Every fixture earns its place by exercising a specific code path. Avoid random sampling.
138
+
139
+ Required edge cases:
140
+ - A model with multiple variants (`openai__gpt-5`)
141
+ - A model with subtask hierarchy (helm_lite, helm_classic)
142
+ - A first-party eval (Mercor ACE/APEX)
143
+ - A third-party eval (Artificial Analysis)
144
+ - A composite eval (helm_capabilities)
145
+ - A matrix eval id pattern (synthetic, but the adapter handles it)
146
+ - An eval with `category: "other"` (most of the corpus)
147
+ - An eval that the regex `inferCategoryFromBenchmark` and the pipeline category disagree on (truthfulqa, helm_safety)
148
+ - A model with setup-alias merging (multiple "prompt"/"fc" variants of same release)
149
+ - An ABC-only benchmark (if any are exposed in eval-list)
150
+ - An aggregate eval URL pattern (`aggregate__<suite>`)
151
+
152
+ Aim for ~25-35 fixtures total. Small enough to review, broad enough to catch the patterns we know about.
153
+
154
+ ### Refresh workflow
155
+
156
+ ```bash
157
+ pnpm refresh-fixtures # copies tests/fixtures/manifest.json IDs
158
+ # from .cache/hf-data/ into tests/fixtures/
159
+ # bumps manifest.json snapshot_ts
160
+ git diff tests/fixtures/ # review what upstream changed
161
+ pnpm test # snapshot tests will probably diff
162
+ pnpm test -- -u # update snapshots if intentional
163
+ git diff tests/__snapshots__/ # review what adapter outputs changed
164
+ git add ... # commit fixtures + snapshots together
165
+ ```
166
+
167
+ The diff in `tests/fixtures/` shows raw upstream changes. The diff in `tests/__snapshots__/` shows what changes when you feed the new data through the adapters. Both belong in the same commit.
168
+
169
+ ### Refresh cadence
170
+
171
+ Manual, on demand. Recommended triggers:
172
+ - Before starting a new migration item (to work against current upstream)
173
+ - After observing a discrepancy between live cache and pinned fixtures
174
+ - Periodically (~monthly) to keep fixtures from drifting
175
+
176
+ There is no auto-refresh. The whole point is that upstream changes are reviewed.
177
+
178
+ ### Live-data drift detection
179
+
180
+ Separate from regression tests. A vitest file `tests/upstream-drift.test.ts` runs Tier-A contracts against the LIVE cache and reports violations. Run it manually (`pnpm test:drift`); not part of `pnpm test`. If contracts fail there but pass on fixtures, upstream has drifted and someone should refresh fixtures + investigate.
181
+
182
+ ## How upstream changes propagate
183
+
184
+ Three independent data layers, each updated by a different command:
185
+
186
+ ```
187
+ huggingface.co/datasets/evaleval/card_backend ← truth (changes when pipeline publishes)
188
+ β”‚ pnpm cache-hf-data ← user-triggered download
189
+ β–Ό
190
+ .cache/hf-data/ ← live local cache (mutable)
191
+ β”‚ pnpm refresh-fixtures ← user-triggered re-pin
192
+ β–Ό
193
+ tests/fixtures/ ← committed pinned snapshots
194
+ β”‚ pnpm test (adapter outputs)
195
+ β–Ό
196
+ tests/__snapshots__/ ← committed expected outputs
197
+ ```
198
+
199
+ Default `pnpm test` only sees the pinned bottom two layers, so upstream churn never flaps the regression suite by accident. Each upstream change is observed *deliberately* by re-pinning and reviewing the diff.
200
+
201
+ ### Scenario matrix β€” what each layer reports
202
+
203
+ | What changed upstream | `pnpm test` | `pnpm test:drift` (live cache contracts) | `pnpm refresh-fixtures && pnpm test` (snapshot diff) | `pnpm audit-adapters --diff baseline.json candidate.json` |
204
+ |---|---|---|---|---|
205
+ | Pure data refresh, no shape change | βœ… | βœ… | ❌ snapshots diff (timestamps, scores) | hash flips for affected adapters |
206
+ | Additive (new field that no adapter consumes) | βœ… | βœ… | βœ… (raw fixture diff visible, snapshots stable) | distributions stable |
207
+ | New enum value (e.g. `evaluator_relationship: "fourth_party"`) | βœ… | ❌ unknown-value contract | βœ… unless consumed | distribution gains a key |
208
+ | Drops a required field (e.g. `source_metadata`) | βœ… | ❌ contract violation with N/M count | ❌ contracts now fail on pinned data too | `throws` count rises |
209
+ | Reclassifies an existing value (e.g. `category: "other"` β†’ `"safety"`) | βœ… | βœ… (still a known string) | ❌ snapshots diff for that fixture | hash flips |
210
+ | Renames a field | βœ… | varies | ❌ snapshot diff + likely contract failure | hash + throws change |
211
+ | Rewrites the schema (breaking) | βœ… | ❌ multiple contracts | ❌ contracts + snapshots both fail | many hash flips |
212
+
213
+ The "βœ…" in `pnpm test` for every row is intentional: by design, default tests only fail when *our code* drifts from a pinned baseline. Upstream drift is reported by the opt-in `pnpm test:drift` and by the snapshot diff that lands the moment fixtures are re-pinned.
214
+
215
+ ### Drift-triage decision tree
216
+
217
+ A `pnpm test:drift` failure means live cache no longer satisfies a contract our deletions assumed. Three possibilities:
218
+
219
+ 1. **Pipeline regressed (e.g. dropped `source_metadata` on some rows)** β€” coordinate with the pipeline owner to restore. Don't refresh fixtures yet; the regression would propagate into our pinned set. The runtime `assertSourceMetadata` guards (lib/hf-data.ts, lib/model-data.ts) would also start firing in production, providing a second signal.
220
+ 2. **Pipeline emitted a new value our enum doesn't recognise (e.g. new `evaluator_relationship`)** β€” extend the corresponding `KNOWN_*` set in `tests/upstream-drift.test.ts` and `tests/pipeline-contract.test.ts` AND any consumer code that branches on the old set.
221
+ 3. **Pipeline made a schema-level change** β€” review the upstream commit log (`git -C ../eval_cards_backend_pipeline log`) for context, decide if our consumer needs updates, then refresh fixtures.
222
+
223
+ A snapshot diff after `pnpm refresh-fixtures` always means *some* output changed. Read the fixture diff and snapshot diff side-by-side:
224
+
225
+ - Fixture diff explains *what* upstream changed (raw data shift)
226
+ - Snapshot diff explains *how* the adapter projected that change into user-visible output
227
+ - Together β†’ review and decide if the new output is correct (`pnpm test -- -u`) or a regression to fix
228
+
229
+ ### Known gaps in drift coverage
230
+
231
+ 1. **Stale `.cache/hf-data/`**: `pnpm test:drift` runs against whatever is on disk; it doesn't auto-refresh from huggingface.co. If `pnpm cache-hf-data` hasn't been run recently, "drift" reports stale-cache-vs-fixtures, not upstream-vs-fixtures. Fix: run `pnpm cache-hf-data` before `pnpm test:drift` when you care about true upstream.
232
+ 2. **Hand-edited fixtures aren't detected**: nothing checks that `tests/fixtures/X.json` matches what `pnpm refresh-fixtures` would produce. If someone edits a fixture for debugging and forgets to restore, tests stay green against the mutation. Mitigation would be a content-hash entry per fixture in `manifest.json`; defer until it's actually a problem.
233
+ 3. **Drift covers Tier A invariants only, not Tier B snapshots**: a value-reclassification (Scenario "reclassifies an existing value" above) is invisible to drift. Detection requires `pnpm refresh-fixtures` (snapshot diff) or `pnpm audit-adapters --live --diff` against an older baseline. By design β€” running snapshots against live data would flap on every refresh.
234
+ 4. **`pnpm test:drift` is opt-in, not scheduled**: nobody runs it unless prompted. A CI nightly cron (or `pnpm test:drift` in a weekly task) would catch upstream contract breaks earlier; currently you discover them only when you next run drift.
235
+ 5. **Audit script doesn't check Tier A contracts**: if a row violates a contract, the audit reports it indirectly via increased `throws` count (the runtime guards fire) but you'd need `pnpm test:drift` for the exact contract message and per-row locator.
236
+
237
+ ## Build order
238
+
239
+ Tier A first (smallest, foundational). Tier B next (replaces subagent audits for normal regression detection). Tier C last (heaviest tooling).
240
+
241
+ Each tier is independently usable, so they can be built in parallel by different agents:
242
+
243
+ | Tier | Estimated effort | Depends on | Parallelizable? |
244
+ |---|---|---|---|
245
+ | A β€” contract tests | 1-2h | nothing | yes |
246
+ | B β€” snapshot tests | 2-3h | fixture set (shared) | mostly |
247
+ | C β€” audit script | 2-3h | nothing | yes |
248
+ | Fixture set (~25 files) | 1h | curation decisions | shared dep |
249
+
250
+ Recommended: build the fixture set + Tier A in series (one agent), Tier B and Tier C in parallel after fixtures are in.
251
+
252
+ ## Test-additions deferred to specific migration items
253
+
254
+ The original Tier B plan listed 8 adapters; 4 are built. The remaining 4 (`hfEvalEntryToListItem`, `aggregateBenchmarkSummaries`, `buildSingleMetricSuiteMatrixSummary`, `createModelFamilySummary`) are deferred to the migration items that touch them β€” adding fixtures + snapshots speculatively now would be testing-for-testing's-sake. Specifically:
255
+
256
+ - **`hfEvalEntryToListItem` snapshot** β€” add when starting #1 (identity parsing) or #2 (setup-alias). Needs an `eval_list_entries` fixture group extracted from `.cache/hf-data/eval-list.json`. Cover at least: a typical entry, one with `display_name` starting with "accuracy on " (triggers `prefersBenchmarkName`), one with `display_name` containing "for scorer", one with a missing `display_name`.
257
+ - **Setup-alias collision fixture** β€” add when starting #2. Pick a model with `additional_details.mode` ∈ {"prompt", "fc", "thinking"} appearing across multiple submissions for the same model_id. `openai__gpt-5.2` model card has thinking variants; find a corresponding model detail file.
258
+ - **`aggregate__<suite>` pattern** β€” add when starting #5 (composites) or #6 (matrix synthesis). The aggregate URL pattern is synthetic, not on disk; the test would call `aggregateBenchmarkSummaries` directly with a curated input set. Defer until that adapter is actually being touched.
259
+ - **`createModelFamilySummary` snapshot** β€” add when starting #3. The flatten + family-summary chain is what `getModelSummaryById` returns; snapshotting `createModelFamilySummary(flattenModelEvaluations(model))` locks the full surface before the refactor.
260
+
261
+ ## Reshape-class items: testing addendum (added 2026-04-28)
262
+
263
+ The Tier B snapshot framework above assumes the migration target is "pipeline emits the value, TS reads it." That works for cleaning-class items. For **reshape-class** items (#3 hierarchy flatten, #5 composite rollup, #6 matrix synthesis, #14 score summary stats, #16 per-category counts; plus the reshape halves of #2 and #13), the migration target is different: pipeline emits relational rows, **DuckDB SQL** does the dedup/groupby/aggregate. See `notes/migration-plan.md` Β§ "Data direction" for framing.
264
+
265
+ This shifts what the test set has to verify:
266
+
267
+ - **Tier A contracts gain a parquet schema dimension.** Today's contracts assert JSON field invariants on `.cache/hf-data/**`. When the parquet schema goes more relational (e.g. one row per `(eval_summary_id, variant_key, retrieved_timestamp)` for the variant dedup case), Tier A grows a parallel set of contracts asserting the new typed columns are present and well-typed. File: `tests/parquet-contract.test.ts` (new, parallel to `tests/pipeline-contract.test.ts`).
268
+ - **Tier B snapshots become parity gates, not destinations.** Today, `tests/adapters/flatten-model-evaluations.test.ts` snapshots the TS reshape output. Once SQL replaces the TS, the same snapshot becomes a TS-vs-SQL parity assertion: run both, diff. The snapshot is committed; the SQL output is computed at test time; equality is the gate. Reshape-class snapshots stay green during the migration *exactly because* they assert behavior preservation, not implementation. Don't delete them on TS removal β€” convert them.
269
+ - **Tier C audit script grows a backend dimension.** `scripts/audit-adapters.mjs` currently runs adapters against the live cache. Add `--backend duckdb` so the same adapter contract is exercised against the DuckDB read path, producing a digest that diffs against the JSON-backend digest. This is the full-corpus generalization of `scripts/compare-data-backends.mjs`, but at the adapter-output level rather than the HTTP-endpoint level.
270
+ - **Five of the eight planned Tier B adapters are reshape-class:** `flattenModelEvaluations`, `buildBenchmarkLeaderboardMatrix`, `buildSingleMetricSuiteMatrixSummary`, `aggregateBenchmarkSummaries`, `createModelFamilySummary`. Their snapshots are the contract the SQL replacement must match. Build them when migrating each item β€” the snapshots gate the deletion.
271
+
272
+ What this doesn't change: cleaning-class items (the 12 that aren't reshape) work exactly as the existing framework describes β€” refresh fixtures β†’ snapshot diff β†’ review β†’ ship. No structural test changes needed for cleaning items.
273
+
274
+ ## What this DOESN'T cover
275
+
276
+ - **End-to-end UI tests.** No clicking through pages. Adapter snapshots are a proxy.
277
+ - **Performance regression.** No timing assertions.
278
+ - **Pipeline-side correctness.** Pipeline has its own tests in the sibling repo. Our contracts assert what we *consume*, not what's *correct upstream*.
279
+ - **The DuckDB shadow read.** That's covered by the existing `scripts/compare-data-backends.mjs` parity harness β€” at the HTTP-endpoint level. The adapter-level parity for reshape items (TS reshape output vs SQL reshape output) is the addendum above.
280
+
281
+ ## Workflows
282
+
283
+ ### Migration workflow (TS deletion against current upstream)
284
+
285
+ Use this for items #1, #2, #3 and any pipeline-side change that flows back into deletions in this repo.
286
+
287
+ ```bash
288
+ # 1. Sync to current upstream so the work is against fresh data
289
+ pnpm cache-hf-data
290
+ pnpm test:drift # does upstream still satisfy our contracts?
291
+ # if no β†’ triage per "Drift-triage decision tree" first
292
+
293
+ # 2. Re-pin fixtures to current upstream
294
+ pnpm refresh-fixtures
295
+ pnpm test # any pre-deletion snapshot diffs?
296
+ # if yes β†’ review, then `pnpm test -- -u`, separate commit
297
+ # so the pin-update is isolated from the deletion
298
+
299
+ # 3. Capture a full-cache baseline so we can diff the impact of the change
300
+ pnpm audit-adapters --output /tmp/baseline.json --live
301
+
302
+ # 4. Make the deletion (or refactor)
303
+
304
+ # 5. Verify
305
+ pnpm test # snapshots flag any unexpected output change
306
+ pnpm audit-adapters --output /tmp/candidate.json --live
307
+ pnpm audit-adapters --diff /tmp/baseline.json /tmp/candidate.json # full-cache impact
308
+ pnpm compare-data-backends --json-base http://localhost:3001 --duckdb-base http://localhost:3002
309
+
310
+ # 6. Review snapshot diff alongside code diff
311
+ # - intentional behaviour change: `pnpm test -- -u`, document the why in the commit
312
+ # - unintentional: fix the code
313
+
314
+ # 7. Ship
315
+ ```
316
+
317
+ Each step covers a distinct failure mode; nothing duplicates. Steps 3, 5b, 5c (the audit captures) are skippable for tiny changes β€” start with `pnpm test` alone and escalate if you want fuller coverage.
318
+
319
+ ### Light-touch workflow (small change, no upstream sync needed)
320
+
321
+ ```bash
322
+ pnpm test # baseline green
323
+ # make the change
324
+ pnpm test # snapshots flag any output change
325
+ # review snapshot diff, `pnpm test -- -u` if intentional
326
+ pnpm compare-data-backends ...
327
+ ```
328
+
329
+ ### Drift-only workflow (you suspect upstream changed)
330
+
331
+ ```bash
332
+ pnpm cache-hf-data # ensure local cache is current
333
+ pnpm test:drift # 5 contracts against full live cache
334
+ # if green: upstream still satisfies our deletions' assumptions
335
+ # if red: triage per "Drift-triage decision tree"
336
+ ```
337
+
338
+ ### Cross-repo workflow (pipeline-side change first, TS deletion later)
339
+
340
+ ```bash
341
+ # In ../eval_cards_backend_pipeline
342
+ uv run --with huggingface_hub --no-project python -m scripts.pipeline --dry-run \
343
+ -e EXPORT_EXPERIMENTAL_PARQUET=1
344
+ # verify output/ has the new field
345
+
346
+ # Back in this repo
347
+ pnpm cache-hf-data # picks up the new published artifact
348
+ pnpm test:drift # do we now have a NEW contract we want to assert?
349
+ # if yes: extend tests/pipeline-contract.test.ts + drift
350
+ pnpm refresh-fixtures
351
+ pnpm test # snapshots reflect the new field if any adapter consumes it
352
+ # now eligible to delete the TS code that the pipeline emission obviates
353
+ ```
notes/transformations/01-identity-canonicalization.md ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Identity canonicalization
2
+
3
+ Drafted 2026-04-28. Migration item #1 in `notes/migration-plan.md`.
4
+
5
+ ## Rule
6
+
7
+ Given a `ModelInfo` (`{id, name, developer?}`) emitted by some upstream source, derive a **canonical identity tuple** the rest of the app uses for routing, display, and grouping:
8
+
9
+ ```
10
+ {
11
+ namespace, // "anthropic" β€” owner segment, lowercase
12
+ rawHandle, // "claude-opus-4.5" β€” model segment as received
13
+ normalizedHandle, // "claude-opus-4.5" β€” separators collapsed to "-", lowercased
14
+ familySlug, // "claude-opus-4.5" β€” handle minus version-date suffix
15
+ familyId, // "anthropic/claude-opus-4.5"
16
+ familyName, // "Claude Opus 4.5" β€” title-cased, with v/V rule
17
+ variantKey, // "base" if no date pattern, else "<YYYYMMDD>" or "<YYYYMMDD>-<qualifier>"
18
+ variantLabel, // "Current" if base, else "<YYYY-MM-DD>" or "<YYYY-MM-DD> Β· <Qualifier>"
19
+ variantDisplayName, // familyName if base, else "<familyName> (<variantLabel>)"
20
+ versionDate?, // "YYYY-MM-DD" if a date pattern was detected
21
+ versionQualifier?, // humanized qualifier suffix, if present
22
+ }
23
+ ```
24
+
25
+ ## Classification
26
+
27
+ - **Unconditional normalization** for casing rules (token case map, v/V handling) β€” when the upstream `name` is present, the canonicalizer ignores it for `familyName` and re-derives from the `id`. Pipeline-side fix: pipeline applies these rules once at emission time; no consumer should re-derive.
28
+ - **Default-only** does not apply to this transformation. Every output field is computed unconditionally.
29
+ - **Cleaning β†’ pipeline.** Both outputs (`model_family_id`, `model_family_name`) are value transforms on a single record. No record merging or aggregation. Migration target: pipeline emits canonical values; TS logic deletes.
30
+
31
+ ## Inputs and expected outputs
32
+
33
+ The full table below is the executable spec. Every row corresponds to a parameterized test case in `tests/transformations/identity-canonicalization.test.ts`.
34
+
35
+ ### Group A β€” Token case map
36
+
37
+ The TS implementation maintains a hand-curated `TOKEN_CASE_MAP` for tokens that deviate from naive title-casing. Pipeline must produce identical outputs for every token below β€” no improvements or additions without first updating this spec, the unit tests, and the verification script (in that order). "TS is the spec" β€” see `notes/transformations/README.md`.
38
+
39
+ | Token (lower) | Canonical |
40
+ |---|---|
41
+ | ai | AI |
42
+ | coder | Coder |
43
+ | command | Command |
44
+ | chat | Chat |
45
+ | claude | Claude |
46
+ | gemini | Gemini |
47
+ | gemma | Gemma |
48
+ | gpt | GPT |
49
+ | haiku | Haiku |
50
+ | instruct | Instruct |
51
+ | instant | Instant |
52
+ | llama | Llama |
53
+ | max | Max |
54
+ | mini | Mini |
55
+ | mistral | Mistral |
56
+ | opus | Opus |
57
+ | phi | Phi |
58
+ | plus | Plus |
59
+ | preview | Preview |
60
+ | pro | Pro |
61
+ | qwen | Qwen |
62
+ | reasoning | Reasoning |
63
+ | sonnet | Sonnet |
64
+ | thinking | Thinking |
65
+ | turbo | Turbo |
66
+ | yi | Yi |
67
+
68
+ **Detected divergence (2026-04-28):** pipeline emits "Minicpm3 4B FC", "Xlam 2 1B FC R", "Xlam 2 32B FC R" (3 distinct slugs, 7 cards total) which the TS map does NOT have an entry for ("fc"). TS title-cases to "Fc". Either the TS map needs an `fc β†’ FC` entry, OR pipeline already does the right thing here and TS has a gap. **Decision needed: do we want `fc β†’ FC` added?** If yes, both sides update; spec adds the row.
69
+
70
+ ### Group B β€” v/V version-token rule
71
+
72
+ For any token matching `/^v\d/i` (e.g. `v3`, `v3.1`, `V0`), the `v` is lowercased.
73
+
74
+ | Token in handle | Token in name |
75
+ |---|---|
76
+ | v3 | v3 |
77
+ | V3 | v3 |
78
+ | v0.1 | v0.1 |
79
+ | V1.0 | v1.0 |
80
+
81
+ **Detected divergence (2026-04-28):** pipeline emits 1,253 cards with capital `V` (e.g. "Deepseek V3", "Mistral 7B Instruct V0.3", "Mixtral 8x22b Instruct V0.1", "Nova Lite V1.0"). TS rule lowercases. Pipeline must apply this rule before emitting `model_family_name`.
82
+
83
+ ### Group C β€” Date and qualifier extraction
84
+
85
+ `splitVersionParts` operates on a handle that has already been through `normalizeHandle` (Group D). For a normalized handle matching `/^(.*?)-((?:19|20)\d{6})(?:-(.+))?$/`:
86
+
87
+ | normalizedHandle (input to splitVersionParts) | familySlug | versionDate | versionQualifier | variantKey | variantLabel |
88
+ |---|---|---|---|---|---|
89
+ | claude-3.5-sonnet | claude-3.5-sonnet | _none_ | _none_ | base | Current |
90
+ | claude-3.5-sonnet-20240620 | claude-3.5-sonnet | 2024-06-20 | _none_ | 20240620 | 2024-06-20 |
91
+ | claude-3.5-sonnet-20240620-thinking | claude-3.5-sonnet | 2024-06-20 | Thinking | 20240620-thinking | 2024-06-20 Β· Thinking |
92
+ | claude-3.5-sonnet-20240620-thinking-high | claude-3.5-sonnet | 2024-06-20 | Thinking High | 20240620-thinking-high | 2024-06-20 Β· Thinking High |
93
+
94
+ Note the **dotted** `3.5` not dashed β€” the upstream call to `normalizeHandle` collapses `(\d)-(?=\d(?:-|$))` to `(\d).` (so `3-5-sonnet` β†’ `3.5-sonnet`). Pipeline-side implementations must apply that collapse before invoking `splitVersionParts`-equivalent logic, otherwise the regex match for the date will be off.
95
+
96
+ The qualifier is humanized via the same token-case + v/V rules used for `familyName`.
97
+
98
+ **Important date-pattern caveat:** the date regex requires 8 contiguous digits (`(?:19|20)\d{6}`). A dashed form like `2025-12-11` (which appears in some pipeline IDs like `openai/gpt-5-2025-12-11-thinking-high`) does NOT match β€” those ten-character dashed dates pass through `normalizeHandle` unchanged (no internal-digit-dash-digit pattern triggers the collapse) and `splitVersionParts` returns `base`/`Current`. This is preserved as-is; do not "fix" the regex to accept dashed dates without checking what relies on the current behaviour.
99
+
100
+ ### Group D β€” Handle normalization
101
+
102
+ Raw handles arrive from `getRawHandle()` (Group E) β€” they do NOT contain the namespace. `normalizeHandle` then applies the rules below in order:
103
+
104
+ | rawHandle | normalizedHandle | Rule fired |
105
+ |---|---|---|
106
+ | Claude_Opus_4.5 | claude-opus-4.5 | lowercase + underscore→dash |
107
+ | claude opus 4.5 | claude-opus-4.5 | space→dash |
108
+ | --claude--opus-- | claude-opus | leading/trailing/repeated dash collapse |
109
+ | claude-3-5 | claude-3.5 | digit-dash-digit collapses (5 is at end β†’ matches lookahead) |
110
+ | claude-3-5-sonnet | claude-3.5-sonnet | same: 5 followed by `-` β†’ matches |
111
+ | gpt-5 | gpt-5 | no digit-dash-digit pattern |
112
+ | claude-3-5-sonnet-20240620 | claude-3.5-sonnet-20240620 | "3-5" collapses; "20240620" is one token (no internal dashes) so no collapse there |
113
+ | openai/foo (called via pipeline β†’ never happens) | not applicable | namespace is always split off in Group E before normalize is called |
114
+
115
+ The digit-dash-digit rule is the subtle one: `/(\d)-(?=\d(?:-|$))/g β†’ "$1."` matches a digit-dash-digit pattern only when the right-hand digit is at end-of-string OR followed by another dash. So `3-5-x` becomes `3.5-x`, `3-5` (at end) becomes `3.5`, but `3-5x` (followed by a non-dash) is left alone. Inside `20240620` there are no dashes, so the regex doesn't fire on the date itself.
116
+
117
+ ### Group E β€” Namespace and rawHandle extraction
118
+
119
+ The `id` field's first slash splits namespace from handle. If no slash, `developer` field is used as namespace (slug-cased: spaces β†’ dashes, lowercased).
120
+
121
+ | input.id | input.developer | namespace | rawHandle |
122
+ |---|---|---|---|
123
+ | anthropic/claude-opus-4-5 | (any) | anthropic | claude-opus-4-5 |
124
+ | openai/gpt-5 | (any) | openai | gpt-5 |
125
+ | Claude Opus 4.5 | Anthropic | anthropic | Claude Opus 4.5 (then normalized) |
126
+ | gpt-5 | OpenAI | openai | gpt-5 |
127
+ | (empty) | OpenAI | openai | (empty β†’ falls back to name) |
128
+
129
+ When `id` lacks a slash, `rawHandle` falls back to `stripNamespace(name, namespace)` then to `name.trim()`.
130
+
131
+ ### Group F β€” `familyId` and `model_route_id`
132
+
133
+ ```
134
+ familyId = `${namespace}/${familySlug}`
135
+ model_route_id = familyId.replace(/\//g, "__")
136
+ ```
137
+
138
+ **Pipeline status (2026-04-28):** `model_family_id` matches TS-computed `familyId` for **5,830 / 5,830** cards. `model_route_id` matches `model_family_id.replace(/\//g, "__")` for **5,830 / 5,830** cards. βœ…
139
+
140
+ This is the part that's already safe to delete on the TS side; the rest is not.
141
+
142
+ ## Current TS implementation
143
+
144
+ The transformation lives in TWO places:
145
+
146
+ ### Primary β€” `lib/model-family.ts` (consumed at request time)
147
+
148
+ | Concern | Location |
149
+ |---|---|
150
+ | Top-level entry point | `lib/model-family.ts:165-190` (`getCanonicalModelIdentity`) |
151
+ | Token case map | `lib/model-family.ts:17-44` (`TOKEN_CASE_MAP`) |
152
+ | Token case helper | `lib/model-family.ts:100-123` (`titleCaseToken`) |
153
+ | v/V rule | `lib/model-family.ts:118-120` (inside `titleCaseToken`) |
154
+ | Handle normalization | `lib/model-family.ts:82-90` (`normalizeHandle`) |
155
+ | Date format helper | `lib/model-family.ts:92-98` (`formatVersionDate`) |
156
+ | Date + qualifier extraction | `lib/model-family.ts:139-163` (`splitVersionParts`) |
157
+ | Family name humanization | `lib/model-family.ts:125-137` (`humanizeHandle`) |
158
+ | Namespace extraction | `lib/model-family.ts:62-69` (`getNamespace`) |
159
+ | Raw handle extraction | `lib/model-family.ts:71-80` (`getRawHandle`) |
160
+ | `route_id` derivation | `lib/model-family.ts:220-225` (`getModelFamilyRouteId`) |
161
+
162
+ Total: ~200 lines. Two exported entry points consumed at six call sites in `lib/model-data.ts`, `lib/hf-data.ts`, `components/eval-detail.tsx`. (A third export, `normalizeModelInfo`, was deleted during the 2026-04-28 orphan sweep β€” zero callers.)
163
+
164
+ ### Secondary β€” `scripts/cache-hf-data.mjs` (cache-build-time duplicate)
165
+
166
+ The cache-build script independently re-implements five of the helpers above (presumably to avoid importing TS into the build script). When `pnpm cache-hf-data` runs, it post-processes downloaded model-cards to apply the same canonicalization that the runtime would. Duplicated helpers:
167
+
168
+ | Concern | Cache-script location | Equivalent in `lib/model-family.ts` |
169
+ |---|---|---|
170
+ | Handle normalization | `scripts/cache-hf-data.mjs:141-149` (`normalizeHandle`) | identical to lib version |
171
+ | Date format helper | `scripts/cache-hf-data.mjs:151-157` (`formatVersionDate`) | identical |
172
+ | Token case helper | `scripts/cache-hf-data.mjs:159-177` (`titleCaseToken`) | identical |
173
+ | Family name humanization | `scripts/cache-hf-data.mjs:179-185` (`humanizeHandle`) | identical |
174
+ | Family info extractor (smaller version) | `scripts/cache-hf-data.mjs:187-197` (`getCanonicalFamilyInfo`) | subset of `getCanonicalModelIdentity` (returns only familyId + familyName) |
175
+
176
+ These five functions in `scripts/cache-hf-data.mjs` ALSO need to be deleted in the same migration cleanup, since pipeline-emitted canonical fields obviate both the runtime AND build-time canonicalization paths.
177
+
178
+ ## Pipeline status
179
+
180
+ Verified against full `.cache/hf-data/model-cards.json` (5,830 entries) on 2026-04-28:
181
+
182
+ | Field | Pipeline match | Notes |
183
+ |---|---|---|
184
+ | `model_family_id` | 5830 / 5830 βœ… | Matches `${namespace}/${familySlug}` exactly. |
185
+ | `model_route_id` | 5830 / 5830 βœ… | Matches `model_family_id.replace(/\//g, "__")`. |
186
+ | `model_family_name` | 4,570 / 5,830 ❌ | 1,260 disagreements β€” see "Divergences detected" below. |
187
+ | `family_slug` | not emitted | Pipeline doesn't surface this field. |
188
+ | `version_date` | not emitted | |
189
+ | `version_qualifier` | not emitted | |
190
+ | `variant_key` | partial | Emitted on per-variant entries inside `model_card.variants[]`; not on top-level card. Match status not yet audited. |
191
+ | `variant_label` | partial | Same as `variant_key`. |
192
+ | `variant_display_name` | not emitted | |
193
+
194
+ ## Divergences detected
195
+
196
+ Sourced from `scripts/verify-identity.mjs` against the full live cache (run 2026-04-28).
197
+
198
+ ### Bucket 1: v/V capitalization (1,253 cards)
199
+
200
+ Pipeline does not lowercase the `v` in version tokens. Examples:
201
+
202
+ | Pipeline `model_family_name` | TS-computed `familyName` |
203
+ |---|---|
204
+ | Deepseek V3 | Deepseek v3 |
205
+ | Deepseek V3.1 | Deepseek v3.1 |
206
+ | Mistral 7B Instruct V0.3 | Mistral 7B Instruct v0.3 |
207
+ | Mixtral 8x7b Instruct V0.1 | Mixtral 8x7b Instruct v0.1 |
208
+ | Mixtral 8x22b Instruct V0.1 | Mixtral 8x22b Instruct v0.1 |
209
+ | Nova Lite V1.0 | Nova Lite v1.0 |
210
+ | Nova Micro V1.0 | Nova Micro v1.0 |
211
+ | Nova Pro V1.0 | Nova Pro v1.0 |
212
+
213
+ ### Bucket 2: missing TOKEN_CASE_MAP entries (7 cards, 3 distinct slugs)
214
+
215
+ Pipeline emits known acronyms in upper-case that TS doesn't know about. Examples:
216
+
217
+ | Pipeline `model_family_name` | TS-computed `familyName` | Token at issue |
218
+ |---|---|---|
219
+ | Minicpm3 4B FC | Minicpm3 4B Fc | `fc` (function-calling) |
220
+ | Xlam 2 1B FC R | Xlam 2 1B Fc R | `fc` |
221
+ | Xlam 2 32B FC R | Xlam 2 32B Fc R | `fc` |
222
+
223
+ **Open product question:** TS map is missing `fc β†’ FC`. Either it's an oversight in TS (we should add `fc`) or pipeline is right and TS is wrong. Resolve with product owner before pipeline-side change. Likely the right fix is to add `fc` to the canonical map and have pipeline emit it.
224
+
225
+ ### Other (0 cards)
226
+
227
+ `date_format`, `qualifier_humanize`, `other` buckets all came back empty. Date/qualifier handling matches pipeline, which is reassuring.
228
+
229
+ ## Migration checklist
230
+
231
+ - [x] Spec written
232
+ - [x] Tests cover each rule branch (`tests/transformations/identity-canonicalization.test.ts`)
233
+ - [ ] `fc β†’ FC` token map decision (product owner)
234
+ - [ ] Filed with pipeline owner (link to issue/PR in `eval_cards_backend_pipeline`)
235
+ - [ ] Pipeline emits `model_family_name` matching this spec across full corpus
236
+ - [ ] Pipeline emits `family_slug`, `version_date`, `version_qualifier`, `variant_key`, `variant_label`, `variant_display_name` on top-level card (or we accept they live only on per-variant entries)
237
+ - [ ] TS deleted; callers read pipeline fields directly. Includes both `lib/model-family.ts:1-225` (full file) AND the 5 duplicated helpers in `scripts/cache-hf-data.mjs:141-197`.
238
+
239
+ ## Notes for pipeline implementer
240
+
241
+ - The `getCanonicalModelIdentity` function is pure (no I/O, no globals beyond `TOKEN_CASE_MAP`); a Python port should be a direct line-by-line translation.
242
+ - The unit tests in `tests/transformations/identity-canonicalization.test.ts` are the acceptance criteria. A Python equivalent (with the same input/output examples) is the simplest verification path.
243
+ - The audit script `scripts/verify-identity.mjs` already runs TS-vs-pipeline diff across the full cache; once pipeline ships, run it to confirm zero mismatches before deleting TS.
244
+ - The `fc β†’ FC` question above is the only ruleset gap; everything else is "pipeline doesn't apply the rule yet." Resolve `fc` first if possible.
notes/transformations/02-setup-alias-merging.md ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Setup-alias variant merging
2
+
3
+ Drafted 2026-04-28. Migration item #2 in `notes/migration-plan.md`.
4
+
5
+ ## Framing reminder
6
+
7
+ We are **refactoring for UI efficiency**, not fixing data correctness. Original TS behaviour is the canonical spec β€” including its quirks β€” because that's what users see today. Pipeline must reproduce TS output exactly when this transformation moves upstream. If anyone wants to change *what* gets merged (vs the current TS rules), that's a separate product decision deferred until later.
8
+
9
+ ## Rule (as TS implements it today)
10
+
11
+ A model card's `variants[]` list contains entries keyed by `variant_key`. The runtime normalizer (`lib/hf-data.ts:normalizeSingleModelCardEntry`, line 750) walks each variant and may transform `variant_key`/`variant_label` based on whether the variant looks like a setup-alias of an underlying date-based variant.
12
+
13
+ Algorithm (verbatim from current code):
14
+
15
+ 1. If `variant_key === "base"` β†’ rename to `"default"` / `"Default"`.
16
+ 2. If `variant_key === "default"` β†’ keep as-is.
17
+ 3. Otherwise, build a synthetic identity by feeding `${familyId}-${variant_key}` through `getCanonicalModelIdentity` (the same canonicalizer as migration item #1).
18
+ 4. Inspect `syntheticIdentity.versionDate` and `syntheticIdentity.versionQualifier`:
19
+ - If `versionDate` is set AND `isSetupAliasQualifier(versionQualifier)` returns true β†’ rewrite to `versionDate` (ISO `YYYY-MM-DD` format) for both key and label.
20
+ - Otherwise β†’ use `syntheticIdentity.variantKey` and `syntheticIdentity.variantLabel`.
21
+ 5. After all variants are processed, deduplicate by normalized `variant_key`. Duplicates merge: `evaluation_count` summed, `raw_model_ids` unioned + sorted, `last_updated` taken as the latest timestamp.
22
+
23
+ `isSetupAliasQualifier` (`lib/hf-data.ts:712`) returns true when the normalized qualifier (lowercased, separators β†’ `-`) matches:
24
+
25
+ - exactly `prompt`
26
+ - exactly `fc`
27
+ - exactly `function-calling`
28
+ - starts with `thinking` (so `thinking`, `thinking-1k`, `thinking-medium`, `thinking-32k`, etc. all match)
29
+
30
+ The "starts with thinking" prefix-match is intentionally broad and aggregates all thinking-budget variants (`thinking-1k`, `thinking-medium`, `thinking-32k`, etc.) into a single date-only entry β€” a deliberate UI-side aggregation choice that prioritizes cross-model comparison readability over per-condition granularity.
31
+
32
+ ## Classification
33
+
34
+ - **Unconditional normalization.** When inputs match the rule, TS overwrites whatever upstream emitted. Pipeline-side implementation must apply the same overwrite.
35
+ - **Dual class β€” cleaning and reshape:**
36
+ - **Cleaning β†’ pipeline** (key derivation): the alias qualifier rules (`isSetupAliasQualifier`) that determine *which bucket* a raw result lands in are value transforms on a single record. Pipeline-side fix: emit a canonical `setup_alias_key` field per result row.
37
+ - **Reshape β†’ DuckDB SQL** (bucket reduction): collapsing multiple result rows with the same `setup_alias_key` into a single variant entry (taking the latest timestamp, merging evaluation results) is a `GROUP BY setup_alias_key` aggregation. Migration target once the key is emitted upstream: SQL `SELECT … MAX(retrieved_timestamp) … GROUP BY setup_alias_key` rather than TS reduce logic.
38
+
39
+ ## Inputs and expected outputs
40
+
41
+ These are the rules as TS executes them today. Pipeline must produce identical outputs.
42
+
43
+ ### Group A β€” `isSetupAliasQualifier` truth table
44
+
45
+ | Input qualifier | Normalized | Returns |
46
+ |---|---|---|
47
+ | `prompt` | `prompt` | `true` |
48
+ | `Prompt` | `prompt` | `true` (case-insensitive) |
49
+ | `fc` | `fc` | `true` |
50
+ | `function-calling` | `function-calling` | `true` |
51
+ | `function calling` | `function-calling` | `true` (whitespace β†’ dash) |
52
+ | `function_calling` | `function-calling` | `true` (underscore β†’ dash) |
53
+ | `thinking` | `thinking` | `true` |
54
+ | `thinking-1k` | `thinking-1k` | `true` (prefix match) |
55
+ | `thinking-medium` | `thinking-medium` | `true` (prefix match) |
56
+ | `thinking_xhigh` | `thinking-xhigh` | `true` (prefix match after normalization) |
57
+ | `Thinking 1K` | `thinking-1k` | `true` |
58
+ | `high` | `high` | `false` |
59
+ | `medium` | `medium` | `false` |
60
+ | `low` | `low` | `false` |
61
+ | `minimal` | `minimal` | `false` |
62
+ | `8k` | `8k` | `false` |
63
+ | (empty / null / undefined) | `""` | `false` |
64
+
65
+ ### Group B β€” End-to-end variant normalization
66
+
67
+ `getCanonicalModelIdentity`'s date regex requires 8 contiguous digits (`(?:19|20)\d{6}`). Dashed-date variant_keys do NOT match β€” they fall through to the `versionDate = undefined` branch, which sends them to `syntheticIdentity.variantKey === "base"`. **This is part of TS's current behaviour and must be preserved.**
68
+
69
+ | Input variant_key | TS-observed output `variant_key` | TS-observed output `variant_label` | Notes |
70
+ |---|---|---|---|
71
+ | `default` | `default` | (unchanged) | passthrough |
72
+ | `base` | `default` | `Default` | rename |
73
+ | `20251101` | `20251101` | `2025-11-01` | YYYYMMDD date-only β€” preserved as raw token, ISO label |
74
+ | `2025-11-01` | `base` | `Current` | dashed date-only β€” falls through to base because regex doesn't match (NB: by TS quirk, a future product call may decide to align this) |
75
+ | `20240620-thinking` | `2024-06-20` | `2024-06-20` | YYYYMMDD + thinking β†’ merge to ISO date |
76
+ | `20240620-thinking-1k` | `2024-06-20` | `2024-06-20` | YYYYMMDD + thinking-1k β†’ merge (startsWith match) |
77
+ | `20240620-thinking-medium` | `2024-06-20` | `2024-06-20` | merge (startsWith match) β€” all thinking-N variants for this date collapse together |
78
+ | `20240620-fc` | `2024-06-20` | `2024-06-20` | merge |
79
+ | `20240620-prompt` | `2024-06-20` | `2024-06-20` | merge |
80
+ | `20240620-high` | `20240620-high` | `2024-06-20 Β· High` | non-alias qualifier preserved |
81
+ | `2025-12-11-thinking-medium` | `base` | `Current` | dashed date β€” regex doesn't match, falls through |
82
+ | `2025-12-11-thinking-1k` | `base` | `Current` | same β€” dashed date passes through to base |
83
+ | `2025-12-11-fc` | `base` | `Current` | same |
84
+ | `2025-12-11-high` | `base` | `Current` | same |
85
+ | `2025-08-07-low` | `base` | `Current` | same |
86
+ | `gpt-foo-bar` | `base` | `Current` | no date detected anywhere |
87
+
88
+ ### Group C β€” Multi-variant deduplication after normalization
89
+
90
+ When multiple input variants normalize to the same `variant_key`, they merge:
91
+ - `raw_model_ids`: union, deduped, sorted
92
+ - `evaluation_count`: sum
93
+ - `last_updated`: maximum (latest)
94
+
95
+ This is what produces, for example, the user-visible behaviour for `openai/gpt-5.2`: the cache file has 7 distinct variants, but TS normalization collapses 6 of the 7 (everything except `default`) into a single `base` bucket β€” because all 6 use dashed dates and fall through to `base`.
96
+
97
+ ## Current TS implementation
98
+
99
+ | Concern | Location |
100
+ |---|---|
101
+ | Runtime normalizer (active) | `lib/hf-data.ts:750-812` (`normalizeSingleModelCardEntry`) |
102
+ | Setup-alias qualifier check (runtime) | `lib/hf-data.ts:712-720` (`isSetupAliasQualifier`) |
103
+ | Qualifier normalizer (runtime) | `lib/hf-data.ts:708-710` (`normalizeSetupAliasQualifier`) |
104
+ | Cache-time normalizer | `scripts/cache-hf-data.mjs:213-246` (`getNormalizedVariantMeta`) |
105
+ | Setup-alias qualifier check (cache) | `scripts/cache-hf-data.mjs:203-211` |
106
+ | Qualifier normalizer (cache) | `scripts/cache-hf-data.mjs:199-201` |
107
+ | Mode-based path (dead, ignore) | `lib/eval-processing.ts:371-434` |
108
+
109
+ The mode-based path reads `model_info.additional_details.mode` which is empty on every production model_result row (verified 2026-04-28: 0 of 86,183). It runs but never fires for any input. Pipeline-side implementation should NOT reproduce it; it's load-bearing only against test fixtures that may carry the field.
110
+
111
+ ## Pipeline status β€” known divergences
112
+
113
+ Pipeline (`eval_cards_backend_pipeline/scripts/pipeline.py`) implements its own variant aggregation in `aggregated_display_identity` (line 1724). It uses a **different qualifier set and a different input field** than TS:
114
+
115
+ | Aspect | TS (this spec) | Pipeline today | Result |
116
+ |---|---|---|---|
117
+ | Input field | `variant_key` from variant entry | `model_info.additional_details.mode` from raw eval | Pipeline merges submissions early; TS re-merges at variant level |
118
+ | Qualifier set | `prompt`, `fc`, `function-calling`, plus *any* `thinking*` (prefix) | exact set: `{prompt, fc, function-calling, thinking, prompt-thinking, fc-thinking, function-calling-thinking}` | TS aggregates more aggressively for `thinking-N` variants; pipeline keeps each thinking budget separate |
119
+ | Date-format handling | Only YYYYMMDD recognized; dashed dates fall through to `base` | N/A β€” pipeline operates on `mode` field, not `variant_key` | |
120
+
121
+ **Concrete example:** for `openai/gpt-5.2` with submissions across 7 setups (default + base 2025-12-11 + 5 thinking budgets):
122
+ - Pipeline emits 7 distinct variants (it merges fc/prompt into the date-only base via `mode`-field check, but keeps thinking-{none,low,medium,high,xhigh} separate because those exact strings aren't in pipeline's set)
123
+ - TS normalizes pipeline's 7 variants β†’ 2 (`default` + `base`, all dashed-date variants collapsed)
124
+
125
+ **The user-visible state today** is whatever TS produces (TS runs on every API request). So users see the post-TS-normalization view: 2 variants for that card, not 7.
126
+
127
+ When the migration moves this transformation upstream, pipeline must produce TS's 2-variant view, not pipeline's current 7-variant view. Pipeline-side options:
128
+
129
+ 1. Add a second-pass normalization to pipeline output that mirrors TS's rules (prefix-match `thinking*`, only YYYYMMDD dates trigger merge).
130
+ 2. Drop pipeline's existing `aggregated_display_identity` mode-based check and replace it entirely with the variant_key-based rule.
131
+
132
+ Option 2 is cleaner if pipeline owners agree.
133
+
134
+ ## Notes for pipeline implementer
135
+
136
+ - **Reproduce the prefix-match `thinking*` exactly.** This is the largest TS-vs-pipeline divergence. Don't tighten it to an exact set without a product call.
137
+ - **Reproduce the dashed-date fall-through behaviour.** `2025-12-11-thinking-medium` produces `variant_key: base` in TS today. Whether that's "right" or "wrong" is not for this migration to decide.
138
+ - **Ignore the `mode` field.** It's empty in production. Pipeline's current `aggregated_display_identity` reads it; the replacement should not.
139
+ - **Re-run `scripts/verify-setup-alias.mjs`** against pipeline output once the change ships. Goal: zero divergence between TS-as-is output and pipeline-emitted output for the full corpus.
140
+
141
+ ## Migration checklist
142
+
143
+ - [x] Spec written (TS-as-is, including quirks)
144
+ - [x] Tests cover each rule branch (`tests/transformations/setup-alias-merging.test.ts`)
145
+ - [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
146
+ - [ ] Pipeline emits variants matching this spec across the full corpus (verified by `scripts/verify-setup-alias.mjs`)
147
+ - [ ] TS deleted; callers read pipeline-emitted variants directly. Files to delete:
148
+ - `lib/hf-data.ts:750-812` (`normalizeSingleModelCardEntry`)
149
+ - `lib/hf-data.ts:708-720` (`normalizeSetupAliasQualifier`, `isSetupAliasQualifier`)
150
+ - `scripts/cache-hf-data.mjs:199-246` (cache-time mirror)
151
+ - `lib/eval-processing.ts:371-434` (the dead mode-based path)
152
+
153
+ ## Future product decision (deferred)
154
+
155
+ Whether the current TS aggregation is the *right* product behaviour is open. The dashed-date fall-through and the prefix-match `thinking*` together produce a heavily-aggregated view (2 variants for `openai/gpt-5.2` instead of 7). If the team later decides users would benefit from per-thinking-budget granularity, the transformation can be redesigned in pipeline (where it's cheaper to change than in TS at runtime). That's explicitly out of scope for this refactor.
notes/transformations/03-license-normalization.md ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # License normalization
2
+
3
+ Drafted 2026-04-28. Migration item #18 in `notes/migration-plan.md`.
4
+
5
+ ## Framing reminder
6
+
7
+ We are refactoring for UI efficiency, not fixing data. TS-as-is is the canonical spec. If the truncation behaviour or rule coverage looks suboptimal, that's a deferred product decision (see end of doc). Do not "improve" the rule when porting upstream.
8
+
9
+ ## Rule (as TS implements it today)
10
+
11
+ `shortenLicense` (`components/eval-card.tsx:38-48`) takes a free-text license string from `benchmark_card.ethical_and_legal_considerations.data_licensing` and returns a short display label. Algorithm:
12
+
13
+ 1. If empty or `"Not specified"` β†’ return `""`.
14
+ 2. If lowercased license includes `"creative commons attribution 4"` β†’ `"CC BY 4.0"`.
15
+ 3. Else if includes `"creative commons zero"` β†’ `"CC0"`.
16
+ 4. Else if includes `"apache license 2"` OR `"apache 2"` β†’ `"Apache 2.0"`.
17
+ 5. Else if includes `"mit license"` β†’ `"MIT"`.
18
+ 6. Else if includes `"cc-by-sa"` β†’ `"CC BY-SA"`.
19
+ 7. Else if length > 24 β†’ return `${license.slice(0, 22)}…` (truncate to 22 chars + ellipsis).
20
+ 8. Else β†’ return the input unchanged.
21
+
22
+ Rules are applied in this order (first match wins). The truncation target (`length > 24`, slice to 22) is asymmetric on purpose β€” one character of headroom to avoid truncating 24-char strings.
23
+
24
+ The companion function `licenseBadgeClass` in the same file is **purely presentational** (CSS color mapping) and stays in the component. NOT in scope for this migration.
25
+
26
+ ## Classification
27
+
28
+ - **Unconditional normalization.** Always overwrite whatever upstream emitted.
29
+ - The output field on the spec side could be named `license_short` and live alongside `data_licensing` in the benchmark card. The original `data_licensing` (free text) stays available for tooltips/details views.
30
+ - **Cleaning β†’ pipeline.** Pure value transform on a single field. No aggregation or record merging. Migration target: pipeline emits `license_short`; TS `shortenLicense` deletes.
31
+
32
+ ## Inputs and expected outputs
33
+
34
+ Each row corresponds to a parameterized test case in `tests/transformations/license-normalization.test.ts`. Pipeline must produce identical outputs for every case below.
35
+
36
+ ### Group A β€” Rule firing order (which rule wins)
37
+
38
+ | Input | Output | Rule fired |
39
+ |---|---|---|
40
+ | `"Apache License 2.0"` | `"Apache 2.0"` | rule 4 (matches "apache license 2") |
41
+ | `"Apache 2.0"` | `"Apache 2.0"` | rule 4 (matches "apache 2") |
42
+ | `"MIT License"` | `"MIT"` | rule 5 |
43
+ | `"Creative Commons Attribution 4.0"` | `"CC BY 4.0"` | rule 2 |
44
+ | `"Creative Commons Zero v1.0 Universal"` | `"CC0"` | rule 3 |
45
+ | `"cc-by-sa-3.0"` | `"CC BY-SA"` | rule 6 |
46
+ | `"Open Data Commons Attribution License"` | `"Open Data Commons Attr…"` | rule 7 (truncate, length > 24) |
47
+ | `"The dataset is made available under a CC BY license."` | `"The dataset is made av…"` | rule 7 (truncate; the prose form bypasses rule 2 because it doesn't contain "creative commons attribution 4") |
48
+ | `"apache-2.0"` | `"apache-2.0"` | rule 8 (passthrough; SPDX-style hyphen-lowercase doesn't match "apache license 2" or "apache 2") |
49
+ | `"other"` | `"other"` | rule 8 (passthrough, length ≀ 24) |
50
+ | `"unknown"` | `"unknown"` | rule 8 (passthrough, length ≀ 24) |
51
+ | `"Not specified"` | `""` | rule 1 |
52
+ | `""` | `""` | rule 1 |
53
+ | `null` / `undefined` | `""` | rule 1 (falsy short-circuit) |
54
+
55
+ ### Group B β€” Edge cases of the truncation rule
56
+
57
+ | Input | Output | Notes |
58
+ |---|---|---|
59
+ | 24-char string (no other rule matches) | (input unchanged) | length ≀ 24 β†’ passthrough |
60
+ | 25-char string | first-22-chars + `…` | length > 24 β†’ truncate |
61
+ | String beginning `"MIT-like license that is custom"` | `"MIT"` | rule 5 fires before truncation (substring match) |
62
+ | String beginning `"some apache 2 thing"` | `"Apache 2.0"` | rule 4 fires (substring match) |
63
+
64
+ ### Group C β€” Case sensitivity
65
+
66
+ All match-rules call `.toLowerCase()` before substring testing. Inputs:
67
+
68
+ | Input | Output | Notes |
69
+ |---|---|---|
70
+ | `"APACHE LICENSE 2.0"` | `"Apache 2.0"` | case-insensitive match |
71
+ | `"creative commons attribution 4.0"` | `"CC BY 4.0"` | already lowercase |
72
+ | `"CREATIVE COMMONS ZERO v1.0"` | `"CC0"` | uppercase still matches |
73
+ | `"Mit License"` | `"MIT"` | mixed case |
74
+
75
+ ## Current TS implementation
76
+
77
+ The same `shortenLicense` function is duplicated in TWO files:
78
+
79
+ | Concern | Location |
80
+ |---|---|
81
+ | Function (eval-card list render path) | `components/eval-card.tsx:38-48` (`shortenLicense`) |
82
+ | Caller | `components/eval-card.tsx:63` |
83
+ | Function (eval-list page render path β€” duplicate copy) | `app/evals/page.tsx:24-41` (`shortenLicense`) |
84
+ | Caller | `app/evals/page.tsx:1720` |
85
+ | CSS class mapping (NOT in scope, stays in UI) | `components/eval-card.tsx:22-36` (`LICENSE_COLORS`, `licenseBadgeClass`) AND `app/evals/page.tsx:43-?` (duplicate) |
86
+
87
+ The two function bodies are **functionally identical** (same output for every input) but textually slightly different β€” `app/evals/page.tsx` uses template literal `` `${license.slice(0, 22)}…` ``, `components/eval-card.tsx` uses concatenation `license.slice(0, 22) + "…"`. Both run at request time on every render. Pipeline-side emission of `license_short` would eliminate both per-render calls; the deletion task must update BOTH files.
88
+
89
+ ## Pipeline status β€” divergences
90
+
91
+ ### Side-by-side comparison table
92
+
93
+ | Aspect | TS (this spec) | Pipeline today | Result for users |
94
+ |---|---|---|---|
95
+ | Where the transformation lives | `components/eval-card.tsx:shortenLicense` (runs at every render) | not implemented | TS runs at request time; pipeline ships free-text `data_licensing` only |
96
+ | Field consumed | `benchmark_card.ethical_and_legal_considerations.data_licensing` | (same β€” unchanged) | β€” |
97
+ | Output field | local variable `shortLicense` (passed to badge as label text) | none | UI shows TS's output; pipeline doesn't expose a short form anywhere |
98
+ | Rule coverage | 5 explicit aliases + truncate fallback | n/a | n/a |
99
+
100
+ ### Concrete worked example with quantified scope
101
+
102
+ Audited 2026-04-28 against `.cache/hf-data/benchmark-metadata.json` (production cache):
103
+
104
+ - 85 of 85 benchmark cards have a `data_licensing` field
105
+ - 11 distinct license strings appear across the corpus
106
+ - `shortenLicense` produces the following distribution:
107
+ - 33 β†’ `""` (cards with `"Not specified"`)
108
+ - 16 β†’ `"Apache 2.0"`
109
+ - 9 β†’ `"MIT"`
110
+ - 8 β†’ `"Open Data Commons Attr…"` (truncated)
111
+ - 6 β†’ `"CC BY 4.0"`
112
+ - 3 β†’ `"CC BY-SA"`
113
+ - 3 β†’ `"other"` (passthrough)
114
+ - 3 β†’ `"unknown"` (passthrough)
115
+ - 2 β†’ `"CC0"`
116
+ - 1 β†’ `"apache-2.0"` (passthrough β€” SPDX-style misses the `apache 2.0` rule)
117
+ - 1 β†’ `"The dataset is made av…"` (truncated; prose form bypasses CC BY rule)
118
+
119
+ Verified by `scripts/verify-license.mjs`.
120
+
121
+ ## Notes for pipeline implementer
122
+
123
+ - Reproduce all 8 rules in order (first match wins). Do not reorder.
124
+ - The `apache-2.0` SPDX-style-lowercase form intentionally falls through to passthrough β€” the existing rule only matches prose forms (`"apache license 2"` or `"apache 2"` with a space). Don't broaden the rule.
125
+ - The truncation produces `${license.slice(0, 22)}…`. That's 22 chars plus a single Unicode ellipsis (`…`, U+2026). Don't substitute three dots (`...`).
126
+ - Truncation triggers at `length > 24`, not `length > 22`. A 24-char string passes through; a 25-char string truncates. Preserve this asymmetry.
127
+ - The empty + `"Not specified"` β†’ `""` short-circuit uses falsy semantics in JS (handles `null`/`undefined`/`""` together). Pipeline-side equivalent should treat all three input shapes as the same.
128
+ - Field name suggestion: `benchmark_card.ethical_and_legal_considerations.license_short` β€” keeps the existing free-text `data_licensing` available for detail views.
129
+
130
+ Verification: run `scripts/verify-license.mjs` against pipeline-emitted `license_short` once it ships. Goal: zero divergence across the 85 production benchmark cards.
131
+
132
+ ## Migration checklist
133
+
134
+ - [x] Spec written
135
+ - [x] Tests cover each rule branch (`tests/transformations/license-normalization.test.ts`)
136
+ - [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
137
+ - [ ] Pipeline emits `license_short` matching this spec across all benchmark cards
138
+ - [ ] TS deleted; `components/eval-card.tsx:38-48` AND `app/evals/page.tsx:24-41` both read `card?.ethical_and_legal_considerations?.license_short` directly. `licenseBadgeClass` stays in both files (UI presentation).
139
+
140
+ ## Future product decision (deferred)
141
+
142
+ The current rule set has known coverage gaps that produce ugly truncation:
143
+ - Free-form CC BY descriptions (e.g. "The dataset is made available under a CC BY license.") aren't recognized as CC BY β†’ truncate to "The dataset is made av…"
144
+ - "Open Data Commons Attribution License" (ODC-By) isn't recognized β†’ truncate to "Open Data Commons Attr…"
145
+ - SPDX-style lowercase identifiers (`apache-2.0`, `mit`, `cc-by-4.0`) aren't recognized as their canonical short forms
146
+
147
+ If the team later decides users would benefit from broader recognition (e.g. SPDX identifier mapping, looser CC matching), the rule can be expanded in pipeline. That's explicitly out of scope for this refactor.
notes/transformations/04-dataset-url-synthesis.md ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dataset URL synthesis
2
+
3
+ Drafted 2026-04-28. Migration item #20 in `notes/migration-plan.md`.
4
+
5
+ ## Framing reminder
6
+
7
+ We are refactoring for UI efficiency, not fixing data. TS-as-is is the canonical spec. The fallback chain is functionally complete; pipeline just needs to do the resolution once and emit the result.
8
+
9
+ ## Rule (as TS implements it today)
10
+
11
+ `components/eval-card.tsx:83-86` resolves `datasetUrl` from `summary.source_data` via a 3-branch nullish-coalescing chain (NOT truthiness). The literal expression:
12
+
13
+ ```ts
14
+ const datasetUrl =
15
+ sourceData?.dataset_url ??
16
+ (Array.isArray(sourceData?.url) ? sourceData?.url?.[0] : sourceData?.url) ??
17
+ (sourceData?.hf_repo ? `https://huggingface.co/datasets/${sourceData.hf_repo}` : undefined)
18
+ ```
19
+
20
+ Reading order:
21
+
22
+ 1. `source_data.dataset_url` β€” used if not `null`/`undefined`. Empty string `""` is RETURNED (not nullish).
23
+ 2. `source_data.url` β€” if array, take `url[0]` (no truthiness check on the element); if string, use directly. Falls through only if the resolved value is `null`/`undefined`.
24
+ 3. `https://huggingface.co/datasets/${source_data.hf_repo}` β€” only if `hf_repo` is truthy (this branch uses a ternary, not `??`, so `""` falls through to `undefined`).
25
+ 4. Else β†’ `undefined`.
26
+
27
+ The constructed HF URL uses the literal template β€” no encoding, no slash normalization, no validation. Whatever the upstream `hf_repo` value is, it gets concatenated as-is.
28
+
29
+ ## Classification
30
+
31
+ - **Default-only.** The rule fills in a value only when `dataset_url` isn't already present. Pipeline-side fix: emit `dataset_url` directly so the fallback chain becomes unnecessary; preserve any existing `dataset_url` value rather than overwriting.
32
+ - **Cleaning β†’ pipeline.** Derives a URL value from other fields on the same record. No aggregation or record merging. Migration target: pipeline emits `dataset_url`; TS fallback chain deletes.
33
+
34
+ ## Inputs and expected outputs
35
+
36
+ Each row corresponds to a parameterized test case in `tests/transformations/dataset-url-synthesis.test.ts`. Pipeline must produce identical outputs for every case.
37
+
38
+ ### Group A β€” Branch firing order (first non-nullish wins)
39
+
40
+ | Input `source_data` | Output | Branch |
41
+ |---|---|---|
42
+ | `{dataset_url: "https://example.com/x"}` | `"https://example.com/x"` | 1 |
43
+ | `{dataset_url: "x", url: ["y"]}` | `"x"` | 1 (dataset_url short-circuits when set) |
44
+ | `{url: ["https://a.com", "https://b.com"]}` | `"https://a.com"` | 2 (first element of array) |
45
+ | `{url: ["only"]}` | `"only"` | 2 |
46
+ | `{url: "https://a.com"}` | `"https://a.com"` | 2 (string form) |
47
+ | `{hf_repo: "Mercor/ACE"}` | `"https://huggingface.co/datasets/Mercor/ACE"` | 3 (HF template) |
48
+ | `{hf_repo: "mercor/apex-agents"}` | `"https://huggingface.co/datasets/mercor/apex-agents"` | 3 (preserves case) |
49
+ | `{dataset_name: "x"}` | `undefined` | 4 (none of the above match) |
50
+ | `{}` | `undefined` | 4 |
51
+ | `null` | `undefined` | 4 (caller passes nullable; defensive) |
52
+ | `undefined` | `undefined` | 4 |
53
+
54
+ ### Group B β€” Edge cases (`??` nullish semantics, NOT truthiness)
55
+
56
+ The original expression uses `??` (nullish coalescing), so `""`, `0`, and `false` do NOT trigger fallback β€” only `null` and `undefined` do. The hf_repo branch internally uses `sourceData.hf_repo ? template : undefined` (a truthiness check), so empty hf_repo IS falsy.
57
+
58
+ | Input `source_data` | Output | Why |
59
+ |---|---|---|
60
+ | `{dataset_url: "", url: ["fallback"]}` | `""` | `""` is not nullish, `??` short-circuits to it |
61
+ | `{url: [], hf_repo: "x/y"}` | `"https://huggingface.co/datasets/x/y"` | `url[0]` is `undefined`, `??` falls through to hf_repo |
62
+ | `{url: [""], hf_repo: "x/y"}` | `""` | `url[0]` is `""` (not nullish), `??` short-circuits to it |
63
+ | `{url: [null], hf_repo: "x/y"}` | `"https://huggingface.co/datasets/x/y"` | `url[0]` is `null` (nullish), `??` falls through to hf_repo |
64
+ | `{url: ["a"], hf_repo: "x/y"}` | `"a"` | url[0] truthy, short-circuits hf_repo |
65
+ | `{hf_repo: ""}` | `undefined` | inline `hf_repo ? template : undefined` uses truthiness β€” `""` falls through to `undefined` |
66
+ | `{hf_repo: "/leading-slash"}` | `"https://huggingface.co/datasets//leading-slash"` | no normalization β€” double slash preserved |
67
+
68
+ ## Current TS implementation
69
+
70
+ The fallback chain exists in TWO sites, with **slightly different semantics** between them. A third related pattern (just the bare HF link) exists in a third site.
71
+
72
+ ### Site A β€” eval-card list (`??` nullish, default `undefined`)
73
+
74
+ `components/eval-card.tsx:83-86`:
75
+
76
+ ```ts
77
+ const datasetUrl =
78
+ sourceData?.dataset_url ??
79
+ (Array.isArray(sourceData?.url) ? sourceData?.url?.[0] : sourceData?.url) ??
80
+ (sourceData?.hf_repo ? `https://huggingface.co/datasets/${sourceData.hf_repo}` : undefined)
81
+ ```
82
+
83
+ This is the spec described above (Group A/B in the table). Empty string `""` is RETURNED (not nullish); only `null`/`undefined` fall through.
84
+
85
+ ### Site B β€” benchmark detail page (`||` truthy, default `null`)
86
+
87
+ `components/benchmark-detail.tsx:5043-5047`:
88
+
89
+ ```ts
90
+ const datasetHref =
91
+ sourceData?.dataset_url ||
92
+ (Array.isArray(sourceData?.url) ? sourceData?.url?.[0] : sourceData?.url) ||
93
+ (sourceData?.hf_repo ? `https://huggingface.co/datasets/${sourceData.hf_repo}` : null) ||
94
+ null
95
+ ```
96
+
97
+ **Differs from Site A on edge cases:** uses `||` (truthiness) so empty strings DO fall through; uses `null` instead of `undefined` as the final default. For `{dataset_url: ""}`, Site A returns `""` and Site B returns the next non-empty branch's value (or `null`).
98
+
99
+ ### Site C β€” direct HF link (no fallback chain)
100
+
101
+ `components/benchmark-detail.tsx:5416-5420`:
102
+
103
+ ```ts
104
+ {sourceData?.hf_repo && (
105
+ <InlineMeta label="HF Repo" value={
106
+ <a href={`https://huggingface.co/datasets/${sourceData.hf_repo}`} ...>
107
+ {sourceData.hf_repo}
108
+ </a>
109
+ } />
110
+ )}
111
+ ```
112
+
113
+ This is just the hf_repo template applied directly when `hf_repo` is truthy. It does NOT consult `dataset_url` or `url` β€” so even if `dataset_url` is set, this site shows the HF link separately. Different intent: this is the "HF Repo" inline-meta link, not the primary dataset link.
114
+
115
+ ### Summary table
116
+
117
+ | Site | Path | Semantics | Default fallback | Status for migration |
118
+ |---|---|---|---|---|
119
+ | A | `components/eval-card.tsx:83-86` | `??` nullish | `undefined` | spec target |
120
+ | B | `components/benchmark-detail.tsx:5043-5047` | `||` truthy | `null` | divergent from A on edge cases β€” pipeline-emitted `dataset_url` resolves both |
121
+ | C | `components/benchmark-detail.tsx:5416-5420` | n/a β€” bare hf_repo template | n/a | UI element with different intent; out of scope |
122
+
123
+ Pipeline-emitted `dataset_url` (resolved per the spec rule) serves both Sites A and B. Once it's populated, both sites just read it directly and the `??` vs `||` divergence becomes moot.
124
+
125
+ ## Pipeline status β€” divergences
126
+
127
+ ### Side-by-side comparison table
128
+
129
+ | Aspect | TS (this spec) | Pipeline today | Result for users |
130
+ |---|---|---|---|
131
+ | Where the resolution lives | `components/eval-card.tsx` (inline, runs at every render) | not implemented | TS resolves at request time |
132
+ | Pipeline-emitted `dataset_url` | consumed if present (branch 1) | **never populated** (verified 2026-04-28: 0/587 eval-details emit `dataset_url`) | branch 1 is currently dead β€” pipeline could populate to retire the fallback chain |
133
+ | Other source_data fields | `url` (array or string), `hf_repo` consumed | both emitted | TS does the resolution work each render |
134
+
135
+ ### Concrete worked example with quantified scope
136
+
137
+ Audited 2026-04-28 against `.cache/hf-data/evals/` (587 production eval-detail files):
138
+
139
+ - Branch 1 (`dataset_url`): **0** files (the field is never emitted β€” branch is dead code today)
140
+ - Branch 2 (`url[0]`): **564** files (96.1%)
141
+ - Branch 3 (`url` as string): **0** files (always emitted as array)
142
+ - Branch 4 (`hf_repo` template): **22** files (3.7%)
143
+ - Branch 5 (`undefined`): **1** file (`cocoabench` has neither `url` nor `hf_repo`)
144
+
145
+ Examples:
146
+ - `appworld`: `{url: ["https://github.com/Exgentic/exgentic"]}` β†’ `"https://github.com/Exgentic/exgentic"`
147
+ - `ace`: `{hf_repo: "Mercor/ACE"}` β†’ `"https://huggingface.co/datasets/Mercor/ACE"`
148
+ - `cocoabench`: `{dataset_name: "CocoaBench v1.0", source_type: "other", additional_details: {…}}` β†’ `undefined`
149
+
150
+ Verified by `scripts/verify-dataset-url.mjs`.
151
+
152
+ ## Notes for pipeline implementer
153
+
154
+ - Reproduce the 4-step fallback exactly. Don't reorder; first match wins.
155
+ - Truthy semantics for branches 1, 4: empty string and `null`/`undefined` are falsy β†’ fall through to next branch.
156
+ - `url[0]` is taken **without checking truthiness**: if `url` is an array of any length, the first element is returned even if it's `null`, `""`, `0`, etc. This means an array `[null]` produces `null`, NOT `undefined`. Don't "improve" this.
157
+ - `url` as a string short-circuits to that string β€” no further fallback. Even an empty string would short-circuit (passes `typeof === "string"`).
158
+ - The HF template is `https://huggingface.co/datasets/${hf_repo}` with NO encoding, validation, or slash normalization. Whatever `hf_repo` is, it gets appended verbatim.
159
+ - Suggested pipeline emission: add `dataset_url` field to every `source_data` object with the resolved URL. TS-side fallback chain becomes dead code (branch 1 always wins).
160
+
161
+ Verification: run `scripts/verify-dataset-url.mjs` against pipeline-emitted `dataset_url` once it ships. Goal: zero divergence vs TS-as-is across 587 production eval-details.
162
+
163
+ ## Migration checklist
164
+
165
+ - [x] Spec written
166
+ - [x] Tests cover each rule branch + edge cases (`tests/transformations/dataset-url-synthesis.test.ts`)
167
+ - [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
168
+ - [ ] Pipeline emits resolved `dataset_url` on every `source_data` matching this spec
169
+ - [ ] TS deleted; BOTH Site A (`components/eval-card.tsx:83-86`) AND Site B (`components/benchmark-detail.tsx:5043-5047`) read `sourceData?.dataset_url` directly. Site C (the bare `hf_repo` template in `components/benchmark-detail.tsx:5416-5420`) is a different UI element and stays.
170
+
171
+ ## Future product decision (deferred)
172
+
173
+ The 1 `undefined` case (`cocoabench`) means the dataset link button will be missing/disabled for that eval. Whether pipeline should synthesize a fallback (e.g. from `additional_details.benchmark_reference_urls_json`) is a product question outside this refactor's scope.
notes/transformations/05-slug-candidates.md ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Slug candidate generation (model + developer file lookup)
2
+
3
+ Drafted 2026-04-28. Migration item #19 in `notes/migration-plan.md`.
4
+
5
+ ## Framing reminder
6
+
7
+ We are refactoring for UI efficiency. TS-as-is is the canonical spec. The retry logic is **load-bearing** in production (39% of model lookups and 99.9% of developer lookups depend on a non-zero retry position). Don't try to "clean up" the candidate generation β€” preserve it until pipeline emits canonical-by-construction filenames.
8
+
9
+ ## Rule (as TS implements it today)
10
+
11
+ Three pure functions in `lib/model-data.ts:150-211` translate model and developer identifiers into ordered lists of candidate filenames to try when looking up the corresponding JSON in the HF cache (`models/<slug>.json`, `developers/<slug>.json`).
12
+
13
+ ### `pipelineSlugify(text)` β€” base helper
14
+
15
+ Mirrors the slug rule the upstream pipeline uses:
16
+
17
+ 1. Strip control characters (`\x00-\x1f\x7f`).
18
+ 2. Replace any character not in `[a-zA-Z0-9._-]` with `_` (preserves dots, dashes, alnum, case).
19
+ 3. Trim leading/trailing underscores.
20
+ 4. Return `"unknown"` if the result is empty.
21
+
22
+ Note: dots and dashes are preserved AS-IS; only "weird" characters become underscores. Slashes are NOT preserved β€” they become underscores.
23
+
24
+ ### `getModelDetailSlugCandidates(modelId)` β€” produce up to 6 candidate model slugs
25
+
26
+ Inserts variants into a `Set` (so duplicates collapse) in this order:
27
+
28
+ ```
29
+ withSlash = modelId.replace(/\//g, "__") // "openai/gpt-5.2" β†’ "openai__gpt-5.2"
30
+ withDots = withSlash.replace(/\./g, "-") // "openai__gpt-5.2" β†’ "openai__gpt-5-2"
31
+ candidates: pipelineSlugify(withSlash),
32
+ pipelineSlugify(withSlash.toLowerCase()),
33
+ pipelineSlugify(withDots),
34
+ pipelineSlugify(withDots.toLowerCase()),
35
+ pipelineSlugify(modelId),
36
+ pipelineSlugify(modelId.toLowerCase())
37
+ return Array.from(set)
38
+ ```
39
+
40
+ The `Set` collapses duplicates: e.g., for an already-lowercase input, the `.toLowerCase()` variants are no-ops and drop out, so the actual returned array is shorter.
41
+
42
+ ### `getDeveloperSlugCandidates(developerOrRouteId)` β€” up to 6 candidate developer slugs
43
+
44
+ Same Set-based pattern but with different transformations:
45
+
46
+ ```
47
+ underscoreSlug = pipelineSlugify(input)
48
+ lowercaseUnderscoreSlug = pipelineSlugify(input.toLowerCase())
49
+ hyphenSlug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
50
+ compactSlug = input.toLowerCase().replace(/[^a-z0-9]+/g, "")
51
+ candidates: underscoreSlug,
52
+ lowercaseUnderscoreSlug,
53
+ underscoreSlug.replace(/_/g, "-"),
54
+ lowercaseUnderscoreSlug.replace(/_/g, "-"),
55
+ hyphenSlug (if non-empty),
56
+ compactSlug (if non-empty)
57
+ return Array.from(set)
58
+ ```
59
+
60
+ ## Classification
61
+
62
+ - **Lookup transformation (default-only)**. The retry walks candidates and uses the first that resolves to an actual file. Pipeline-side fix: emit a single canonical filename per model/developer that matches `model_route_id`/`developer_route_id` exactly. Then no retry needed.
63
+ - **Cleaning β†’ pipeline.** The slug derivation (`pipelineSlugify`) and canonical ID emission are value transforms per record. The retry loop exists only to compensate for the pipeline not yet emitting a stable canonical ID β€” once it does, both the slug logic and the retry delete together. No aggregation.
64
+
65
+ ## Inputs and expected outputs
66
+
67
+ ### Group A β€” `pipelineSlugify`
68
+
69
+ | Input | Output | Rule |
70
+ |---|---|---|
71
+ | `"openai__gpt-5"` | `"openai__gpt-5"` | passthrough (alnum + dash + underscore allowed) |
72
+ | `"openai__gpt-5.2"` | `"openai__gpt-5.2"` | passthrough (dot allowed) |
73
+ | `"openai/gpt-5"` | `"openai_gpt-5"` | slash β†’ underscore (slash not in allowed set) |
74
+ | `"OpenAI"` | `"OpenAI"` | passthrough (case preserved) |
75
+ | `"x0000001"` | `"x0000001"` | passthrough |
76
+ | `"foo bar"` | `"foo_bar"` | space β†’ underscore |
77
+ | `"foo!@#bar"` | `"foo___bar"` | each special char β†’ `_` |
78
+ | `"___foo___"` | `"foo"` | trim leading/trailing underscores |
79
+ | `"!!!"` | `"unknown"` | empty after trim β†’ fallback |
80
+ | `""` | `"unknown"` | empty β†’ fallback |
81
+
82
+ ### Group B β€” `getModelDetailSlugCandidates`
83
+
84
+ | Input | Candidates returned | Why these are distinct |
85
+ |---|---|---|
86
+ | `"openai/gpt-5"` | `["openai__gpt-5", "openai_gpt-5"]` | already-lowercase + no dots β†’ only slash and slash-stripped variants survive Set dedup |
87
+ | `"openai/gpt-5.2"` | `["openai__gpt-5.2", "openai__gpt-5-2", "openai_gpt-5.2"]` | dotted form gets the with-dots variant (position 1) |
88
+ | `"OpenAI/GPT-5"` | `["OpenAI__GPT-5", "openai__gpt-5", "OpenAI_GPT-5", "openai_gpt-5"]` | case-mixed input β†’ both case variants survive |
89
+ | `"anthropic/claude-3.7-sonnet"` | `["anthropic__claude-3.7-sonnet", "anthropic__claude-3-7-sonnet", "anthropic_claude-3.7-sonnet"]` | dotted version |
90
+ | `"unknown/foo"` | `["unknown__foo", "unknown_foo"]` | already-lowercase + no dots |
91
+
92
+ ### Group C β€” `getDeveloperSlugCandidates`
93
+
94
+ | Input | Candidates returned (in order, deduped) |
95
+ |---|---|
96
+ | `"openai"` | `["openai"]` (all variants collapse to same form) |
97
+ | `"OpenAI"` | `["OpenAI", "openai"]` (case variants distinct) |
98
+ | `"01-ai"` | `["01-ai", "01ai"]` (compactSlug strips the dash) |
99
+ | `"Mistral AI"` | `["Mistral_AI", "mistral_ai", "Mistral-AI", "mistral-ai", "mistralai"]` (space β†’ underscore + hyphen + compact variants) |
100
+ | `"01_ai"` | `["01_ai", "01-ai", "01ai"]` (underscore-slug, dash variant, compact) |
101
+
102
+ ## Current TS implementation
103
+
104
+ The four functions are tightly coupled β€” `pipelineSlugify` is the base; the others build on it.
105
+
106
+ | Concern | Location | Used by |
107
+ |---|---|---|
108
+ | Base slugifier | `lib/model-data.ts:150-157` (`pipelineSlugify`) | the other three slug functions |
109
+ | Developer route_id derivation (exported) | `lib/model-data.ts:159-161` (`getDeveloperRouteId`) | sets `route_id` on output objects in 7 places (see below) |
110
+ | Model candidates | `lib/model-data.ts:167-185` (`getModelDetailSlugCandidates`) | model lookup retry |
111
+ | Developer candidates (exported) | `lib/model-data.ts:187-211` (`getDeveloperSlugCandidates`) | developer lookup retry |
112
+
113
+ ### Call sites β€” model lookups (`getModelDetailSlugCandidates` retry)
114
+
115
+ | Location | Context |
116
+ |---|---|
117
+ | `lib/model-data.ts:1492` | `getModelSummaryById` β€” first attempt: try candidates of the URL-passed modelId |
118
+ | `lib/model-data.ts:1527` | `getModelSummaryById` fallback β€” for each variant's raw_model_ids, try candidates |
119
+
120
+ So the model lookup is THREE-stage in `getModelSummaryById`:
121
+ 1. Direct candidates from the input `modelId` (line 1492)
122
+ 2. If a card matches in `model-cards.json`, try its `model_route_id` directly (line 1516)
123
+ 3. Iterate every variant's `raw_model_ids` and try candidates of each (line 1527)
124
+
125
+ ### Call sites β€” developer lookups (`getDeveloperSlugCandidates` retry)
126
+
127
+ | Location | Context |
128
+ |---|---|
129
+ | `lib/model-data.ts:1343` | inside developer-list build; iterate candidates of `entry.developer` |
130
+ | `lib/model-data.ts:1413` | `getDeveloperSummaryById` β€” try candidates of the URL-passed routeId |
131
+ | `lib/model-data.ts:1452` | `getDeveloperSummaryById` fallback β€” try candidates of the matched developer's name |
132
+
133
+ ### Call sites β€” `getDeveloperRouteId` (output-side route_id derivation)
134
+
135
+ | Location | Context |
136
+ |---|---|
137
+ | `lib/model-data.ts:1320` | `getDeveloperList` β€” set `route_id` on each developer summary |
138
+ | `lib/model-data.ts:1368` | (build path A) |
139
+ | `lib/model-data.ts:1402` | `getDeveloperSummaryById` β€” set `route_id` on returned summary |
140
+ | `lib/model-data.ts:1435` | (build path B) |
141
+ | `lib/model-data.ts:1448` | comparison: `e.developer === routeId \|\| getDeveloperRouteId(e.developer) === routeId` |
142
+ | `lib/model-data.ts:1476` | (build path C) |
143
+ | `lib/duckdb-data.ts:307` | DuckDB backend β€” set `route_id` on developer list output |
144
+
145
+ `getDeveloperRouteId` is the function that DERIVES `route_id` from `developer` β€” it's what makes the comparison at line 1448 work, and it's how the API output gets a stable `route_id` field for routing. Deleting `getDeveloperRouteId` without addressing these callers would break developer-page navigation.
146
+
147
+ ## Pipeline status β€” divergences
148
+
149
+ ### Side-by-side comparison table
150
+
151
+ | Aspect | TS (this spec) | Pipeline today | Result for users |
152
+ |---|---|---|---|
153
+ | File naming (models/) | n/a (consumer side) | filenames written by pipeline; some use `route_id`, others use a dot-stripped variant | TS retries up to 6 candidates per request to find the right file |
154
+ | File naming (developers/) | n/a (consumer side) | filenames are slug-cased developer names; `developers.json` does NOT carry `route_id` | TS derives candidates from the developer name itself |
155
+ | Lookup overhead | up to 6 HF fetch attempts per missing-direct lookup | none (it's just emitting files) | wasted requests on cold-cache; redirected by retry logic |
156
+
157
+ ### Concrete worked example with quantified scope
158
+
159
+ Audited 2026-04-28 against `.cache/hf-data/`:
160
+
161
+ **Model lookups (5,830 cards):**
162
+ - Candidate position 0 hits: **3,529** (60.5%) β€” `route_id` matches the file directly
163
+ - Candidate position 1 hits: **2,297** (39.4%) — needed the dot→dash conversion (e.g. `gpt-5.2` → file `gpt-5-2`)
164
+ - Misses (none of 6 candidates worked): **4** (0.07%)
165
+
166
+ **Developer lookups (824 developers):**
167
+ - Candidate position 0 hits: **468** (56.8%) β€” slugified raw input matches
168
+ - Candidate position 1 hits: **351** (42.6%) β€” needed `.toLowerCase()` (developers with mixed-case names)
169
+ - Candidate position 3 hits: **4** (0.5%) — needed underscore→dash on the lowercased slug
170
+ - Misses: **1** (`x0000001` β€” no developer file under that name)
171
+
172
+ The retry is doing real work: 39% of models and 43% of developers would 404 on direct lookup.
173
+
174
+ Verified by `scripts/verify-slug-candidates.mjs`.
175
+
176
+ ## Notes for pipeline implementer
177
+
178
+ The cleanest pipeline-side fix: **always emit `models/<route_id>.json` and `developers/<route_id>.json` directly**, where `route_id` is the canonical form already on each card. Then TS does a single direct lookup; the retry chain becomes dead code.
179
+
180
+ If pipeline can't easily change file naming, the second-best option is to emit a **slug→file map** in `manifest.json` so TS does an O(1) lookup with no fallback.
181
+
182
+ Concrete requirements for the simpler "canonical filenames" path:
183
+
184
+ 1. For every card in `model-cards.json`: write `models/<card.model_route_id>.json` with the contents.
185
+ - For dotted family_ids like `openai/gpt-5.2`, the `model_route_id` is `openai__gpt-5.2` (with dot preserved). The current cache files for these use dashes (`openai__gpt-5-2.json`). Pick one form and stick with it.
186
+ 2. For every entry in `developers.json`: ensure `route_id` is populated (currently absent) and write `developers/<route_id>.json`.
187
+ 3. The 4 model misses + 1 developer miss currently in production should be investigated separately β€” they represent missing files, not naming-convention issues.
188
+
189
+ Don't try to reproduce the 6-candidate generation logic upstream. The point of the migration is to make it unnecessary.
190
+
191
+ Verification: once pipeline ships canonical naming, every card in `model-cards.json` should resolve via `fs.existsSync('models/' + card.model_route_id + '.json')` directly. Run `scripts/verify-slug-candidates.mjs` and confirm `position 0 hits === total cards`.
192
+
193
+ ## Migration checklist
194
+
195
+ - [x] Spec written
196
+ - [x] Tests cover each rule branch (`tests/transformations/slug-candidates.test.ts`)
197
+ - [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
198
+ - [ ] Pipeline emits `models/<route_id>.json` matching `model_route_id` exactly for all 5,830 cards
199
+ - [ ] Pipeline emits `developers/<route_id>.json` matching `developer_route_id` for all 824 developers
200
+ - [ ] Pipeline adds `route_id` field to every entry in `developers.json` (currently absent β€” TS derives via `getDeveloperRouteId(developer)`)
201
+ - [ ] Pipeline-emitted `developer_route_id` matches `pipelineSlugify(developer.trim().toLowerCase())` for every developer (so the 7 `getDeveloperRouteId` call sites can read pipeline values directly without re-deriving)
202
+ - [ ] TS deleted; callers do single direct lookup. Deletion includes ALL FOUR functions (`pipelineSlugify`, `getDeveloperRouteId`, `getModelDetailSlugCandidates`, `getDeveloperSlugCandidates`) plus the 12 call sites enumerated above.
203
+
204
+ ## Future product decision (deferred)
205
+
206
+ The 4 model misses and 1 developer miss represent files that don't exist. Whether those are "should exist but pipeline forgot" or "intentionally absent" is a product question. Surface to pipeline owner separately during the cleanup pass.
notes/transformations/06-developer-name-canonicalization.md ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Developer name canonicalization
2
+
3
+ Drafted 2026-04-28. Migration item #9 in `notes/migration-plan.md`.
4
+
5
+ ## Framing reminder
6
+
7
+ We are refactoring for UI efficiency. TS-as-is is the canonical spec. The transformation has known imperfections (random HF handles get mechanically title-cased; some users' preferred capitalization doesn't survive) but those are deferred product decisions, not bugs to fix in this migration.
8
+
9
+ ## Rule (as TS implements it today)
10
+
11
+ `normalizeDeveloperName(name)` (`lib/model-data.ts:236-244`) applies one of three transformations in this order:
12
+
13
+ 1. **Map hit (case-insensitive lookup):** lowercase the name, then look up in `KNOWN_DEVELOPER_NAMES`. If present, return the mapped canonical form.
14
+ 2. **Title-case fallback:** if input is fully lowercase AND starts with `[a-z]`, return `name.charAt(0).toUpperCase() + name.slice(1)`. Only the first character is capitalized.
15
+ 3. **Passthrough:** return input unchanged.
16
+
17
+ The map (`lib/model-data.ts:217-234`) has 16 entries:
18
+
19
+ | Map key | Canonical |
20
+ |---|---|
21
+ | `openai` | OpenAI |
22
+ | `google` | Google |
23
+ | `anthropic` | Anthropic |
24
+ | `meta` | Meta |
25
+ | `microsoft` | Microsoft |
26
+ | `mistralai` | Mistral AI |
27
+ | `deepseek` | DeepSeek |
28
+ | `deepseek-ai` | DeepSeek |
29
+ | `cohere` | Cohere |
30
+ | `nvidia` | NVIDIA |
31
+ | `alibaba` | Alibaba |
32
+ | `amazon` | Amazon |
33
+ | `apple` | Apple |
34
+ | `ibm` | IBM |
35
+ | `xai` | xAI |
36
+ | `x-ai` | xAI |
37
+
38
+ Note that some map keys collide with their canonical form (e.g. `google` β†’ `Google` is just case-fixing) while others apply substantive transforms (`mistralai` β†’ `Mistral AI` adds a space; `deepseek-ai` β†’ `DeepSeek` strips the `-ai` suffix; `xai` β†’ `xAI` mid-word capital).
39
+
40
+ ## Classification
41
+
42
+ - **Unconditional normalization.** The function always runs on whatever `developer` string is present β€” it does not check for a pre-existing canonical field. Map hits, title-case fallback, and passthrough are all branches of the same unconditional transform. Pipeline-side fix: emit `developer` in canonical form directly; no consumer should re-derive it.
43
+ - **Cleaning β†’ pipeline.** Pure value transform on a single field. No aggregation or record merging. Migration target: pipeline emits canonical `developer`; TS `normalizeDeveloperName` and `KNOWN_DEVELOPER_NAMES` delete.
44
+
45
+ ## Inputs and expected outputs
46
+
47
+ Each row corresponds to a parameterized test case in `tests/transformations/developer-name-canonicalization.test.ts`.
48
+
49
+ ### Group A β€” Map hits (case-insensitive, substantive transforms)
50
+
51
+ | Input | Output | Rule |
52
+ |---|---|---|
53
+ | `openai` | `OpenAI` | map (case fix) |
54
+ | `OpenAI` | `OpenAI` | map (case-insensitive lookup β†’ same canonical form) |
55
+ | `OPENAI` | `OpenAI` | map |
56
+ | `mistralai` | `Mistral AI` | map (space added) |
57
+ | `MistralAI` | `Mistral AI` | map (case-insensitive) |
58
+ | `deepseek-ai` | `DeepSeek` | map (`-ai` suffix dropped) |
59
+ | `DeepSeek-AI` | `DeepSeek` | map (case-insensitive) |
60
+ | `xai` | `xAI` | map (mid-word cap) |
61
+ | `x-ai` | `xAI` | map (alias) |
62
+ | `nvidia` | `NVIDIA` | map (uppercase) |
63
+ | `IBM` | `IBM` | map (case-insensitive lookup β†’ uppercase canonical) |
64
+
65
+ ### Group B β€” Title-case fallback (lowercase input, not in map)
66
+
67
+ | Input | Output | Why |
68
+ |---|---|---|
69
+ | `jaspionjader` | `Jaspionjader` | lowercase + starts with [a-z] β†’ title-case first char only |
70
+ | `allenai` | `Allenai` | lowercase + not in map β†’ first-char uppercase only |
71
+ | `bunnycore` | `Bunnycore` | same |
72
+ | `zelk12` | `Zelk12` | same β€” digits inside don't matter |
73
+
74
+ ### Group C β€” Passthrough (mixed case, not in map)
75
+
76
+ | Input | Output | Why |
77
+ |---|---|---|
78
+ | `JayHyeon` | `JayHyeon` | already has uppercase β†’ not lowercase β†’ passthrough |
79
+ | `DreadPoor` | `DreadPoor` | same |
80
+ | `Qwen` | `Qwen` | (Qwen is NOT in the map; passes through) |
81
+ | `prithivMLmods` | `prithivMLmods` | mixed case, passthrough as-is β€” no first-char capitalization (input has uppercase, so the lowercase check fails) |
82
+ | `Quazim0t0` | `Quazim0t0` | same |
83
+ | `01-ai` | `01-ai` | does NOT start with [a-z] (starts with digit) β†’ fallback rule fails β†’ passthrough. Note: 01-ai is NOT in the map. |
84
+ | `01_ai` | `01_ai` | same |
85
+
86
+ ### Group D β€” Edge cases
87
+
88
+ | Input | Output | Notes |
89
+ |---|---|---|
90
+ | ` google ` | `Google` | trim happens inside `key = name.trim().toLowerCase()` BUT the title-case branch and passthrough use the ORIGINAL `name` (not trimmed). For ` google `: key = "google" β†’ matches map β†’ "Google" |
91
+ | ` jaspionjader ` | ` jaspionjader ` | key = "jaspionjader" β†’ no map hit. Title-case check uses original `name` which has spaces β€” `" jaspionjader " === " jaspionjader ".toLowerCase()` is true, BUT `/^[a-z]/.test(" jaspionjader ")` is FALSE (starts with space). β†’ falls to passthrough |
92
+ | (empty string) | (empty string) | trim makes key empty β†’ no map hit. Title-case check: `"" === "".toLowerCase()` is true but `/^[a-z]/.test("")` is false β†’ passthrough returns "" |
93
+
94
+ The edge case shows a TS quirk: leading whitespace prevents the title-case rule from firing, so `" jaspionjader "` passes through unchanged. The map-lookup uses the trimmed form and works for known names regardless.
95
+
96
+ ## Current TS implementation
97
+
98
+ | Concern | Location |
99
+ |---|---|
100
+ | Map | `lib/model-data.ts:217-234` (`KNOWN_DEVELOPER_NAMES`) |
101
+ | Function (exported) | `lib/model-data.ts:236-244` (`normalizeDeveloperName`) |
102
+
103
+ ### Call sites (5 total)
104
+
105
+ | Location | Context |
106
+ |---|---|
107
+ | `lib/model-data.ts:387` | `hfModelCardToEvaluationCardData` β€” set `developer` on output card |
108
+ | `lib/model-data.ts:1367` | developer-list build path A |
109
+ | `lib/model-data.ts:1401` | `getDeveloperSummaryById` β€” set `developer` on returned summary |
110
+ | `lib/model-data.ts:1434` | developer-list build path B |
111
+ | `lib/duckdb-data.ts:306` | DuckDB backend β€” set `developer` on developer list output |
112
+
113
+ ## Pipeline status β€” divergences
114
+
115
+ ### Side-by-side comparison table
116
+
117
+ | Aspect | TS (this spec) | Pipeline today | Result for users |
118
+ |---|---|---|---|
119
+ | Where canonicalization runs | request time, in 5 call sites | not implemented; raw `developer` string emitted as-is | TS canonicalizes per request |
120
+ | Output field | inline transformation of `developer` field | n/a | TS-canonicalized name appears on user-visible UI |
121
+
122
+ ### Concrete worked example with quantified scope
123
+
124
+ Audited 2026-04-28 against `.cache/hf-data/`:
125
+
126
+ **`developers.json` (824 entries):**
127
+ - Map hits: **15** (1.8%) β€” names like `Google`, `OpenAI`, `Alibaba` (the map's main job is case-fixing on these inputs since they already arrive Title-cased)
128
+ - Title-case fallback: **458** (55.6%) β€” lowercase HF handles like `jaspionjader`, `allenai`, `bunnycore` get first-char-uppercased
129
+ - Passthrough: **351** (42.6%) β€” mixed-case handles like `JayHyeon`, `prithivMLmods`, `Qwen` survive unchanged
130
+
131
+ **`model-cards.json` (5,830 cards):**
132
+ - Map hits: **695** (11.9%)
133
+ - Title-case fallback: **2,824** (48.4%)
134
+ - Passthrough: **2,311** (39.6%)
135
+
136
+ The substantive map transforms (`mistralai` β†’ `Mistral AI`, `deepseek-ai` β†’ `DeepSeek`) DO fire in production β€” the model-cards.json mapHit count being higher than developers.json (11.9% vs 1.8%) suggests more model entries use the lowercase-suffix forms (e.g., `deepseek-ai` from the HF org slug) than the developers.json which already uses canonical forms.
137
+
138
+ Verified by `scripts/verify-developer-name.mjs`.
139
+
140
+ ## Notes for pipeline implementer
141
+
142
+ - Reproduce all 16 map entries exactly (both case-fix entries like `google β†’ Google` and substantive transforms like `mistralai β†’ Mistral AI`).
143
+ - Reproduce the title-case fallback exactly: only fire when the entire string equals `name.toLowerCase()` AND starts with `[a-z]`. Don't capitalize anything else (no smart casing of multi-word names, no unicode-aware uppercasing).
144
+ - The leading-whitespace quirk (`" jaspionjader "` passes through unchanged because the title-case regex fails on the leading space) should be preserved as-is.
145
+ - Suggested pipeline emission: add `canonical_developer_name` field to `developers.json` entries and to every model card. Don't overwrite the upstream `developer` field; new field for clarity.
146
+
147
+ Verification: run `scripts/verify-developer-name.mjs` against pipeline output once it ships. Goal: zero divergence vs TS-as-is across both 824 developers and 5,830 model cards.
148
+
149
+ ## Migration checklist
150
+
151
+ - [x] Spec written
152
+ - [x] Tests cover each rule branch (`tests/transformations/developer-name-canonicalization.test.ts`)
153
+ - [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
154
+ - [ ] Pipeline emits `canonical_developer_name` on every developer entry + model card matching this spec
155
+ - [ ] TS deleted; replace 5 call sites (4 in lib/model-data.ts + 1 in lib/duckdb-data.ts) with direct field reads. Delete `KNOWN_DEVELOPER_NAMES` table + `normalizeDeveloperName`.
156
+
157
+ ## Future product decision (deferred)
158
+
159
+ The title-case fallback produces stylistically-questionable output for HF user handles (`jaspionjader β†’ Jaspionjader`). Whether the team wants to (a) expand the map to cover more cases, (b) leave random HF handles as-is, or (c) take a different approach (e.g. fetch the user's preferred display name from HF API) is out of scope for this refactor.
notes/transformations/07-timestamp-normalization.md ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Timestamp normalization
2
+
3
+ Drafted 2026-04-28. Migration item #13 in `notes/migration-plan.md`.
4
+
5
+ ## Framing reminder
6
+
7
+ We are refactoring for UI efficiency. TS-as-is is the canonical spec. Three different timestamp normalizers exist in production, with subtly different semantics. They produce different numeric values for the same input but happen to converge on production data (99.99% is unix-seconds-strings; the divergence only fires when comparing across formats, which production rarely does). The migration target: emit a single canonical timestamp format upstream so all three normalizers can be deleted.
8
+
9
+ ## Rule (as TS implements it today β€” three variants)
10
+
11
+ Three independent functions parse string timestamps into comparable numbers:
12
+
13
+ ### Variant A β€” `lib/model-data.ts:76-81` (`normalizeEvalTimestamp`)
14
+
15
+ ```ts
16
+ function normalizeEvalTimestamp(value: string) {
17
+ const numericTimestamp = Number(value)
18
+ return !Number.isNaN(numericTimestamp) && !value.includes("-")
19
+ ? numericTimestamp * 1000
20
+ : new Date(value).getTime()
21
+ }
22
+ ```
23
+
24
+ - Uses `Number()` (strict β€” entire string must be numeric or returns `NaN`)
25
+ - If numeric AND no `-` in input β†’ multiply by 1000 (treats as **unix seconds**, output in ms)
26
+ - Else β†’ `new Date(value).getTime()` (ISO date parsing, output in ms)
27
+ - Returns `NaN` if neither path produces a finite number (no defensive fallback)
28
+
29
+ ### Variant B β€” `lib/hf-data.ts:1049-1061` (`toComparableTimestamp`)
30
+
31
+ ```ts
32
+ function toComparableTimestamp(timestamp: string | undefined) {
33
+ if (!timestamp) return Number.NEGATIVE_INFINITY
34
+ const numericTimestamp = Number.parseFloat(timestamp)
35
+ if (Number.isFinite(numericTimestamp)) return numericTimestamp
36
+ const parsedTimestamp = new Date(timestamp).getTime()
37
+ return Number.isFinite(parsedTimestamp) ? parsedTimestamp : Number.NEGATIVE_INFINITY
38
+ }
39
+ ```
40
+
41
+ - Uses `Number.parseFloat()` (lenient β€” parses leading numeric prefix; e.g. `"2026-04-13"` β†’ `2026`)
42
+ - If parseFloat returns finite β†’ return AS-IS (NO `* 1000` multiplier)
43
+ - Else β†’ fallback to `Date.getTime()` or `NEGATIVE_INFINITY`
44
+ - Defensive: undefined β†’ `NEGATIVE_INFINITY`
45
+
46
+ ### Variant C β€” `components/benchmark-detail.tsx:1418-1426` (`toComparableTimestamp`)
47
+
48
+ Same as Variant B but parameter is `string` (not `string | undefined`) and there's no leading `if (!timestamp)` check. Otherwise functionally identical.
49
+
50
+ ## Classification
51
+
52
+ This item has two halves that land in different places:
53
+
54
+ - **Cleaning (value format canonicalization) β†’ pipeline.** The pipeline currently emits `retrieved_timestamp` as a unix-seconds-string. Converting to ISO 8601 is a value-change that belongs upstream; once done, all consumers read a consistently-formatted string with no parsing quirks.
55
+ - **Reshape (variant dedup / sort-key derivation) β†’ DuckDB SQL.** The 3 normalizers + 8 call sites exist solely to compare timestamps in order to pick the freshest variant or sort models by recency. That's a `MAX(retrieved_timestamp)` or `ORDER BY retrieved_timestamp DESC` operation β€” reshape work that has no business running at request time in TS. Once timestamps are ISO 8601, SQL comparison is lexicographic and correct. With a relational parquet schema, variant dedup becomes `QUALIFY ROW_NUMBER() OVER (PARTITION BY variant_key ORDER BY retrieved_timestamp DESC) = 1` instead of three TS normalizers.
56
+
57
+ The two halves delete together: pipeline emits ISO 8601 (cleaning done) β†’ SQL replaces the comparison call sites (reshape done) β†’ all three TS functions deleted.
58
+
59
+ ## Inputs and expected outputs
60
+
61
+ Each table below describes ONE variant. Pipeline must produce identical outputs per variant when canonical timestamps still roundtrip through these functions; the deletion target is to remove all three.
62
+
63
+ ### Group A β€” Variant A (`normalizeEvalTimestamp`)
64
+
65
+ | Input | Output | Path |
66
+ |---|---|---|
67
+ | `"1774096306"` | `1774096306000` | numeric, no dash β†’ `* 1000` (unix seconds β†’ ms) |
68
+ | `"1774096306.427425"` | `1774096306427.4248` | numeric, no dash β†’ `* 1000` |
69
+ | `"2026-04-13T12:34:56Z"` | `1776083696000` | not numeric β†’ `Date.getTime()` |
70
+ | `"2025-01-01"` | `1735689600000` | not numeric β†’ `Date.getTime()` |
71
+ | `"-1774096306"` | (a Date in 1969) | numeric BUT includes `-` β†’ falls to `Date.getTime()` of negative-number-string β†’ unexpected |
72
+ | `"not a date"` | `NaN` | not numeric AND `Date(...)` is invalid β†’ returns NaN |
73
+ | `""` | `NaN` | Number("") = 0, no dash, β†’ 0 * 1000 = 0... actually wait, Number("") is 0, !isNaN(0) is true, includes("-") false, β†’ 0 * 1000 = 0. So empty returns 0, not NaN. |
74
+ | `"20240620"` | `20240620000` | numeric, no dash β†’ `* 1000`. Treated as unix seconds (year 1970) β€” NOT as YYYYMMDD date |
75
+
76
+ ### Group B β€” Variant B (`toComparableTimestamp` in lib/hf-data.ts)
77
+
78
+ | Input | Output | Path |
79
+ |---|---|---|
80
+ | `"1774096306"` | `1774096306` | parseFloat finite β†’ return as-is (NO multiplier) |
81
+ | `"1774096306.427425"` | `1774096306.427425` | parseFloat finite β†’ return as-is |
82
+ | `"2026-04-13T12:34:56Z"` | `2026` | parseFloat parses leading "2026" β†’ finite β†’ returns `2026` (TS quirk: ISO datetimes look like the year-as-number, NOT compared as ms-of-epoch) |
83
+ | `"2025-01-01"` | `2025` | parseFloat β†’ 2025 (TS quirk again) |
84
+ | `"not a date"` | `NEGATIVE_INFINITY` | parseFloat NaN β†’ Date NaN β†’ fallback |
85
+ | `""` | `NEGATIVE_INFINITY` | falsy β†’ defensive fallback |
86
+ | `undefined` | `NEGATIVE_INFINITY` | falsy β†’ defensive fallback |
87
+ | `"20240620"` | `20240620` | parseFloat finite β†’ return as-is |
88
+
89
+ ### Group C β€” Variant C (`toComparableTimestamp` in components/benchmark-detail.tsx)
90
+
91
+ Same as Variant B except `""` and `undefined` paths:
92
+
93
+ | Input | Output | Path |
94
+ |---|---|---|
95
+ | `""` | `NEGATIVE_INFINITY` | parseFloat("") = NaN, Date("").getTime() = NaN β†’ fallback |
96
+ | `undefined` | (TypeError at call site, since signature is `string` not `string \| undefined`) | undefined isn't allowed; parseFloat(undefined) = NaN, but TS would flag the call |
97
+
98
+ In practice the `string` signature means callers always pass strings, so the `if (!timestamp)` check is unnecessary.
99
+
100
+ ### Group D β€” Cross-variant divergence (TS quirk)
101
+
102
+ For the same input, the three variants produce DIFFERENT numbers. Comparing values from different variants is unsafe β€” but in production each variant is used in a self-contained scope, so this divergence doesn't usually fire.
103
+
104
+ | Input | Variant A | Variant B | Variant C |
105
+ |---|---|---|---|
106
+ | `"1774096306.427425"` | `1774096306427.4248` (ms) | `1774096306.427425` (seconds, no multiplier) | `1774096306.427425` |
107
+ | `"2026-04-13T12:34:56Z"` | `1776083696000` (ms-of-epoch from Date) | `2026` (parseFloat extracts the year!) | `2026` |
108
+ | Comparing the two above (a vs b) | a < b (correct: 2026 is more recent) | a > b (**incorrect**: parseFloat treats ISO as the number 2026) | a > b (**incorrect**) |
109
+
110
+ **This is a real bug in Variants B and C** for cross-format comparisons. It doesn't manifest in production because 99.99% of timestamps in `.cache/hf-data/models/*.json` are unix-seconds-strings. Do NOT fix in this migration; document and let pipeline canonicalize the format upstream so the bug becomes structurally impossible.
111
+
112
+ ## Current TS implementation
113
+
114
+ | Concern | Location | Callers |
115
+ |---|---|---|
116
+ | Variant A β€” `normalizeEvalTimestamp` | `lib/model-data.ts:76-81` | 4 sites: `lib/model-data.ts:266, 650, 945-946, 1124` (all sort/compare timestamps when picking latest or sorting model_results) |
117
+ | Variant B β€” `toComparableTimestamp` | `lib/hf-data.ts:1049-1061` | 2 sites: `lib/hf-data.ts:1311-1312` (compare in flattenHierarchyNode variant-bucket reduction) |
118
+ | Variant C β€” `toComparableTimestamp` | `components/benchmark-detail.tsx:1418-1426` | 2 sites: `components/benchmark-detail.tsx:1600-1601` (variant deduplication) |
119
+
120
+ Total: 3 functions + 8 caller sites across 3 files.
121
+
122
+ ## Pipeline status β€” divergences
123
+
124
+ ### Side-by-side comparison table
125
+
126
+ | Aspect | TS (this spec) | Pipeline today | Result for users |
127
+ |---|---|---|---|
128
+ | Where canonicalization runs | request time, in 3 functions | not implemented; raw `retrieved_timestamp` strings emitted | TS parses on every comparison |
129
+ | Output format | varies per variant (ms vs seconds) | `retrieved_timestamp` is unix-seconds-string in 99.99% of rows; ISO datetime in 0.006% | mixed; TS handles each variant differently but production format consistency means it usually works |
130
+
131
+ ### Concrete worked example with quantified scope
132
+
133
+ Audited 2026-04-28 against `.cache/hf-data/models/*.json`:
134
+
135
+ - Total `retrieved_timestamp` values: **86,183**
136
+ - Unix-seconds-string format (`"1774096306.427425"`): **86,178** (99.994%)
137
+ - ISO datetime format (`"2024-10-27T00:00:00Z"`): **5** (0.006%)
138
+ - Empty / null: **0**
139
+ - Other: **0**
140
+
141
+ Verified by `scripts/verify-timestamp.mjs`.
142
+
143
+ ## Notes for pipeline implementer
144
+
145
+ - **Recommended canonical format: ISO 8601** (`"2026-04-13T12:34:56Z"`). Lexicographic sort works as chronological sort; `Date(...)` parsing is unambiguous; matches what AGENTS.md uses elsewhere.
146
+ - Once pipeline emits all timestamps as ISO 8601:
147
+ - Variant A's `* 1000` multiplier path becomes dead (no numeric input β†’ all paths use `Date.getTime()`)
148
+ - Variants B and C's `parseFloat` quirk becomes irrelevant (ISO inputs β†’ parseFloat NaN β†’ fall to `Date.getTime()`)
149
+ - All three variants then become equivalent and can be replaced with a single `Date(ts).getTime()` inline (or a shared one-line helper).
150
+ - Don't try to migrate to a different format mid-flight (e.g. ms-of-epoch as bigint); ISO matches what the rest of the system expects.
151
+ - The 5 existing ISO-format rows in production are evidence this format already works for the cache; the rest just need to be converted upstream.
152
+
153
+ Verification: once pipeline ships ISO timestamps for all 86,183 rows, run `scripts/verify-timestamp.mjs` and confirm the unixSecondsString count drops to 0.
154
+
155
+ ## Migration checklist
156
+
157
+ - [x] Spec written
158
+ - [x] Tests cover each variant's semantics + the cross-variant divergence (`tests/transformations/timestamp-normalization.test.ts`)
159
+ - [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
160
+ - [ ] Pipeline emits all `retrieved_timestamp` values as ISO 8601 across all 86,183 rows
161
+ - [ ] TS deleted; replace 3 functions + 8 callers with a single shared `Date(ts).getTime()` (or inline). Files: `lib/model-data.ts`, `lib/hf-data.ts`, `components/benchmark-detail.tsx`.
162
+
163
+ ## Future product decision (deferred)
164
+
165
+ The `parseFloat` bug in Variants B and C produces incorrect ordering for cross-format comparisons. We're choosing to fix-by-canonicalization-upstream rather than fix-in-place. Whether the bug should be patched in TS as a defensive measure (in case a non-ISO timestamp slips through after migration) is a separate decision.
notes/transformations/08-benchmark-display-names.md ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Benchmark display names
2
+
3
+ Drafted 2026-04-28. Migration item #8 in `notes/migration-plan.md`.
4
+
5
+ ## Framing reminder
6
+
7
+ We are refactoring for UI efficiency. TS-as-is is the canonical spec. The transformation has known imperfections (one of the two implementations is a substring-match contains-test that will mangle e.g. "MMLU-Pro" by replacing the entire string with the long-form for "MMLU"; the active map is hand-curated and only covers ~34 known suite/parent keys) but those are deferred product decisions, not bugs to fix in this migration.
8
+
9
+ ## Rule (as TS implements it today)
10
+
11
+ The repo has **two** functions named `getBenchmarkDisplayName` with different semantics; the active one in production is `lib/model-data.ts` (the `lib/eval-processing.ts` copy is an unused/duplicate β€” see "Duplicate implementation" below).
12
+
13
+ ### Active implementation β€” `lib/model-data.ts:146-148`
14
+
15
+ `getBenchmarkDisplayName(benchmark: string)` applies one of two transformations:
16
+
17
+ 1. **Map hit (normalized lookup):** normalize the input via `normalizeBenchmarkKeyForLookup` (lowercase; replace any run of `-`, `.`, or whitespace with a single `_`; strip leading/trailing `_`), then look up in `BENCHMARK_NAMES`. If present, return the mapped canonical form.
18
+ 2. **Tokenize fallback (`humanizeToken`, `lib/model-data.ts:90-96`):** split the *original* (un-normalized) input on `[_-]+`, drop empty parts, capitalize the first character of each part, join with a single space. Note: only the first character is uppercased β€” `mmlu` becomes `Mmlu`, not `MMLU`.
19
+
20
+ The map (`lib/model-data.ts:109-140`) has 30 entries β€” all hand-maintained suite/family keys.
21
+
22
+ | Map key | Canonical |
23
+ |---|---|
24
+ | `hfopenllm_v2` | HF Open LLM v2 |
25
+ | `helm_lite` | HELM Lite |
26
+ | `helm_capabilities` | HELM Capabilities |
27
+ | `helm_classic` | HELM Classic |
28
+ | `helm_instruct` | HELM Instruct |
29
+ | `helm_mmlu` | HELM MMLU |
30
+ | `reward_bench` | RewardBench |
31
+ | `reward_bench_2` | RewardBench 2 |
32
+ | `bfcl` | BFCL |
33
+ | `global_mmlu_lite` | Global MMLU Lite |
34
+ | `swe_bench` | SWE-bench |
35
+ | `arc_agi` | ARC-AGI |
36
+ | `tau_bench_2` | TAU-Bench 2 |
37
+ | `ace` | ACE |
38
+ | `apex_agents` | APEX Agents |
39
+ | `apex_v1` | APEX v1 |
40
+ | `appworld` | AppWorld |
41
+ | `browsecompplus` | BrowseComp+ |
42
+ | `livecodebenchpro` | LiveCodeBench Pro |
43
+ | `sciarena` | SciArena |
44
+ | `terminal_bench_2_0` | Terminal Bench 2.0 |
45
+ | `la_leaderboard` | LA Leaderboard |
46
+ | `theory_of_mind` | Theory of Mind |
47
+ | `fibble_arena` | Fibble Arena |
48
+ | `fibble1_arena` | Fibble Arena v1 |
49
+ | `fibble2_arena` | Fibble Arena v2 |
50
+ | `fibble3_arena` | Fibble Arena v3 |
51
+ | `fibble4_arena` | Fibble Arena v4 |
52
+ | `fibble5_arena` | Fibble Arena v5 |
53
+ | `wordle_arena` | Wordle Arena |
54
+
55
+ The lookup is normalization-insensitive: `"HELM Lite"`, `"helm-lite"`, `"helm.lite"`, `" helm lite "` all normalize to `helm_lite` and hit the map.
56
+
57
+ ### Suite-display-name companion β€” `components/benchmark-detail.tsx:308-336`
58
+
59
+ `benchmark-detail.tsx` carries its own `SUITE_DISPLAY_NAMES` table (an exact 30-entry copy of `BENCHMARK_NAMES`) plus two further override tables (`DISPLAY_TOKEN_OVERRIDES`, `DISPLAY_NAME_OVERRIDES`) and a different normalize/tokenize pipeline (`normalizeDisplayLabel`/`normalizeDisplayToken`). The suite-name path:
60
+
61
+ 1. `normalizeSuiteKey(key)` collapses `[-.\s]+` β†’ `_`, strips edge `_`, then applies two regex special-cases: `/^fibble\d*_arena$/` collapses to `fibble_arena`, `/^arc_agi_v\d+/` collapses to `arc_agi`.
62
+ 2. `getSuiteDisplayName(key)` returns `SUITE_DISPLAY_NAMES[normalizedKey] ?? normalizeDisplayLabel(key)`.
63
+ 3. The fallback (`normalizeDisplayLabel`) is more sophisticated than `humanizeToken`: it splits on `/`, then on whitespace, then per-token applies `DISPLAY_TOKEN_OVERRIDES` (a 26-entry table that knows acronyms like `mmlu β†’ MMLU`, `helm β†’ HELM`, `gpt β†’ GPT`).
64
+
65
+ The benchmark-detail suite-name path is **not** the same function as `getBenchmarkDisplayName` β€” it is consumed only inside `benchmark-detail.tsx` for rendering suite headers. The model-detail path (`lib/model-data.ts β†’ getBenchmarkDisplayName`) is the one consumed across the rest of the app (model cards, comparison index, eval rollups, DuckDB backend).
66
+
67
+ ### Duplicate implementation β€” `lib/eval-processing.ts:861-885`
68
+
69
+ A second function with the same name `getBenchmarkDisplayName` lives in `lib/eval-processing.ts`. It uses a completely different rule:
70
+
71
+ ```
72
+ const mapping = { 'MMLU': 'Massive Multitask Language Understanding', 'MMLU-Pro': 'MMLU Professional', ... }
73
+ for (const [key, value] of Object.entries(mapping)) {
74
+ if (name.toUpperCase().includes(key.toUpperCase())) return value
75
+ }
76
+ return name
77
+ ```
78
+
79
+ It does case-insensitive substring matching against a 10-entry mapping of long-form descriptive names. **It disagrees with the `lib/model-data.ts` version on every map hit** (e.g. for input `"MMLU"`, model-data returns `"Mmlu"` via fallback, eval-processing returns `"Massive Multitask Language Understanding"`). Reachability:
80
+
81
+ - `lib/eval-processing.ts:903` β€” `getBenchmarkDisplayName(compositeBenchmarkKey)` inside `groupEvaluationsByBenchmark`. `groupEvaluationsByBenchmark` is exported but is **not imported anywhere else in the repo** (verified by `rg "groupEvaluationsByBenchmark"` β€” only its own declaration appears). Functionally dead.
82
+
83
+ The substring-include rule has a known soft-bug: input `"MMLU-Pro"` would return the mapping for `"MMLU"` (`"Massive Multitask Language Understanding"`) because the loop iterates in insertion order and `MMLU` comes first; the `MMLU-Pro` entry never wins. Documented as TS-as-is; do not "fix" this in the migration.
84
+
85
+ ## Classification
86
+
87
+ - **Unconditional normalization.** The function always runs on whatever benchmark key/name is present β€” it does not check for a pre-existing canonical field. Map hits and tokenize fallback are both branches of the same unconditional transform. Pipeline-side fix: emit a canonical `display_name` field per benchmark; no consumer should re-derive it.
88
+ - **Cleaning β†’ pipeline.** Pure value transform on a single string field. No aggregation or record merging. Migration target: pipeline emits canonical display name on every benchmark/eval entry; TS map + `getBenchmarkDisplayName` + the duplicate in `eval-processing.ts` + the parallel `SUITE_DISPLAY_NAMES` table in `benchmark-detail.tsx` all delete.
89
+
90
+ ## Inputs and expected outputs
91
+
92
+ Each row corresponds to a parameterized test case in `tests/transformations/benchmark-display-names.test.ts`.
93
+
94
+ ### Group A β€” Map hits (normalized-key lookup against `BENCHMARK_NAMES`)
95
+
96
+ | Input | Output | Rule |
97
+ |---|---|---|
98
+ | `helm_lite` | `HELM Lite` | exact normalized match |
99
+ | `HELM Lite` | `HELM Lite` | normalize: lower + space→`_` → `helm_lite` |
100
+ | `helm-lite` | `HELM Lite` | normalize: dash→`_` |
101
+ | `helm.lite` | `HELM Lite` | normalize: dot→`_` |
102
+ | ` helm lite ` | `HELM Lite` | normalize: whitespace runs β†’ `_`, trim edges |
103
+ | `arc_agi` | `ARC-AGI` | substantive transform: returns dashed form |
104
+ | `swe_bench` | `SWE-bench` | substantive (lowercase `bench`) |
105
+ | `reward_bench` | `RewardBench` | substantive (concatenated, no separator) |
106
+ | `reward_bench_2` | `RewardBench 2` | substantive |
107
+ | `terminal_bench_2_0` | `Terminal Bench 2.0` | substantive (literal `_0` becomes `.0` in output) |
108
+ | `mistralai` (n/a β€” not a benchmark) | β€” | not in benchmark map |
109
+ | `hfopenllm_v2` | `HF Open LLM v2` | substantive (3-token split) |
110
+ | `bfcl` | `BFCL` | uppercase |
111
+ | `ace` | `ACE` | uppercase |
112
+ | `apex_agents` | `APEX Agents` | partial uppercase |
113
+ | `apex_v1` | `APEX v1` | partial uppercase + lowercase v |
114
+ | `appworld` | `AppWorld` | mixed-case substantive |
115
+ | `browsecompplus` | `BrowseComp+` | adds `+` |
116
+ | `livecodebenchpro` | `LiveCodeBench Pro` | substantive |
117
+ | `sciarena` | `SciArena` | substantive |
118
+ | `la_leaderboard` | `LA Leaderboard` | partial uppercase |
119
+ | `theory_of_mind` | `Theory of Mind` | substantive (lowercase `of`) |
120
+ | `fibble_arena` | `Fibble Arena` | base entry |
121
+ | `fibble1_arena` | `Fibble Arena v1` | substantive |
122
+ | `fibble2_arena` | `Fibble Arena v2` | substantive |
123
+ | `fibble3_arena` | `Fibble Arena v3` | substantive |
124
+ | `fibble4_arena` | `Fibble Arena v4` | substantive |
125
+ | `fibble5_arena` | `Fibble Arena v5` | substantive |
126
+ | `wordle_arena` | `Wordle Arena` | substantive |
127
+ | `global_mmlu_lite` | `Global MMLU Lite` | partial uppercase |
128
+ | `helm_capabilities` | `HELM Capabilities` | partial uppercase |
129
+ | `helm_classic` | `HELM Classic` | partial uppercase |
130
+ | `helm_instruct` | `HELM Instruct` | partial uppercase |
131
+ | `helm_mmlu` | `HELM MMLU` | full uppercase |
132
+ | `tau_bench_2` | `TAU-Bench 2` | substantive (`TAU-Bench`, dashed) |
133
+
134
+ ### Group B β€” Tokenize fallback (`humanizeToken`)
135
+
136
+ Inputs that miss the map go through `humanizeToken(originalInput)`: split on `[_-]+`, drop empty parts, uppercase the first char of each part, join with `" "`.
137
+
138
+ | Input | Output | Why |
139
+ |---|---|---|
140
+ | `bbh` | `Bbh` | single token, only first char uppercased β€” NOT `BBH` |
141
+ | `gpqa` | `Gpqa` | only first char uppercased β€” NOT `GPQA` |
142
+ | `mmlu` | `Mmlu` | NOT `MMLU` (this is what's served when only the model-data path runs) |
143
+ | `gsm8k` | `Gsm8k` | digits inside don't capitalize differently |
144
+ | `MATH` | `MATH` | already uppercase, untouched (only first-char of each token is *set* β€” but `M` is already upper) |
145
+ | `MMLU-PRO` | `MMLU PRO` | split on dash; each token already starts upper; rest of token preserved as-is |
146
+ | `MMLU` | `MMLU` | passthrough β€” already starts uppercase, fallback's `charAt(0).toUpperCase()` is a no-op on `M`, slice preserves `MLU` |
147
+ | `helm air bench` | `HELM Lite` (NO!) β€” wait | normalizes to `helm_air_bench`, not in map β†’ tokenize fallback uses ORIGINAL `helm air bench` β†’ split on `[_-]+` is single token `helm air bench` β†’ `Helm air bench` |
148
+ | `Helm air bench` | `Helm air bench` | NOT in map (only `helm_air_bench` would be β€” and isn't); fallback splits on `[_-]+` only, so the spaces survive and only the first char gets uppercased |
149
+ | `humaneval` | `Humaneval` | not in map |
150
+ | `truthfulqa` | `Truthfulqa` | not in map |
151
+ | `BBQ` | `BBQ` | not in map; split on `[_-]+` is single `BBQ`; first char already upper, rest preserved |
152
+ | `swe-bench-verified` | `Swe Bench Verified` | not in map; split on `-` β†’ 3 tokens, each first-cap |
153
+ | `swe_bench_verified_mini` | `Swe Bench Verified Mini` | split on `_` β†’ 4 tokens |
154
+ | `multi_swe_bench` | `Multi Swe Bench` | split on `_` β†’ 3 tokens |
155
+ | `helm_air_bench` | `Helm Air Bench` | not in map (only the *suite* keys above are); split on `_` β†’ 3 tokens |
156
+ | `helm_safety` | `Helm Safety` | not in map |
157
+ | `swe_bench_verified` | `Swe Bench Verified` | not in map (only `swe_bench` is) |
158
+ | `cocoabench` | `Cocoabench` | single token |
159
+ | `llm_stats` | `Llm Stats` | not in map |
160
+ | `artificial_analysis_llms` | `Artificial Analysis Llms` | not in map |
161
+
162
+ The **systematic quirk**: `humanizeToken` only uppercases the first character of each token. It does NOT consult the same acronym table that the map encodes (so `mmlu β†’ Mmlu`, `bbh β†’ Bbh`, `gpqa β†’ Gpqa`). This is why suites like `helm_lite` need an explicit map entry β€” without one, fallback would produce `Helm Lite` (already pretty close), but for `mmlu` the fallback produces the visibly-wrong `Mmlu`. The companion table in `benchmark-detail.tsx` (`DISPLAY_TOKEN_OVERRIDES`) DOES know the acronyms β€” but that table is consumed by a different code path.
163
+
164
+ ### Group C β€” Edge cases
165
+
166
+ | Input | Output | Notes |
167
+ |---|---|---|
168
+ | `""` (empty) | `""` | normalize β†’ `""`, no map hit; humanizeToken splits empty β†’ empty array β†’ `[].join(" ")` β†’ `""` |
169
+ | `"_"` | `""` | normalize β†’ `""` (edge `_` stripped), no map hit; humanizeToken splits `_` β†’ `[""]` β†’ filter empty β†’ `[].join(" ")` β†’ `""` |
170
+ | `"___helm___lite___"` | `HELM Lite` | normalize collapses runs of `_` and trims β†’ `helm_lite` β†’ map hit |
171
+ | `"HELM-LITE"` | `HELM Lite` | normalize β†’ `helm_lite` β†’ map hit |
172
+ | `"helm lite"` (2 spaces) | `HELM Lite` | normalize collapses whitespace runs β†’ `helm_lite` |
173
+ | `"a"` | `A` | not in map; humanizeToken β†’ `["a"]` β†’ `["A"]` β†’ `"A"` |
174
+ | `"a-b"` | `A B` | split β†’ `["a","b"]` β†’ `["A","B"]` β†’ `"A B"` |
175
+
176
+ ### Group D β€” Duplicate `getBenchmarkDisplayName` in `lib/eval-processing.ts` (functionally dead, document for completeness)
177
+
178
+ The substring-include rule (10-entry mapping). Documented inputs:
179
+
180
+ | Input | Output | Rule branch |
181
+ |---|---|---|
182
+ | `null` | `Unknown Benchmark` | guard at top of function |
183
+ | `undefined` | `Unknown Benchmark` | guard |
184
+ | `""` | `Unknown Benchmark` | guard (`!name`) |
185
+ | `MMLU` | `Massive Multitask Language Understanding` | substring match on `MMLU` |
186
+ | `mmlu` | `Massive Multitask Language Understanding` | case-insensitive substring |
187
+ | `MMLU-Pro` | `Massive Multitask Language Understanding` | iteration order: `MMLU` matches first; `MMLU-Pro` never reached |
188
+ | `GSM8K` | `Grade School Math 8K` | match |
189
+ | `HumanEval` | `Human Eval (Code)` | match |
190
+ | `MBPP` | `Mostly Basic Python Problems` | match |
191
+ | `HellaSwag` | `HellaSwag (Commonsense)` | match |
192
+ | `ARC` | `AI2 Reasoning Challenge` | match |
193
+ | `TruthfulQA` | `TruthfulQA` | match (key === value) |
194
+ | `BBH` | `Big-Bench Hard` | match |
195
+ | `MATH` | `MATH Dataset` | match |
196
+ | `helm_lite` | `helm_lite` | no match β†’ passthrough |
197
+ | `MMLU Lite something` | `Massive Multitask Language Understanding` | substring match still fires anywhere in the name |
198
+
199
+ Note that this function is **not the active path** in production; it lives inside `groupEvaluationsByBenchmark` which is unreferenced. Tests cover it for completeness so a pipeline implementer porting both functions sees the divergence in behaviour explicitly.
200
+
201
+ ## Current TS implementation
202
+
203
+ | Concern | Location |
204
+ |---|---|
205
+ | Active map (`BENCHMARK_NAMES`, 30 entries) | `lib/model-data.ts:109-140` |
206
+ | Active key-normalizer (`normalizeBenchmarkKeyForLookup`) | `lib/model-data.ts:142-144` |
207
+ | Active tokenize fallback (`humanizeToken`) | `lib/model-data.ts:90-96` |
208
+ | Active function (`getBenchmarkDisplayName`, exported) | `lib/model-data.ts:146-148` |
209
+ | Suite-name companion map (`SUITE_DISPLAY_NAMES`, 30 entries; copy of `BENCHMARK_NAMES`) | `components/benchmark-detail.tsx:101-132` |
210
+ | Suite-name token overrides (`DISPLAY_TOKEN_OVERRIDES`, 26 entries) | `components/benchmark-detail.tsx:134-162` |
211
+ | Suite-name overrides (`DISPLAY_NAME_OVERRIDES`) | `components/benchmark-detail.tsx:164-173` |
212
+ | Suite key normalizer (with fibble/arc-agi regex special-cases) | `components/benchmark-detail.tsx:308-313` |
213
+ | Suite display-name lookup | `components/benchmark-detail.tsx:333-336` |
214
+ | **Duplicate** function (functionally dead) | `lib/eval-processing.ts:861-885` |
215
+
216
+ ### Call sites of the active `getBenchmarkDisplayName` (15 total)
217
+
218
+ | Location | Context |
219
+ |---|---|
220
+ | `lib/model-data.ts:274` | `top_scores` rollup β€” set `benchmark` display name on score entry |
221
+ | `lib/model-data.ts:410` | `benchmark_names` array on developer summary |
222
+ | `lib/model-data.ts:459` | `benchmarkDisplayName` for hierarchy entries |
223
+ | `lib/model-data.ts:485` | `latest_source_name` on category aggregation |
224
+ | `lib/model-data.ts:778` | `composite_benchmark_name` on category-mode aggregation |
225
+ | `lib/model-data.ts:785` | `latest_source_name` (same record) |
226
+ | `lib/model-data.ts:821` | `composite_benchmark_name` on benchmark-mode aggregation |
227
+ | `lib/model-data.ts:828` | `latest_source_name` (same record) |
228
+ | `lib/model-data.ts:903` | `suiteDisplayName` for suite aggregation |
229
+ | `lib/model-data.ts:1056` | `suiteDisplayName` (second aggregator) |
230
+ | `lib/model-data.ts:1362` | model-card rollup A |
231
+ | `lib/model-data.ts:1396` | model-card rollup B |
232
+ | `lib/model-data.ts:1429` | model-card rollup C |
233
+ | `lib/model-data.ts:1470` | model-card rollup D |
234
+ | `lib/duckdb-data.ts:301` | DuckDB backend β€” set `benchmark` on per-model rollup |
235
+
236
+ `normalizeBenchmarkKeyForLookup` itself is also called separately at `lib/model-data.ts:1572` and `:1579` to derive suite-key matches (independent of display-name derivation).
237
+
238
+ ### Call sites of `SUITE_DISPLAY_NAMES`/`normalizeDisplayLabel` (renderer-only)
239
+
240
+ `components/benchmark-detail.tsx` consumes `normalizeDisplayLabel` at ~30 sites for in-render labels (model name, organization, dataset name, source name, run label, subtask label, etc.). The suite-display-name path (`getSuiteDisplayName`) is only called inside this file. None of these are used outside the benchmark detail page; they are presentation-layer helpers that operate on already-emitted strings.
241
+
242
+ ## Pipeline status β€” divergences
243
+
244
+ ### Side-by-side comparison table
245
+
246
+ | Aspect | TS (this spec) | Pipeline today | Result for users |
247
+ |---|---|---|---|
248
+ | Where display name is derived | request time, in 15+ call sites | pipeline emits `benchmark_parent_name`, `benchmark_family_name`, `display_name`, `canonical_display_name` on each eval entry; raw `benchmark` is also present | TS re-derives display name from the key field, ignoring the pipeline's already-canonical `*_name` fields |
249
+ | Key field consumed | `benchmark_parent_key` / `benchmark_family_key` / `benchmark` (mostly key-shaped strings like `helm_lite`) | n/a β€” pipeline emits both keys and names | TS map hit yields canonical name; non-mapped keys fall through to mechanical title-case |
250
+ | Acronym handling | `BENCHMARK_NAMES` map: 30 hand-curated entries; everything else gets `humanizeToken` (only first char per token uppercased) | `display_name` / `canonical_display_name` already encode the canonical capitalization (e.g. `BBH`, `GPQA`, `MMLU`) | TS produces user-visible `Mmlu` / `Bbh` / `Gpqa` for unmapped acronym keys; pipeline's `display_name` would have correct casing |
251
+ | Suite/family rollup labels | `getSuiteDisplayName` in `benchmark-detail.tsx` does its own thing (DISPLAY_TOKEN_OVERRIDES knows acronyms); active `getBenchmarkDisplayName` in `model-data.ts` does NOT consult those overrides | n/a | The two TS paths can produce *different* display names for the same key β€” e.g. for `mmlu`, `getBenchmarkDisplayName` returns `Mmlu` but the suite path returns `MMLU` |
252
+
253
+ ### Concrete worked examples (audit numbers)
254
+
255
+ Audited 2026-04-28 against `.cache/hf-data/eval-list.json` (587 evals) and `.cache/hf-data/model-cards-lite.json` (5,830 cards) by `scripts/verify-benchmark-display-names.mjs`.
256
+
257
+ **Distinct benchmark-key strings in production (eval-list.json):**
258
+
259
+ | Field | Distinct values | mapHit (distinct) | fallback (distinct) | mapHit calls / 587 | fallback calls / 587 |
260
+ |---|---|---|---|---|---|
261
+ | `benchmark` | 544 | 15 (2.8%) | 529 (97.2%) | 20 | 567 |
262
+ | `benchmark_parent_key` | 34 | 25 (73.5%) | 9 (26.5%) | 71 | 516 |
263
+ | `benchmark_family_key` | 34 | 24 (70.6%) | 10 (29.4%) | 65 | 522 |
264
+ | `benchmark_parent_name` | 544 | 15 (2.8%) | 529 (97.2%) | 20 | 567 |
265
+
266
+ **Distinct benchmark fields on model-cards-lite.json (5,830 cards):**
267
+
268
+ | Field | Distinct | mapHit | fallback |
269
+ |---|---|---|---|
270
+ | `card.benchmark_names[]` | 377 | 14 (3.7%) | 363 (96.3%) |
271
+ | `card.top_benchmark_scores[].benchmarkKey` | 339 | 17 (5.0%) | 322 (95.0%) |
272
+ | `card.top_benchmark_scores[].benchmark` | 301 | 13 (4.3%) | 288 (95.7%) |
273
+
274
+ **Notable fallback outputs (visibly-wrong style produced by `humanizeToken`):**
275
+
276
+ | Input | TS-computed |
277
+ |---|---|
278
+ | `BBH` | `BBH` (no-op β€” already first-cap) |
279
+ | `MMLU-PRO` | `MMLU PRO` (loses the dash) |
280
+ | `artificial_analysis_llms` | `Artificial Analysis Llms` (`LLMs` β†’ `Llms`) |
281
+ | `helm_air_bench` | `Helm Air Bench` (`HELM` β†’ `Helm`) |
282
+ | `helm_safety` | `Helm Safety` |
283
+ | `swe_bench_verified` | `Swe Bench Verified` (`SWE` β†’ `Swe`) |
284
+ | `swe_bench_verified_mini` | `Swe Bench Verified Mini` |
285
+ | `multi_swe_bench` | `Multi Swe Bench` |
286
+ | `llm_stats` | `Llm Stats` |
287
+ | `hfopenllm` (family key) | `Hfopenllm` |
288
+ | `ARC-AGI v2` (from `benchmark_names[]`) | `ARC AGI v2` (loses the dash) |
289
+ | `BrowseComp-Plus` | `BrowseComp Plus` (loses the dash) |
290
+
291
+ **TS vs pipeline-emitted display fields (587 evals):**
292
+
293
+ | Comparison | Agree | Disagree | Notes |
294
+ |---|---|---|---|
295
+ | TS(`benchmark_parent_key`) == pipeline `benchmark_parent_name` | 9 | 578 | Pipeline's `*_name` is the per-eval display name (e.g. `BBH`), not the suite roll-up name. They aren't meant to match β€” TS path produces the suite name (`HF Open LLM v2`), pipeline produces the leaf eval name (`BBH`). |
296
+ | TS(`benchmark_family_key`) == pipeline `benchmark_family_name` | 8 | 579 | Same dynamic. |
297
+ | TS(`benchmark`) == pipeline `display_name` | 86 | 501 | Disagreements include `MMLU-PRO` β†’ `MMLU PRO` and the "key vs leaf-eval display" mismatch as above (e.g. `Artificial Analysis LLM API` vs `artificial_analysis.median_output_tokens_per_second`). |
298
+ | TS(`benchmark`) == pipeline `canonical_display_name` | 86 | 501 | Same as `display_name`. |
299
+
300
+ **Divergence summary:**
301
+ - Only ~3% of distinct `benchmark` strings hit the map; ~74% of distinct suite keys (`benchmark_parent_key`) do.
302
+ - For the ~97% of `benchmark` strings that miss, the active TS function passes them through `humanizeToken` which mangles acronyms (`MMLU-PRO` β†’ `MMLU PRO`). For inputs that are already nicely cased (most pipeline-emitted `benchmark` strings are), the fallback can be a strict regression vs the input.
303
+ - The pipeline's `display_name` and `canonical_display_name` already provide leaf-eval display names; the TS function is doing suite-key β†’ suite-display-name work that pipeline does NOT yet emit (no `parent_display_name` field). The `benchmark_parent_name` field exists but holds the *leaf eval name* picked from one child, not the suite display name.
304
+ - The duplicate `getBenchmarkDisplayName` in `eval-processing.ts` is unreachable β€” `groupEvaluationsByBenchmark` has zero importers (verified by ripgrep).
305
+
306
+ Run `scripts/verify-benchmark-display-names.mjs` for the live numbers.
307
+
308
+ ## Notes for pipeline implementer
309
+
310
+ - **Prefer to expose pre-computed `display_name` / `canonical_display_name` on every benchmark/eval entry rather than asking consumers to map keys β†’ names.** Pipeline already does this for ~all entries (verify field coverage with the audit script). The TS layer is essentially defending against the historical case where consumers received only a snake_case key.
311
+ - If the pipeline keeps emitting both keys and names, the migration target is: TS callers read `entry.display_name` (or `benchmark_parent_name`, etc.) directly; the `BENCHMARK_NAMES` map + `getBenchmarkDisplayName` + `humanizeToken` (active) + `SUITE_DISPLAY_NAMES`/`normalizeSuiteKey`/`getSuiteDisplayName` (companion) all delete.
312
+ - The 30 entries in `BENCHMARK_NAMES` (and the parallel 30 in `SUITE_DISPLAY_NAMES`) encode product decisions about how to render the suite-level rollups. If the pipeline does not yet emit a *suite-level* display name (separate from per-eval `display_name`), it should β€” exactly the 30 entries above are the acceptance set.
313
+ - The two regex special-cases in `benchmark-detail.tsx` (`/^fibble\d*_arena$/ β†’ fibble_arena`, `/^arc_agi_v\d+/ β†’ arc_agi`) are normalization-layer rules β€” pipeline should fold versioned variants of these suites into the same canonical key OR the consumer must continue to apply the collapse. Document the chosen approach.
314
+ - The duplicate `getBenchmarkDisplayName` in `lib/eval-processing.ts` should be deleted along with `groupEvaluationsByBenchmark`. It has no live callers.
315
+ - The `benchmark-detail.tsx` file's many `normalizeDisplayLabel` call sites (model name, org name, dataset name, run label, etc.) are a separate concern — they are presentation-only normalization on already-emitted strings, not a key→name lookup. Whether to migrate them upstream is a separate item.
316
+
317
+ Verification: run `scripts/verify-benchmark-display-names.mjs` against pipeline output once it ships. Goal: zero divergence vs TS-as-is across every distinct `benchmark` / `benchmark_parent_key` / `benchmark_family_key` value in the 587-eval cache.
318
+
319
+ ## Migration checklist
320
+
321
+ - [x] Spec written
322
+ - [x] Tests cover each rule branch (`tests/transformations/benchmark-display-names.test.ts`)
323
+ - [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
324
+ - [ ] Pipeline emits `display_name` / `canonical_display_name` (already does on per-eval) PLUS suite-level display name covering the 30 `BENCHMARK_NAMES` entries on every benchmark/eval entry
325
+ - [ ] TS deleted; replace 15 active call sites + 30+ `normalizeDisplayLabel` sites in `benchmark-detail.tsx` with direct field reads. Delete `BENCHMARK_NAMES`, `getBenchmarkDisplayName` (model-data.ts), `humanizeToken`, `normalizeBenchmarkKeyForLookup`, `SUITE_DISPLAY_NAMES`, `DISPLAY_TOKEN_OVERRIDES`, `DISPLAY_NAME_OVERRIDES`, `normalizeSuiteKey`, `getSuiteDisplayName`, `normalizeDisplayLabel`, `normalizeDisplayToken`, plus the duplicate `getBenchmarkDisplayName` + `groupEvaluationsByBenchmark` in `eval-processing.ts`.
326
+
327
+ ## Future product decision (deferred)
328
+
329
+ `BENCHMARK_NAMES` is hand-curated and only covers 30 suite/parent keys; many leaf benchmarks fall through to a fallback that mangles acronyms (`mmlu β†’ Mmlu`, `bbh β†’ Bbh`). Whether to (a) expand the map to cover the long tail, (b) ship pipeline-emitted `display_name` everywhere and delete the map entirely, or (c) take a different approach (HTML-style override file, attribute on the source eval, etc.) is out of scope for this refactor. Document-don't-improve.
notes/transformations/09-metric-display-name-expansion.md ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Metric display name expansion
2
+
3
+ Drafted 2026-04-28. Migration item #10 in `notes/migration-plan.md`.
4
+
5
+ ## Framing reminder
6
+
7
+ We are refactoring for UI efficiency. TS-as-is is the canonical spec. Originally both functions in this spec were claimed to be "defensive scaffolding firing 0 times against current data." That claim was **partially wrong** and is corrected below (verified 2026-04-28).
8
+
9
+ This spec covers two related functions with the same product intent (expand a non-informative metric name by prefixing the benchmark), but they have very different statuses in the current codebase:
10
+
11
+ 1. **`getEvaluationDisplayName`** (`lib/eval-processing.ts:70-86`) β€” **DEAD CODE via orphaned caller chain.** Its callers (`createEvaluationCard` line 537, `groupEvaluationsByBenchmark` line 893) are exported from `lib/eval-processing.ts` but never invoked from `app/`, `components/`, or any other `lib/` file. The function never runs in production. (The earlier audit's "0 fires" was correct in result but incorrect in reasoning β€” it audited `evaluations_by_category` from cache files, which is pipeline-pre-flattened. The function actually consumes the *post-`flattenModelEvaluations`* shape, where bare-generic names ARE present.)
12
+ 2. **`prefersBenchmarkName`** (`lib/model-data.ts:462-470`) β€” **active code path that fires 0 times against current data.** It runs inside `hfEvalEntryToListItem`, which is called from `lib/model-data.ts:1261, 1301` and `lib/duckdb-data.ts:171` β€” all live read paths. None of the 4 heuristic patterns match any of the 587 eval-list entries in production.
13
+
14
+ This split matters because the migration recommendations are different for each. See "Recommended migration path" below.
15
+
16
+ ## Rule (as TS implements it today)
17
+
18
+ ### Rule 1 β€” `getEvaluationDisplayName(evaluation, result)`
19
+
20
+ Computes the display string for one `evaluation_result` row.
21
+
22
+ ```
23
+ benchmarkName = getBenchmarkName(evaluation, result)
24
+ metricName = result.evaluation_name.trim()
25
+
26
+ if metricName === benchmarkName: return metricName // already redundant; show once
27
+ if GENERIC_EVALUATION_NAMES.has(metricName.toLowerCase()):
28
+ return `${benchmarkName} - ${metricName}`
29
+ otherwise: return metricName
30
+ ```
31
+
32
+ `GENERIC_EVALUATION_NAMES` is a 6-entry lowercase-keyed set (`lib/eval-processing.ts:27-34`):
33
+
34
+ | Key |
35
+ |---|
36
+ | `score` |
37
+ | `accuracy` |
38
+ | `mean win rate` |
39
+ | `exact match` |
40
+ | `f1` |
41
+ | `pass@1` |
42
+
43
+ `getBenchmarkName` (`lib/eval-processing.ts:49-68`) resolves the benchmark string with this precedence:
44
+
45
+ 1. `result.source_data.dataset_name` (when source_data is an object, not an array)
46
+ 2. `evaluation.benchmark`
47
+ 3. `evaluation.source_data.dataset_name` (when source_data is an object)
48
+ 4. `result.evaluation_name`
49
+ 5. `evaluation.evaluation_id`
50
+
51
+ ### Rule 2 β€” `prefersBenchmarkName` (inline, `lib/model-data.ts:459-470`)
52
+
53
+ Decides whether to substitute the benchmark display name for an eval-list entry's display string.
54
+
55
+ ```
56
+ benchmarkDisplayName = getBenchmarkDisplayName(entry.benchmark_parent_name || entry.benchmark || "")
57
+ rawDisplayName = entry.evaluation_name || entry.display_name || entry.benchmark_leaf_name || entry.eval_summary_id
58
+ normalized = rawDisplayName.trim().toLowerCase()
59
+
60
+ prefersBenchmarkName = Boolean(benchmarkDisplayName) && (
61
+ normalized.startsWith("accuracy on ") ||
62
+ normalized.startsWith("score on ") ||
63
+ normalized.includes("for scorer") ||
64
+ normalized.includes("model_graded")
65
+ )
66
+
67
+ evaluation_name on output = prefersBenchmarkName ? benchmarkDisplayName : rawDisplayName
68
+ ```
69
+
70
+ Note the asymmetry: the first two checks are `startsWith`, the second two are `includes`. This is faithful to the TS code (not "fixed" here).
71
+
72
+ ## Classification
73
+
74
+ - **Unconditional normalization.** Both rules always run on whatever metric/eval string is present; neither defers to a pre-existing canonical field. Pipeline-side fix: emit the final `display_name` already in expanded form (or leave it as-is for the cases where neither rule fires β€” i.e. all 86,183 production rows today). No consumer should re-derive.
75
+ - **Cleaning β†’ pipeline.** Pure value transform on a single field per record. No aggregation, no joining, no record merging. Migration target: pipeline emits `display_name` already in the form TS would produce; TS deletes both helpers and inlines a direct field read.
76
+
77
+ ## Inputs and expected outputs
78
+
79
+ Each row corresponds to a parameterized test case in `tests/transformations/metric-display-name-expansion.test.ts`.
80
+
81
+ ### Group A β€” `getEvaluationDisplayName`: generic name expansion
82
+
83
+ Input: `(evaluation, result)` synthesized so `getBenchmarkName` resolves to the value in the "benchmark" column.
84
+
85
+ | benchmark | result.evaluation_name | Output | Why |
86
+ |---|---|---|---|
87
+ | `MMLU` | `Accuracy` | `MMLU - Accuracy` | metric `accuracy` is generic β†’ prefix benchmark |
88
+ | `GSM8K` | `accuracy` | `GSM8K - accuracy` | lowercased generic still triggers; output keeps original casing of metric |
89
+ | `MATH` | `EXACT MATCH` | `MATH - EXACT MATCH` | uppercase generic still triggers (set check is `.toLowerCase()`) |
90
+ | `RewardBench` | `Mean Win Rate` | `RewardBench - Mean Win Rate` | "mean win rate" is in the set |
91
+ | `HumanEval` | `pass@1` | `HumanEval - pass@1` | symbol-bearing generic still in the set |
92
+ | `SuperGLUE` | `f1` | `SuperGLUE - f1` | shortest generic |
93
+ | `OpenBookQA` | `Score` | `OpenBookQA - Score` | "score" is generic |
94
+
95
+ ### Group B β€” `getEvaluationDisplayName`: passthrough (non-generic)
96
+
97
+ | benchmark | result.evaluation_name | Output | Why |
98
+ |---|---|---|---|
99
+ | `MMLU` | `MMLU` | `MMLU` | metricName === benchmarkName β†’ return as-is (early return; expansion never considered) |
100
+ | `MMLU` | `BLEU` | `BLEU` | not in generic set β†’ passthrough |
101
+ | `RewardBench` | `Chat Hard` | `Chat Hard` | not generic, distinct from benchmark β†’ passthrough |
102
+ | `MMLU` | `accuracy_strict` | `accuracy_strict` | substring of "accuracy" but not equal β†’ not in set β†’ passthrough |
103
+ | `MMLU` | `Accuracy ` (trailing space) | `MMLU - Accuracy` | `.trim()` on metricName before set lookup β†’ matches |
104
+ | `MMLU` | ` accuracy ` | `MMLU - accuracy` | trim happens to metricName |
105
+
106
+ ### Group C β€” `getEvaluationDisplayName`: `getBenchmarkName` precedence
107
+
108
+ These exercise the resolution chain that feeds the rule.
109
+
110
+ | Setup | Resolved benchmark | Why |
111
+ |---|---|---|
112
+ | `result.source_data = { dataset_name: "RewardBench" }`, `evaluation.benchmark = "reward-bench"` | `RewardBench` | result.source_data.dataset_name wins (precedence #1) |
113
+ | `result.source_data = ["url1", "url2"]` (array), `evaluation.benchmark = "reward-bench"` | `reward-bench` | array source_data is skipped β†’ falls to `evaluation.benchmark` |
114
+ | `result.source_data = undefined`, `evaluation.benchmark = "reward-bench"` | `reward-bench` | evaluation.benchmark (precedence #2) |
115
+ | `result.source_data = undefined`, `evaluation.benchmark = ""`, `evaluation.source_data = { dataset_name: "MMLU" }` | `MMLU` | evaluation.source_data.dataset_name (precedence #3) β€” note empty string is falsy |
116
+ | All sources empty, `result.evaluation_name = "Foo"` | `Foo` | precedence #4 |
117
+ | All empty, `evaluation.evaluation_id = "id-123"` | `id-123` | precedence #5 (final fallback) |
118
+
119
+ ### Group D β€” `prefersBenchmarkName`: heuristic matches
120
+
121
+ For each, the eval-list entry has `benchmark_parent_name = "MMLU"` (so `benchmarkDisplayName` resolves to a non-empty string).
122
+
123
+ | `evaluation_name` (input) | Output `evaluation_name` | Why |
124
+ |---|---|---|
125
+ | `accuracy on subset_humanities` | `MMLU` | `startsWith("accuracy on ")` |
126
+ | `Accuracy On SubsetHumanities` | `MMLU` | normalized to lowercase before startsWith |
127
+ | `score on test_set` | `MMLU` | `startsWith("score on ")` |
128
+ | `xyz for scorer judge_v2` | `MMLU` | `includes("for scorer")` (anywhere in string) |
129
+ | `for scorer xyz at start` | `MMLU` | `includes("for scorer")` matches at start too |
130
+ | `something model_graded thing` | `MMLU` | `includes("model_graded")` (underscore, not space) |
131
+ | `model_graded` | `MMLU` | substring match works on the whole string |
132
+
133
+ ### Group E β€” `prefersBenchmarkName`: passthrough
134
+
135
+ | `evaluation_name` | Output | Why |
136
+ |---|---|---|
137
+ | `MMLU - Accuracy` | `MMLU - Accuracy` | does not start with "accuracy on " (has prefix); no other token matches |
138
+ | `Accuracy` | `Accuracy` | bare "accuracy" doesn't satisfy `startsWith("accuracy on ")` (no " on ") |
139
+ | `accuracy onset` | `accuracy onset` | "accuracy on" with no trailing space; the rule literal is `"accuracy on "` (note trailing space) β€” but "accuracy onset".startsWith("accuracy on ") is **false** because position 11 is "s" not " ". Good β€” no match. |
140
+ | `score onyx` | `score onyx` | `startsWith("score on ")` requires literal trailing space β€” "onyx" fails |
141
+ | `Model Graded Eval` | `Model Graded Eval` | `model_graded` (underscore) does not match "Model Graded" (space) after lowercasing β†’ "model graded eval" does not contain "model_graded" |
142
+ | `accuracy_for_scorer` | `accuracy_for_scorer` | `for scorer` (with space) does not match `for_scorer` after lowercasing β€” "accuracy_for_scorer" does not contain "for scorer" |
143
+ | `Scorer based eval` | `Scorer based eval` | "scorer based eval" does not contain "for scorer" |
144
+ | `BBH` | `BBH` | none of the four conditions match |
145
+
146
+ ### Group F β€” `prefersBenchmarkName`: `benchmarkDisplayName` empty short-circuits
147
+
148
+ | Setup | Output | Why |
149
+ |---|---|---|
150
+ | `benchmark_parent_name = ""`, `benchmark = ""`, `evaluation_name = "accuracy on x"` | `accuracy on x` (raw) | `Boolean(benchmarkDisplayName)` is false β†’ `prefersBenchmarkName = false` β†’ falls back to raw |
151
+
152
+ ### Group G β€” `prefersBenchmarkName`: `rawDisplayName` precedence
153
+
154
+ Order: `entry.evaluation_name` β†’ `entry.display_name` β†’ `entry.benchmark_leaf_name` β†’ `entry.eval_summary_id`.
155
+
156
+ | Setup | rawDisplayName | Why |
157
+ |---|---|---|
158
+ | `evaluation_name = "score on x"` | `score on x` | first non-falsy field |
159
+ | `evaluation_name = ""`, `display_name = "MMLU"` | `MMLU` | empty string is falsy β†’ falls through |
160
+ | All empty except `eval_summary_id = "id_xyz"` | `id_xyz` | final fallback |
161
+
162
+ ## Current TS implementation
163
+
164
+ | Concern | Location |
165
+ |---|---|
166
+ | Generic-names set | `lib/eval-processing.ts:27-34` (`GENERIC_EVALUATION_NAMES`) |
167
+ | Benchmark-name resolver | `lib/eval-processing.ts:49-68` (`getBenchmarkName`) |
168
+ | Per-result expansion | `lib/eval-processing.ts:70-86` (`getEvaluationDisplayName`) |
169
+ | Eval-list-entry heuristic | `lib/model-data.ts:459-470` (`prefersBenchmarkName`, inline; assigns to `evaluation_name` field) |
170
+ | Benchmark display name (used by Rule 2) | `lib/model-data.ts:146-148` (`getBenchmarkDisplayName`) |
171
+
172
+ ### Call sites
173
+
174
+ `getEvaluationDisplayName` (2 call sites, both internal to `lib/eval-processing.ts`):
175
+
176
+ | Location | Context |
177
+ |---|---|
178
+ | `lib/eval-processing.ts:631` | `processModelEvaluations` β€” populates `allScores[].benchmark` for per-model score aggregation |
179
+ | `lib/eval-processing.ts:900` | `groupEvaluationsByBenchmark` β€” populates `BenchmarkEvalSummary.evaluation_name` keyed by `eval_summary_id` |
180
+
181
+ `prefersBenchmarkName` (1 call site, declared inline):
182
+
183
+ | Location | Context |
184
+ |---|---|
185
+ | `lib/model-data.ts:462-470` | `hfEvalEntryToListItem` β€” sets `evaluation_name` on `BenchmarkEvalListItem` for the browse-evals list page |
186
+
187
+ `GENERIC_EVALUATION_NAMES`: only consumed by `getEvaluationDisplayName` itself.
188
+
189
+ ## Pipeline status β€” divergences
190
+
191
+ ### Side-by-side comparison table
192
+
193
+ | Aspect | TS (this spec) | Pipeline today | Result for users |
194
+ |---|---|---|---|
195
+ | Where expansion runs | request time, in 3 call sites total | not implemented as a transform; pipeline emits `display_name` / `evaluation_name` already in their final form | TS expansion logic exists but never fires against current pipeline output |
196
+ | Generic-name detection | runtime check against 6-entry set | n/a β€” no metric in production has a bare-generic `evaluation_name` | no observable difference today |
197
+ | Heuristic prefix detection | runtime regex/substring on 4 patterns | n/a β€” no eval-list entry has the patterns today | no observable difference today |
198
+
199
+ ### Concrete worked example with quantified scope
200
+
201
+ Audited 2026-04-28 against `.cache/hf-data/`:
202
+
203
+ **`getEvaluationDisplayName` against the 5,830 model files (86,183 total `(evaluation, result)` pairs):**
204
+ - `metric === benchmark` early-return: **30,968 (35.9%)** β€” most rows hit this; `evaluation_name` is already identical to the resolved benchmark name, so the function just returns it
205
+ - Generic-name expansion fires: **0 (0.0%)** β€” zero rows have a metric whose lowercased name is in `GENERIC_EVALUATION_NAMES`
206
+ - Passthrough (non-generic, distinct from benchmark): **55,215 (64.1%)**
207
+
208
+ Distribution of generic names hit in production: **empty.** The 6-entry set is dead code against current data.
209
+
210
+ **`prefersBenchmarkName` against the 587 eval-list entries:**
211
+ - `accuracy on …` matches: **0**
212
+ - `score on …` matches: **0**
213
+ - `for scorer` matches: **0**
214
+ - `model_graded` matches: **0**
215
+ - Total entries where heuristic fires: **0 (0.0%)**
216
+
217
+ Both transformations are **defensive scaffolding** β€” preserved for shapes the pipeline used to or could produce, but the current corpus produces neither generic bare-metric names nor heuristic-matching display strings.
218
+
219
+ Verified by `scripts/verify-metric-display-name.mjs`.
220
+
221
+ ## Verified state (2026-04-28)
222
+
223
+ **For `getEvaluationDisplayName`:**
224
+ - Caller chain trace: called from `createEvaluationCard` (`lib/eval-processing.ts:631`) and `groupEvaluationsByBenchmark` (`lib/eval-processing.ts:900`). Both functions are exported from `lib/eval-processing.ts` but **not called from any file in `app/`, `components/`, `scripts/`, or other `lib/`** β€” verified by `grep -r`. The full chain `processEvaluationsToCards β†’ createEvaluationCard β†’ getEvaluationDisplayName` and `processEvaluationsToBenchmarkSummaries β†’ groupEvaluationsByBenchmark β†’ getEvaluationDisplayName` runs only inside the module's exports; no consumer triggers it.
225
+ - Data check: pipeline DOES emit bare-generic `metric_name` in 38,140 of 82,781 metrics in `hierarchy_by_category` (~46%). `flattenModelEvaluations` in `lib/hf-data.ts:1275` propagates this to `evaluation_name` on flattened result rows. So *if* the function were called, the expansion path WOULD fire β€” on 39,831 of 86,183 result rows. But nothing calls it.
226
+ - The earlier audit script (`scripts/verify-metric-display-name.mjs`) walked `models/<id>.json`'s `evaluations_by_category` (pipeline-pre-flattened, specific names) β€” that path doesn't go through `flattenModelEvaluations`, so it correctly reported "0 fires" for that traversal, but it didn't capture that the function would fire on the `hierarchy_by_category` traversal that `flattenModelEvaluations` actually performs.
227
+
228
+ **For `prefersBenchmarkName`:**
229
+ - Caller chain trace: lives inline at `lib/model-data.ts:462-470` inside `hfEvalEntryToListItem`. That function IS actively called: `lib/model-data.ts:1261, 1301` and `lib/duckdb-data.ts:171`. Live read path on browse-evals pages.
230
+ - Data check: across all 587 eval-list entries in `.cache/hf-data/eval-list.json`, **none of the 4 heuristic patterns match** β€” verified directly with a one-liner script (count = 0). The active path runs on every request but never finds a match.
231
+
232
+ ## Recommended migration path
233
+
234
+ Two separate decisions, one per function:
235
+
236
+ ### `getEvaluationDisplayName` (and the orphaned subsystem) β€” delete locally, no pipeline involvement
237
+
238
+ The function and its caller chain are dead code. Safe to delete without any pipeline coordination:
239
+
240
+ - `getEvaluationDisplayName` (`lib/eval-processing.ts:70-86`)
241
+ - `GENERIC_EVALUATION_NAMES` (`lib/eval-processing.ts:27-34`)
242
+ - `getEvaluationSummaryId` (`lib/eval-processing.ts:88-94`) β€” calls `getBenchmarkName`, only used by orphaned chain
243
+ - `createEvaluationCard` (`lib/eval-processing.ts:537+`)
244
+ - `processEvaluationsToCards` (`lib/eval-processing.ts:815`)
245
+ - `processEvaluationsToBenchmarkSummaries` (`lib/eval-processing.ts:1000`)
246
+ - `groupEvaluationsByBenchmark` (`lib/eval-processing.ts:893`)
247
+ - `loadEvaluations` (`lib/eval-processing.ts:788`) β€” only called by the two `processEvaluations*` orphans
248
+ - `getCategoryStats` (`lib/eval-processing.ts:747`) β€” verify via grep before deleting; may also be orphaned
249
+
250
+ Plus the imports of `createEvaluationCard` and `groupEvaluationsByBenchmark` in `lib/model-data.ts:18, 20` (unused imports).
251
+
252
+ **No contract test about bare-generic metric names** β€” pipeline emits them today, the contract would fail. The function was never preventing user-visible bugs because nothing called it. The active UI rendering path uses `metric.display_name` (which IS pipeline-pre-expanded as `"ACE / Score"`, `"RewardBench / Accuracy"`, etc.) β€” that's the field consumers actually read.
253
+
254
+ **Verify-script update:** `scripts/verify-metric-display-name.mjs` is no longer meaningful for this function once it's deleted. Either delete the script or rewrite it to audit the `hierarchy_by_category` traversal (so the spec stays honest about what the function would do if revived).
255
+
256
+ `getBenchmarkName` (`lib/eval-processing.ts:49-68`) has separate consumers and stays.
257
+
258
+ ### `prefersBenchmarkName` β€” delete locally + add contract test
259
+
260
+ This one IS in an active path, fires 0 times, and matches the "implicit safety net β†’ explicit contract test" pattern cleanly:
261
+
262
+ - Delete the inline 9-line block at `lib/model-data.ts:462-470` (replace with direct read of the resolved display string).
263
+ - Add a Tier A contract test in `tests/pipeline-contract.test.ts`:
264
+ - Assertion: no eval-list entry's display string (`evaluation_name || display_name || benchmark_leaf_name || eval_summary_id`) starts with `"accuracy on "` or `"score on "` (case-insensitive).
265
+ - Assertion: no eval-list entry's display string contains `"for scorer"` or `"model_graded"`.
266
+ - If pipeline ever regresses, the contract test fails loudly with the specific eval_summary_id.
267
+
268
+ Pipeline owner is told: "this 4-pattern absence is currently true; if you ever start emitting display strings in those shapes, please coordinate so the contract test is updated alongside the data change."
269
+
270
+ ## Migration checklist
271
+
272
+ - [x] Spec written (corrected 2026-04-28)
273
+ - [x] Tests cover each rule branch (`tests/transformations/metric-display-name-expansion.test.ts`) β€” note these test the function in isolation; they do not assert that the function is reached from any user-visible path.
274
+ - [ ] Verify-script disposition decided (delete `scripts/verify-metric-display-name.mjs` or rewrite to audit the `hierarchy_by_category` traversal that reflects what the function would do if called)
275
+ - [ ] Delete the orphaned subsystem from `lib/eval-processing.ts`: `getEvaluationDisplayName`, `GENERIC_EVALUATION_NAMES`, `getEvaluationSummaryId` (verify orphan status), `createEvaluationCard`, `processEvaluationsToCards`, `processEvaluationsToBenchmarkSummaries`, `groupEvaluationsByBenchmark`, `loadEvaluations`, `getCategoryStats` (verify orphan status). Plus unused imports of `createEvaluationCard` and `groupEvaluationsByBenchmark` in `lib/model-data.ts:18, 20`. Keep `getBenchmarkName` β€” separate consumers.
276
+ - [ ] Delete `prefersBenchmarkName` block at `lib/model-data.ts:462-470` and replace with direct read of resolved display string.
277
+ - [ ] Add Tier A contract test in `tests/pipeline-contract.test.ts`: no eval-list display string matches the 4 heuristic patterns (`startsWith("accuracy on ")`, `startsWith("score on ")`, `includes("for scorer")`, `includes("model_graded")`).
278
+ - [ ] Notify pipeline owner: 4-pattern absence is currently true; coordinate before changing eval-list display string emission.
279
+
280
+ ## Future product decision (deferred)
281
+
282
+ The defensive scaffolding only ever mattered for upstream data shapes that the pipeline no longer produces. If product wants to expand the generic-name set (e.g., add `"recall"`, `"precision"`, `"bleu"`) or the heuristic patterns (e.g., add `"judged by"`, `"with prompt"`), that's a separate decision; this spec just locks in TS-as-is.
notes/transformations/10-params-parsing.md ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Params billions parsing
2
+
3
+ Drafted 2026-04-28. Migration item #12 in `notes/migration-plan.md`.
4
+
5
+ ## Framing reminder
6
+
7
+ We are refactoring for UI efficiency. TS-as-is is the canonical spec. Five separate parameter-count parsers exist across the app, with subtly different unit grammars, fallback chains, and anchoring. They produce the same answer for the most common production inputs (clean `"7B"` / `"34.389"` style strings) but diverge sharply on edge cases. The migration target: emit a single canonical `params_billions` (numeric, billions) upstream so all five parsers can be deleted.
8
+
9
+ ## Rule (as TS implements it today β€” five variants)
10
+
11
+ Five independent code paths convert a free-form parameter-count token into a billions-of-parameters number.
12
+
13
+ ### Variant A β€” `lib/model-data.ts:312-354` (`parseParamsBillions`)
14
+
15
+ ```ts
16
+ function parseParamsBillions(value: unknown): number | null {
17
+ if (typeof value === "number") {
18
+ return Number.isFinite(value) && value > 0 ? value : null
19
+ }
20
+ if (typeof value !== "string") return null
21
+
22
+ const normalized = value.trim().toLowerCase()
23
+ if (!normalized) return null
24
+
25
+ const compact = normalized.replace(/,/g, "")
26
+ const tokenMatch = compact.match(/(\d+(?:\.\d+)?)\s*(trillion|tn|t|billion|bn|b|million|mn|m|thousand|k)\b/)
27
+ if (tokenMatch) {
28
+ const amount = Number.parseFloat(tokenMatch[1])
29
+ if (!Number.isFinite(amount) || amount <= 0) return null
30
+ const unit = tokenMatch[2]
31
+ if (unit === "trillion" || unit === "tn" || unit === "t") return amount * 1000
32
+ if (unit === "billion" || unit === "bn" || unit === "b") return amount
33
+ if (unit === "million" || unit === "mn" || unit === "m") return amount / 1000
34
+ if (unit === "thousand" || unit === "k") return amount / 1_000_000
35
+ }
36
+
37
+ const numeric = Number.parseFloat(compact)
38
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : null
39
+ }
40
+ ```
41
+
42
+ - Polymorphic input (`unknown`); accepts `number` directly (positive only).
43
+ - For strings: lowercases, strips commas, then scans for `<number><unit>` where unit ∈ {trillion, tn, t, billion, bn, b, million, mn, m, thousand, k}.
44
+ - Unit table converts to billions; `t` β†’ Γ—1000, `b` β†’ as-is, `m` β†’ Γ·1000, `k` β†’ Γ·1_000_000.
45
+ - Falls back to `parseFloat` of the whole string (assumed to be billions). **Positive-only**: rejects 0 and negatives at both branches.
46
+
47
+ Used by: `lib/model-data.ts:409` (`parseParamsBillions(entry.params_billions)` in `hfModelCardToEvaluationCardData`). Sole caller. Input is `entry.params_billions` from `model-cards.json`, which in production is always `number | null` (see audit) β€” so only the `typeof value === "number"` branch ever fires.
48
+
49
+ ### Variant B β€” `components/eval-detail.tsx:81-119` (`parseParamsBillionsFromText`)
50
+
51
+ ```ts
52
+ function parseParamsBillionsFromText(value: string | null | undefined) {
53
+ if (!value) return null
54
+ const normalized = value.trim().toLowerCase()
55
+ if (!normalized) return null
56
+
57
+ const compact = normalized.replace(/,/g, "")
58
+ const tokenMatch = compact.match(/(\d+(?:\.\d+)?)\s*(trillion|tn|t|billion|bn|b|million|mn|m|thousand|k)\b/)
59
+ if (tokenMatch) {
60
+ const amount = Number.parseFloat(tokenMatch[1])
61
+ if (!Number.isFinite(amount)) return null // ← NO `<= 0` check (differs from A)
62
+ /* same unit table as Variant A */
63
+ }
64
+ const numeric = Number.parseFloat(compact)
65
+ return Number.isFinite(numeric) ? numeric : null // ← NO `> 0` check (differs from A)
66
+ }
67
+ ```
68
+
69
+ - Same regex + unit table as A.
70
+ - **Differs from A** on two checks: A rejects `amount <= 0` and `numeric <= 0`; B accepts `0`, negatives, and any finite number. So `"0B"` β†’ `0` here, `null` in Variant A. The `"-5"` parseFloat fallback returns `-5` in B but `null` in A. (For `"-5B"` both return `5`, since the regex's `\d+` matches the `5` substring and the leading minus is silently dropped.)
71
+ - Used by `getParamsBillionsFromModelInfo` (Variant D) for `additional_details.params_billions` (string in production, see audit) and `model_info.parameter_count` strings.
72
+
73
+ ### Variant C β€” `components/eval-detail.tsx:121-155` (`parseParamsBillionsFromText`'s sibling, `parseParamsBillionsFromModelName`)
74
+
75
+ ```ts
76
+ function parseParamsBillionsFromModelName(modelName: string | null | undefined) {
77
+ if (!modelName) return null
78
+ const sizeTokens = Array.from(modelName.matchAll(/\b(\d+(?:\.\d+)?)\s*([tmbk])\b/gi))
79
+ if (sizeTokens.length === 0) return null
80
+
81
+ const lastToken = sizeTokens[sizeTokens.length - 1]
82
+ const numericValue = Number.parseFloat(lastToken[1])
83
+ if (!Number.isFinite(numericValue)) return null
84
+
85
+ const unit = lastToken[2].toLowerCase()
86
+ if (unit === "t") return numericValue * 1000
87
+ if (unit === "b") return numericValue
88
+ if (unit === "m") return numericValue / 1000
89
+ if (unit === "k") return numericValue / 1_000_000
90
+ return null
91
+ }
92
+ ```
93
+
94
+ - Word-boundary match (`\b...\b`) on **single-letter unit only** (`t|m|b|k`, case-insensitive).
95
+ - Picks the **last** matching token in the string (e.g. `"Llama-3-70B-Instruct-8K"` β†’ matches `70B` and `8K` β†’ returns last (`8K` = 0.000008B)). This is **a known TS quirk**: model names containing context-window suffixes (`8K`, `32K`, `128K`) cause the parser to return the context-window size instead of the parameter count.
96
+ - Used by `getParamsBillionsFromModelInfo` (Variant D) as final fallback when `additional_details.params_billions` and `parameter_count` are both absent/unparseable.
97
+
98
+ ### Variant D β€” `components/eval-detail.tsx:157-184` (`getParamsBillionsFromModelInfo`)
99
+
100
+ Composite orchestrator (not a parser itself). Order:
101
+
102
+ 1. `additional_details.params_billions` ?? `additional_details.parameter_count` ?? `additional_details.num_parameters` ?? `additional_details.params`
103
+ - if `number` β†’ return as-is (no validity check; could be negative or non-finite)
104
+ - if `string` β†’ `parseParamsBillionsFromText` (Variant B)
105
+ 2. else if `modelInfo.parameter_count` is `string` β†’ `parseParamsBillionsFromText` (Variant B)
106
+ 3. else β†’ `parseParamsBillionsFromModelName(modelInfo.name)` (Variant C)
107
+
108
+ Used at: `components/eval-detail.tsx:350, 1253, 1368` (paramsBillions cell in eval-detail tables, leaderboard sort filtering, "any model has params" header check).
109
+
110
+ ### Variant E β€” `components/model-compare-dialog.tsx:44-60` (`parseParamsBillionsFromModelName`)
111
+
112
+ ```ts
113
+ function parseParamsBillionsFromModelName(modelName: string | null | undefined) {
114
+ if (!modelName) return null
115
+ const sizeTokens = Array.from(modelName.matchAll(/\b(\d+(?:\.\d+)?)\s*([bm])\b/gi))
116
+ if (sizeTokens.length === 0) return null
117
+
118
+ const lastToken = sizeTokens[sizeTokens.length - 1]
119
+ const numericValue = Number(lastToken[1])
120
+ if (!Number.isFinite(numericValue)) return null
121
+
122
+ const unit = lastToken[2].toLowerCase()
123
+ if (unit === "b") return numericValue
124
+ if (unit === "m") return numericValue / 1000
125
+ return null
126
+ }
127
+ ```
128
+
129
+ - Same shape as Variant C, but unit set is **only `b|m`** (no `t`, no `k`) and uses `Number()` instead of `parseFloat`.
130
+ - Used by `formatParamsBillions(value, modelName)` only when the explicit numeric `value` is null/NaN β€” so it's the fallback parser for the compare-dialog header label.
131
+
132
+ ### Variant F β€” `app/evals/[id]/page.tsx:434-437` (inline regex)
133
+
134
+ ```ts
135
+ const sizeMatch = (data.name + " " + id).match(/\b(\d+(?:\.\d+)?)\s*[bB]\b/)
136
+ if (sizeMatch) sizeB = parseFloat(sizeMatch[1])
137
+ ```
138
+
139
+ - One-shot regex against the **concatenation of `name + " " + id`** (not just name).
140
+ - Unit set: **only `b|B`**. No multi-unit support, no fallback.
141
+ - `match()` (not `matchAll()`) β†’ returns **first** match (Variants C and E pick the **last**). For a name like `"Llama-3-8B-70B-Instruct"` Variant F returns `8`, Variant C returns `70`.
142
+ - Used at the matrix-leaderboard sizeB filter (`m.sizeB`, lines 472-473) for the params-range slider.
143
+
144
+ ## Classification
145
+
146
+ - **Cleaning / standardization β†’ pipeline.** Pure value transform on a single field per row. The product decision being encoded β€” "express parameter count in billions" β€” is a per-record canonicalization that belongs upstream. Pipeline-side fix: emit a single numeric `params_billions` (in billions) on every model record; consumers stop parsing.
147
+ - **Unconditional normalization.** Each variant runs unconditionally over its source fields; none defer to a pre-existing canonical numeric (because none exists at the per-result level today). Pipeline-side fix is to emit the canonical value before consumers see the row, not to gate normalization on its absence.
148
+
149
+ (One nuance: Variant D's *fallback chain* β€” try `additional_details.params_billions`, then `parameter_count`, then `name` β€” is itself a small bit of reshape logic. After pipeline emits a canonical numeric, the chain collapses to a single field read.)
150
+
151
+ ## Inputs and expected outputs
152
+
153
+ Each table below describes ONE variant.
154
+
155
+ ### Group A β€” Variant A (`parseParamsBillions`, lib/model-data.ts)
156
+
157
+ | Input | Output | Path |
158
+ |---|---|---|
159
+ | `7` (number) | `7` | number, finite, > 0 β†’ return as-is |
160
+ | `0` (number) | `null` | number, > 0 fails β†’ null |
161
+ | `-3` (number) | `null` | number, > 0 fails β†’ null |
162
+ | `NaN` | `null` | number, finite fails β†’ null |
163
+ | `null` / `undefined` / `[]` | `null` | not number, not string β†’ null |
164
+ | `"7B"` | `7` | regex matches β†’ unit `b` β†’ 7 |
165
+ | `"7b"` | `7` | lowercased; same |
166
+ | `"70B params"` | `70` | regex matches at start, `b` β†’ 70 |
167
+ | `"1.5B"` | `1.5` | float supported |
168
+ | `"405b"` | `405` | lowercased |
169
+ | `"7 billion"` | `7` | full word `billion` β†’ 7 |
170
+ | `"7bn"` | `7` | `bn` alias |
171
+ | `"1.2T"` | `1200` | `t` β†’ Γ—1000 |
172
+ | `"2 trillion"` | `2000` | `trillion` β†’ Γ—1000 |
173
+ | `"2T params"` | `2000` | regex stops at `t\b`; trailing text ignored |
174
+ | `"560M"` | `0.56` | `m` β†’ Γ·1000 |
175
+ | `"560 million"` | `0.56` | `million` β†’ Γ·1000 |
176
+ | `"1000K"` | `0.001` | `k` β†’ Γ·1_000_000 |
177
+ | `"1,500B"` | `1500` | comma stripped |
178
+ | `"34.389"` | `34.389` | no unit token β†’ parseFloat fallback |
179
+ | `"7 B"` (double space) | `7` | regex `\s*` matches |
180
+ | `"abc"` | `null` | no match, parseFloat NaN β†’ null |
181
+ | `""` | `null` | trim β†’ "" β†’ early return |
182
+ | `" "` | `null` | trim β†’ "" β†’ early return |
183
+ | `"0B"` | `null` | regex matches, amount=0, `<= 0` reject β†’ null |
184
+ | `"3.5tn"` | `3500` | `tn` alias |
185
+ | `"7Banana"` | `7` | regex `b\b` fails (no boundary after `b`); falls to `parseFloat("7banana")` = `7` β†’ returns 7. **TS quirk: trailing junk allowed in parseFloat fallback** |
186
+ | `"-5B"` | `5` | regex `\d+` doesn't include `-`, but matches the `5b` substring β†’ amount=5; `>0` passes β†’ returns `5`. **TS quirk: leading minus is silently dropped** |
187
+
188
+ ### Group B β€” Variant B (`parseParamsBillionsFromText`, eval-detail.tsx)
189
+
190
+ Same as A except:
191
+
192
+ | Input | A | B | Why |
193
+ |---|---|---|---|
194
+ | `"0B"` | `null` | `0` | B has no `<= 0` reject |
195
+ | `"-5"` (string) | `null` | `-5` | B has no `> 0` reject on parseFloat fallback |
196
+ | `"-5B"` (string) | `5` | `5` | both: regex matches the `5b` substring β†’ amount=5 (TS quirk: leading minus dropped silently) |
197
+ | `"NaN"` (string) | `null` | `null` | parseFloat("nan") = NaN, `isFinite` false β†’ null in both |
198
+ | number input | passes-through (with `>0` check) | n/a (B rejects non-strings) | A is polymorphic; B is string-only |
199
+
200
+ All other rows in Group A apply to B identically (string-input rows only).
201
+
202
+ ### Group C β€” Variant C (`parseParamsBillionsFromModelName`, eval-detail.tsx)
203
+
204
+ | Input | Output | Path |
205
+ |---|---|---|
206
+ | `"Llama-3-70B-Instruct"` | `70` | matchAll finds `70B`, last token, `b` β†’ 70 |
207
+ | `"Llama-3-8B-Instruct-8K"` | `0.000008` | matchAll finds `8B` and `8K`; **last** token is `8K` β†’ Γ·1_000_000 β†’ 0.000008. **TS quirk** |
208
+ | `"Llama-3-70B-Instruct-32K"` | `0.000032` | last token is `32K` β†’ 0.000032. **TS quirk: context-window beats param count** |
209
+ | `"Mixtral-8x7B"` | `null` | regex needs `\b` before the `\d`; `8x7b` has no boundary between `x` and `7`, so no token matches. **TS quirk: MoE-style names parse to null** |
210
+ | `"Phi-3.5-mini-3.8B"` | `3.8` | matches `3.8B` |
211
+ | `"560M"` | `0.56` | `m` token β†’ Γ·1000 |
212
+ | `"GPT-4"` | `null` | no `\b\d+[tmbk]\b` token |
213
+ | `"Yi-1.5-34B-32K"` | `0.000032` | last token `32K` (context window!) β†’ 0.000032 |
214
+ | `"Qwen2-7B-Instruct"` | `7` | last token `7B` |
215
+ | `"7 billion"` | `null` | regex requires single-letter unit; `billion` has no `\bb\b` since it's word-internal |
216
+ | `"1.2T"` | `1200` | `t` β†’ Γ—1000 |
217
+ | `""` / `null` / `undefined` | `null` | early return |
218
+
219
+ ### Group D (1) β€” Variant D (`getParamsBillionsFromModelInfo`, orchestrator)
220
+
221
+ Behavior depends on which field is populated:
222
+
223
+ | modelInfo state | Result |
224
+ |---|---|
225
+ | `additional_details.params_billions` is `number` 7 | `7` (returned as-is, no validation) |
226
+ | `additional_details.params_billions` is `number` -3 | `-3` (no validity check; passed through) |
227
+ | `additional_details.params_billions` is `string "7.242"` | `7.242` (Variant B parseFloat fallback) |
228
+ | `additional_details.params_billions` is `string "7B"` | `7` (Variant B regex) |
229
+ | `additional_details.params_billions` absent, `additional_details.parameter_count` is `"34.389"` | `34.389` (Variant B) |
230
+ | `additional_details` absent, `modelInfo.parameter_count` is `"7B"` | `7` (Variant B) |
231
+ | All `additional_details.*` and `parameter_count` absent, `modelInfo.name` is `"Llama-3-70B-Instruct"` | `70` (Variant C) |
232
+ | All absent, `modelInfo.name` is `"Llama-3-8B-Instruct-8K"` | `0.000008` (Variant C TS quirk) |
233
+
234
+ ### Group E β€” Variant E (`parseParamsBillionsFromModelName`, model-compare-dialog.tsx)
235
+
236
+ Same as Variant C but rejects `t` and `k`:
237
+
238
+ | Input | C | E | Why |
239
+ |---|---|---|---|
240
+ | `"Llama-3-70B-Instruct"` | `70` | `70` | both β€” `b` token |
241
+ | `"Llama-3-8B-8K"` | `0.000008` | `8` | E ignores `K` β†’ last `b|m` token is `8B` |
242
+ | `"Yi-1.5-34B-32K"` | `0.000032` | `34` | E correctly returns 34 (TS quirk: E is *more correct* on names with context-window suffixes!) |
243
+ | `"1.2T"` | `1200` | `null` | E doesn't accept `t` |
244
+ | `"Mixtral-8x7B"` | `null` | `null` | both β€” `8x7b` has no `\b` before the digit |
245
+ | `"560M"` | `0.56` | `0.56` | both |
246
+ | `"7Banana"` | `null` | `null` | both β€” regex requires `\b` boundary |
247
+
248
+ ### Group F β€” Variant F (`(name + " " + id).match(/\b(\d+(?:\.\d+)?)\s*[bB]\b/)`, app/evals/[id]/page.tsx)
249
+
250
+ | Input (`name + " " + id`) | Output | Path |
251
+ |---|---|---|
252
+ | `"Llama-3-70B meta/llama-3-70b"` | `70` | first `70B` matches |
253
+ | `"Llama-3-70B-Instruct-8K meta/llama-3-70b-instruct"` | `70` | first match wins; `8K` not a `b\|B` so ignored entirely |
254
+ | `"Yi-1.5-34B-32K 01-ai/yi-1-5-34b-32k"` | `34` | first match `34B` |
255
+ | `"GPT-4 openai/gpt-4"` | `null` | no `b\|B` token |
256
+ | `"Mixtral-8x7B mistralai/mixtral-8x7b"` | `null` | `8x` blocks word-boundary; no `\b\d` anchor; no match |
257
+ | `"560M openai/foo-560m"` | `null` | F only accepts `b\|B` |
258
+ | `"1.5B-instruct meta/foo-1-5b"` | `1.5` | first `1.5B` |
259
+ | `"" + " " + ""` | `null` | empty β†’ no match |
260
+
261
+ **Cross-variant ordering quirk:** Variants C and E pick the *last* size-token; Variant F picks the *first*. For ambiguous names the answer can differ; in practice production names tend to have one numeric+unit token so this rarely matters.
262
+
263
+ ### Group G β€” Cross-variant divergence
264
+
265
+ For the same input, the variants produce different outputs. In production, format consistency keeps disagreement narrow but real:
266
+
267
+ For inputs that aren't model names (free-text params strings), A and B are the relevant variants:
268
+
269
+ | Input | A | B |
270
+ |---|---|---|
271
+ | `"7B"` | `7` | `7` |
272
+ | `"7"` (string) | `7` (parseFloat fallback) | `7` |
273
+ | `7` (number) | `7` | n/a (B rejects non-strings) |
274
+ | `0` (number) | `null` (>0 reject) | n/a |
275
+ | `NaN` (number) | `null` | n/a |
276
+ | `"0B"` | `null` (rejects amount ≀0) | `0` |
277
+ | `"-5"` (string) | `null` (>0 reject on parseFloat fallback) | `-5` |
278
+ | `"-5B"` (string) | `5` | `5` (regex matches `5b`; minus dropped β€” both variants) |
279
+ | `"7Banana"` | `7` | `7` (parseFloat lenient β€” both variants) |
280
+
281
+ For inputs that ARE model names (the C/E/F input domain), all five variants can be applied:
282
+
283
+ | Input | A | B | C | E | F (`name + " " + id`) |
284
+ |---|---|---|---|---|---|
285
+ | `"Llama-3-70B-Instruct"` | `70` (first regex match) | `70` | `70` (last token = `70B`) | `70` | `70` (first match) |
286
+ | `"Llama-3-70B-Instruct-8K"` | `70` (`match()` returns first; first `(\d+)(unit)` is `70b`) | `70` (same regex as A) | `0.000008` (C's `matchAll` β†’ last token = `8K`, Γ·1_000_000 β†’ 0.000008) | `8` (E ignores `K`; last `b\|m` token = `8B`) | `70` (F is `[bB]` only; first match = `70B`) |
287
+ | `"Yi-1.5-34B-32K"` | `34` | `34` | `0.000032` (last token `32K`) | `34` | `34` |
288
+ | `"Mixtral-8x7B"` | `7` (A's regex has no leading `\b` β€” `\d+` can match anywhere, including right after `x` β†’ matches `7b` β†’ 7) | `7` (same as A) | `null` (C's regex starts with `\b\d` β€” no `\b` between `x` and `7` β†’ no match) | `null` | `null` |
289
+ | `"560M"` | `0.56` | `0.56` | `0.56` | `0.56` | `null` (F is B-only) |
290
+ | `"1.2T"` | `1200` | `1200` | `1200` | `null` (E is `b\|m`-only) | `null` (F is B-only) |
291
+ | `"7 billion"` | `7` | `7` | `null` (C requires single-letter unit only; `billion` doesn't match the `[tmbk]` class) | `null` | `null` |
292
+
293
+ **This is not a bug to fix in the migration.** It's evidence that the parsers were written with subtly different assumptions about input shape (model-name vs free-text vs trusted-numeric). The pipeline-canonical fix collapses all five into a single field read.
294
+
295
+ ## Current TS implementation
296
+
297
+ | Variant | Function | Location | Callers | Source field |
298
+ |---|---|---|---|---|
299
+ | A | `parseParamsBillions` | `lib/model-data.ts:312-354` | 1 site: `lib/model-data.ts:409` | `entry.params_billions` from `model-cards.json` (number\|null in prod) |
300
+ | B | `parseParamsBillionsFromText` | `components/eval-detail.tsx:81-119` | 2 sites inside Variant D: `eval-detail.tsx:170, 177` | strings from `additional_details.params_billions / parameter_count / num_parameters / params` and `modelInfo.parameter_count` |
301
+ | C | `parseParamsBillionsFromModelName` | `components/eval-detail.tsx:121-155` | 1 site inside Variant D: `eval-detail.tsx:183` | `modelInfo.name` |
302
+ | D | `getParamsBillionsFromModelInfo` (orchestrator) | `components/eval-detail.tsx:157-184` | 3 sites: `eval-detail.tsx:350, 1253, 1368` | `ModelResultForBenchmark["model_info"]` (built per-result by `lib/hf-data.ts:1133` `buildModelInfoForVariant`) |
303
+ | E | `parseParamsBillionsFromModelName` (compare-dialog) | `components/model-compare-dialog.tsx:44-60` | 1 site: `model-compare-dialog.tsx:64` (`formatParamsBillions` fallback) | model display name in compare dialog |
304
+ | F | inline regex | `app/evals/[id]/page.tsx:434-437` | 1 site (inline use): lines 472-473 (sizeB filter for slider) | `data.name + " " + id` from per-row matrix-leaderboard model entries |
305
+
306
+ Total: 5 distinct parsers + 1 orchestrator + 8 caller sites across 4 files.
307
+
308
+ ## Pipeline status β€” divergences
309
+
310
+ ### Side-by-side comparison table
311
+
312
+ | Aspect | TS (this spec) | Pipeline today | Result for users |
313
+ |---|---|---|---|
314
+ | Where canonicalization runs | request time, in 5 functions | `model-cards.json.params_billions` is already a clean number for 87% of cards (5072/5830); per-result `additional_details.params_billions` is a *string* in ~58% of model files | TS parses on every render |
315
+ | Output format | varies per variant: number-of-billions; some return 0/negative, some null on edge | model-card level: clean float in billions; per-result: string requiring downstream parse | mostly converges in production but the per-result string parsing is unnecessary work |
316
+ | Coverage | Variants C/E/F regex-fallback fires when no `additional_details` data exists | model-cards: 13% (758/5830) have `params_billions=null` (no data, irrespective of parser) | "Not reported" appears for 13% of cards regardless of parser correctness |
317
+
318
+ ### Concrete worked example with quantified scope
319
+
320
+ Audited 2026-04-28 against `.cache/hf-data/` (5,830 model-cards, 5,830 model files, **86,183 model_result rows**):
321
+
322
+ - **`model-cards.json` (top-level, drives Variant A)**: 5,830 entries.
323
+ - `params_billions` is `number`: 5,072 (87.0%) β€” Variant A returns positive value
324
+ - `params_billions` is `null`: 758 (13.0%) β€” Variant A returns null
325
+ - `params_billions` is string: **0** β†’ Variant A's string-parsing branches are entirely dead code in production
326
+ - **Per-row `model_info.additional_details.params_billions` (drives Variants B β†’ D fallback)**:
327
+ - undefined: 58,822 (68.3%) β€” D falls through to model-name fallback
328
+ - string: 27,361 (31.7%) β€” virtually all `cleanDecimal` shape (e.g. `"7.242"`, `"34.389"`); 18 rows are `"-1.0"` (negative sentinel β€” Variant B accepts it as `-1`, A would reject)
329
+ - number: 0
330
+ - **Variant D resolution path counts** (out of 86,183 rows):
331
+ - `addPbString` (additional_details.params_billions string β†’ B): 27,361 (31.7%)
332
+ - `modelNameFallback` (Variant C from name): 18,174 (21.1%)
333
+ - **`noResolution`** (D returns null): 40,648 (47.2%) β€” these rows display "Not reported"
334
+ - **Model name format distribution** (drives Variants C/E/F):
335
+ - `hasBOnly` (single B-token, no context-window): 36,286 (42.1%) β€” happy path; all parsers converge
336
+ - `hasBAndContextWindow` (e.g. `Yi-1.5-34B-32K`): **526 (0.61%)** β€” these hit Variant C's TS quirk
337
+ - `hasMOnly` (e.g. `d-SmolLM2-360M`): 417 (0.48%)
338
+ - `hasMoEPattern` (e.g. `WizardLM-2-8x22B`): 1,173 (1.36%) β€” A/B parse via no-leading-`\b` regex; C/E/F return null
339
+ - `noUnitToken` (e.g. `Yi Large Preview`, `GPT-4`): 47,781 (55.4%) β€” no parser matches
340
+ - `hasBAndT`: 0
341
+ - **Cross-variant agreement on names** (A/B/C/E/F applied to the model name string):
342
+ - All five converge: 81,442 (94.50%)
343
+ - **Variant C TS-quirk hit (context-window beats param count)**: **472 rows (0.55%)** β€” only C is wrong; A/B/E/F all return correct param count
344
+ - F-only-missing (F returns null because no B-token but others find m/k/t): 3,001 (3.48%)
345
+ - Other disagreement: 1,268 (1.47%)
346
+
347
+ **Top quirk in production**: Variant C returns the context-window size (`32K β†’ 0.000032B`, `16K β†’ 0.000016B`, `8K β†’ 0.000008B`) instead of the parameter count for **472 model_result rows** with names like `Yi-1.5-34B-32K`, `Yi-1.5-34B-Chat-16K`. Variant D's fallback chain only reaches Variant C (the model-name parser) when `additional_details.params_billions` is missing, so the user-visible impact is bounded β€” but those 472 rows render with a `~0B` parameter count on the eval-detail leaderboard, well below the params-range filter floor.
348
+
349
+ Verified by `scripts/verify-params-parsing.mjs`.
350
+
351
+ ## Notes for pipeline implementer
352
+
353
+ - **Recommended canonical field: `params_billions: float | null`** at the *per-result* level (pipeline emits it on every `model_result.model_info`, not just at the top-level model card).
354
+ - Eliminate the multi-field fallback chain in Variant D by emitting the resolved value in one canonical place. Existing fields (`additional_details.params_billions`, `additional_details.parameter_count`, `additional_details.num_parameters`, `additional_details.params`, `modelInfo.parameter_count`) can stay for compatibility but consumers stop reading them.
355
+ - The model-name regex fallback (Variants C/E/F) is the *only* path that fires when `additional_details` is missing. Pipeline should attempt to parse from name **once** upstream (with whatever quirks it chooses; default to the Variant E semantics β€” `b|m` only β€” to avoid the context-window false-positive) and emit the result. Document the upstream parser's choice clearly so this spec can be retired.
356
+ - The "params_billions in millions vs billions" unit is implicit; recommend keeping the field name `params_billions` to avoid breaking changes, and storing the value in **billions** as today.
357
+ - Once pipeline emits per-result `params_billions`:
358
+ - Variant D collapses to a single field read.
359
+ - Variants A/B/C/E/F all become deletable.
360
+ - Variant F's `name + " " + id` regex disappears with the rest.
361
+
362
+ Verification: once pipeline ships per-result `params_billions`, run `scripts/verify-params-parsing.mjs` and confirm the regex-fallback ("name-derived") row count drops to 0.
363
+
364
+ ## Migration checklist
365
+
366
+ - [x] Spec written
367
+ - [x] Tests cover each variant's semantics + cross-variant divergence (`tests/transformations/params-parsing.test.ts`)
368
+ - [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
369
+ - [ ] Pipeline emits per-result `params_billions` numeric (in billions) across the full corpus
370
+ - [ ] TS deleted; replace 5 functions + orchestrator + 8 callers with a single field read. Files: `lib/model-data.ts`, `components/eval-detail.tsx`, `components/model-compare-dialog.tsx`, `app/evals/[id]/page.tsx`.
371
+
372
+ ## Future product decision (deferred)
373
+
374
+ The Variant C "context-window suffix beats parameter count" quirk (`"Llama-3-8B-8K"` β†’ 0.000008B) is a real bug. We're choosing to fix-by-canonicalization-upstream rather than fix-in-place. Whether the pipeline parser should match Variant C, Variant E (which avoids the quirk by ignoring `K`/`T`), or implement a smarter "prefer the larger token" heuristic is a separate design decision for the pipeline owner.
375
+
376
+ The Variant A `<= 0` rejection (treats `"0B"` as missing data) versus Variant B passthrough (`"0B"` β†’ `0`) is another deferred decision. Production never emits `0`-valued params, so this also doesn't manifest today.
notes/transformations/11-benchmark-card-attachment.md ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Benchmark-card attachment (per-eval lookup join)
2
+
3
+ Drafted 2026-04-28. Migration item #17 in `notes/migration-plan.md`.
4
+
5
+ ## Framing reminder
6
+
7
+ We are refactoring for UI efficiency. TS-as-is is the canonical spec. The retry-loop iteration over candidate names is **load-bearing** in production: 85% of evals (499/587) reach this code path because the pipeline does not inline `benchmark_card` for them. Don't try to "fix" the candidate-name derivation or the dedup-on-first-name-collision behavior in `getMap()` β€” preserve them until pipeline always inlines `benchmark_card` upstream.
8
+
9
+ ## Rule (as TS implements it today)
10
+
11
+ For each `BenchmarkEvalSummary` (detail page) or `BenchmarkEvalListItem` (list page), if `benchmark_card` is not already populated on the record, derive an ordered list of candidate names from the eval and try each one against a deduped `Map<string, BenchmarkCard>`. The first match wins; first hit attaches the card via spread (`{ ...summary, benchmark_card: card }`); no match leaves the record unchanged (passthrough).
12
+
13
+ The lookup is composed from three pieces:
14
+
15
+ 1. **Map build** (`lib/benchmark-metadata.ts:11-28`, `readPipelineBenchmarkCards`)
16
+ - Source: `benchmark-metadata.json` (Record<string, BenchmarkCard>) β€” currently 85 cards in production.
17
+ - For each card with `card.benchmark_details.name`, generate `candidateBenchmarkKeys(name)` and insert each key into a `Map`.
18
+ - **First-write-wins** dedup: `if (!map.has(key)) map.set(key, card)`. If two cards normalize to the same key, the first one inserted (i.e. the first one returned by `Object.values()`) takes the slot.
19
+ - Cached for the lifetime of the process via `cachedMapPromise`.
20
+
21
+ 2. **Per-name candidate generation** (`lib/benchmark-metadata-utils.ts:23-33`, `candidateBenchmarkKeys`)
22
+ - Input: a free-text benchmark name.
23
+ - Produces an array of up to 4 lookup keys via a `Set` (so duplicates collapse), in this order:
24
+ 1. `base = normalizeBenchmarkKey(name)` β€” the canonical form (see below).
25
+ 2. `base.replace(/-/g, " ")` β€” dashes β†’ spaces.
26
+ 3. `base.replace(/ /g, "-")` β€” spaces β†’ dashes.
27
+ 4. `base.replace(/[^a-z0-9]/g, "")` β€” strip everything to alnum.
28
+
29
+ 3. **`normalizeBenchmarkKey`** (`lib/benchmark-metadata-utils.ts:10-18`) β€” the base normalizer:
30
+ - Returns `""` for falsy input (does NOT fall through to `"unknown"` like `pipelineSlugify` does).
31
+ - Strip a leading `<alnum_underscore_token>` followed by optional space and a `/` (e.g. `"hfopenllm_v2/mmlu"` β†’ `"mmlu"`).
32
+ - `.toLowerCase()`.
33
+ - Collapse runs of `_` or `-` to a single space.
34
+ - Collapse whitespace to single space.
35
+ - `.trim()`.
36
+
37
+ 4. **The retry loop over per-record candidate names** (`lib/model-data.ts:863-872`, `attachBenchmarkCardToSummary`)
38
+ - Builds a list of three candidate names from the summary (in this exact order):
39
+ 1. `summary.evaluation_name`
40
+ 2. `summary.composite_benchmark_name`
41
+ 3. `summary.composite_benchmark_key`
42
+ - For each, calls `getBenchmarkCard(candidate)` (which itself runs `candidateBenchmarkKeys` on the name and tries each key against the map).
43
+ - First hit wins. No `.filter(Boolean)` here, so empty-string candidates still hit `getBenchmarkCard` (which then returns `null` because `normalizeBenchmarkKey("")` returns `""`).
44
+
45
+ The list-item variant (`lib/duckdb-data.ts:133-156`, `attachBenchmarkCardsToEvalListItems` and `lib/model-data.ts:1264-1282` inline in `getEvalListData`) is identical except:
46
+ - Order is `[evaluation_name, composite_benchmark_key, composite_benchmark_name]` (key BEFORE name β€” the inverse of the summary version).
47
+ - Both wrap with `.filter(Boolean)` to drop empty/undefined candidates before the loop.
48
+
49
+ This three-vs-three asymmetry between the summary path and list path is **TS-as-spec**: do not "harmonize" it. Same record can resolve to different cards via the two paths if the second and third candidates point at different benchmarks (in production this difference is benign β€” see "Divergences detected").
50
+
51
+ ## Classification
52
+
53
+ - **Default-only** (do NOT overwrite when value present). Both attach functions guard with `if (summary.benchmark_card) return summary` / `if (item.benchmark_card) return item`. Pipeline-side fix: emit `benchmark_card` inline for every eval, then this branch is dead.
54
+ - **Cleaning β†’ pipeline.** This is a per-record lookup join (eval Γ— benchmark_card). The map build, candidate-name generation, and three-attempt retry exist only to reconcile the eval's free-text name against the benchmark-metadata file. Once the pipeline writes `benchmark_card: <BenchmarkCard>` directly into every `eval-list.json` / `eval-detail.json` record, all four pieces (map build, candidateBenchmarkKeys, normalizeBenchmarkKey, the two attach functions) delete together. No aggregation; no derived view.
55
+
56
+ ## Inputs and expected outputs
57
+
58
+ ### Group A β€” `normalizeBenchmarkKey`
59
+
60
+ | Input | Output | Rule branch |
61
+ |---|---|---|
62
+ | `"MMLU"` | `"mmlu"` | lowercase only |
63
+ | `"BIG-Bench Hard (BBH)"` | `"big bench hard (bbh)"` | dash β†’ space |
64
+ | `"hfopenllm_v2/mmlu"` | `"mmlu"` | composite prefix stripped |
65
+ | `"hfopenllm_v2 / mmlu"` | `"mmlu"` | composite prefix stripped (with space before `/`) |
66
+ | `"GPQA / Diamond"` | `"diamond"` | composite prefix stripped β€” regex `/^[a-z0-9_]+ ?\//i` allows one optional space between the leading token and the `/` |
67
+ | `""` | `""` | falsy short-circuit (does NOT fall back to "unknown") |
68
+ | `" MMLU "` | `"mmlu"` | trim |
69
+ | `"foo___bar"` | `"foo bar"` | underscore run collapsed to single space |
70
+ | `"foo - - bar"` | `"foo bar"` | dash + space runs collapse |
71
+
72
+ ### Group B β€” `candidateBenchmarkKeys`
73
+
74
+ | Input | Candidates returned (deduped, in order) | Why |
75
+ |---|---|---|
76
+ | `"MMLU"` | `["mmlu"]` | base only β€” no dashes, no spaces, alnum-only |
77
+ | `"BIG-Bench Hard (BBH)"` | `["big bench hard (bbh)", "big-bench-hard-(bbh)", "bigbenchhardbbh"]` | base; spaces→dashes; alnum-only. Dashes→spaces collides with base. |
78
+ | `"GSM-8K"` | `["gsm 8k", "gsm-8k", "gsm8k"]` | base; spaces→dashes; alnum-only |
79
+ | `"gsm 8k"` | `["gsm 8k", "gsm-8k", "gsm8k"]` | identical result; base/dashes-form collision |
80
+ | `"hfopenllm_v2/mmlu"` | `["mmlu"]` | composite prefix stripped first β†’ no dashes/spaces |
81
+ | `""` | `[""]` | base is `""`; the four variants all collapse to `""` |
82
+
83
+ ### Group C β€” `getBenchmarkCard` (per-name lookup against the deduped map)
84
+
85
+ Given a map built from cards `{ "mmlu": cardA, "big bench hard (bbh)": cardB, "gsm 8k": cardC }`:
86
+
87
+ | Input | Resolution | Notes |
88
+ |---|---|---|
89
+ | `"MMLU"` | cardA | base candidate `"mmlu"` hits |
90
+ | `"mmlu_categories/mmlu_pro"` | cardA only if `"mmlu pro"` not in map; otherwise `null`/no hit unless the prefix-stripped form `"mmlu_pro"` (after `^[a-z0-9_]+ ?\//` strips `mmlu_categories/`) β†’ `"mmlu pro"` was indexed. Demonstrates: composite prefix stripping can either help (find a generic card for the leaf) or miss (if the leaf has its own card the map didn't index under that exact spelling). |
91
+ | `"BBH"` | `null` if cardB indexed under full title only | Reverse-lookup limitation β€” see "Divergences detected" |
92
+ | `""` | `null` | `normalizeBenchmarkKey("")` short-circuits to `""`; all four candidates are `""`; `map.get("")` β†’ undefined |
93
+
94
+ ### Group D β€” `attachBenchmarkCardToSummary` retry order
95
+
96
+ For a summary with `evaluation_name="bbh/category_x"`, `composite_benchmark_name="BIG-Bench Hard (BBH)"`, `composite_benchmark_key="bbh"`:
97
+
98
+ | Position | Candidate name | Lookup behaviour |
99
+ |---|---|---|
100
+ | 0 | `"bbh/category_x"` | `normalizeBenchmarkKey` strips `bbh/` β†’ `"category_x"` β†’ `"category x"` β€” likely no match |
101
+ | 1 | `"BIG-Bench Hard (BBH)"` | `candidateBenchmarkKeys` produces `"big bench hard (bbh)"` β†’ likely match |
102
+ | 2 | `"bbh"` | base `"bbh"` β†’ only matches if cards indexed the abbreviation |
103
+
104
+ If position 1 hits, the loop short-circuits and the spread happens. If all three miss, `summary` returned unchanged.
105
+
106
+ ### Group E β€” pre-attached benchmark_card (default-only guard)
107
+
108
+ | Input | Output |
109
+ |---|---|
110
+ | `summary.benchmark_card = <existing card>` | returned as-is, no map lookup |
111
+ | `summary.benchmark_card = null` | falsy β†’ falls through to retry loop |
112
+ | `summary.benchmark_card = undefined` | falsy β†’ falls through to retry loop |
113
+
114
+ ## Current TS implementation
115
+
116
+ | Concern | Location |
117
+ |---|---|
118
+ | Map build + caching | `lib/benchmark-metadata.ts:11-36` (`readPipelineBenchmarkCards`, `getMap`, `cachedMapPromise`) |
119
+ | Per-name lookup | `lib/benchmark-metadata.ts:38-49` (`getBenchmarkCard`) |
120
+ | Reverse map flatten (used by `/api/benchmark-metadata` route) | `lib/benchmark-metadata.ts:51-66` (`getAllBenchmarkCards`) |
121
+ | Candidate-key generation | `lib/benchmark-metadata-utils.ts:23-33` (`candidateBenchmarkKeys`) |
122
+ | Base normalizer | `lib/benchmark-metadata-utils.ts:10-18` (`normalizeBenchmarkKey`) |
123
+ | Summary attach (3-candidate retry) | `lib/model-data.ts:860-875` (`attachBenchmarkCardToSummary`) |
124
+ | List-item attach (3-candidate retry, key before name) | `lib/duckdb-data.ts:133-156` (`attachBenchmarkCardsToEvalListItems`) |
125
+ | List-item attach (inline, key before name, JSON backend) | `lib/model-data.ts:1264-1282` (inside `getEvalListData`) |
126
+
127
+ ### Call sites
128
+
129
+ | Location | What it does |
130
+ |---|---|
131
+ | `lib/model-data.ts:870` | `attachBenchmarkCardToSummary` core (3-candidate retry over [evaluation_name, composite_benchmark_name, composite_benchmark_key]) |
132
+ | `lib/model-data.ts:1272` | `getEvalListData` inline 3-candidate retry over [evaluation_name, composite_benchmark_key, composite_benchmark_name] (note the swapped 2nd/3rd) |
133
+ | `lib/model-data.ts:1562` | aggregate eval path β€” calls `attachBenchmarkCardToSummary` per sub-eval before passing into `aggregateBenchmarkSummaries` |
134
+ | `lib/model-data.ts:1595` | synthetic-matrix eval path β€” single attach call |
135
+ | `lib/model-data.ts:1602` | direct eval lookup β€” single attach call |
136
+ | `lib/duckdb-data.ts:147` | DuckDB list-item retry (in `attachBenchmarkCardsToEvalListItems`) |
137
+ | `lib/duckdb-data.ts:179` | DuckDB single-eval path β€” calls `attachBenchmarkCardToSummary` |
138
+ | `lib/duckdb-data.ts:218` | DuckDB list path β€” calls `attachBenchmarkCardsToEvalListItems` |
139
+ | `app/api/benchmark-metadata/route.ts:5` | `/api/benchmark-metadata` route β€” exposes `getAllBenchmarkCards()` (the reverse flatten); not part of the per-eval attach but lives in the same module |
140
+
141
+ Total: 7 attach call sites across 2 files (3 in `model-data.ts` + 1 inline + 3 in `duckdb-data.ts` + the inline list loop). All would delete together once pipeline always inlines.
142
+
143
+ ## Pipeline status β€” divergences
144
+
145
+ Audited 2026-04-28 against `.cache/hf-data/` (587 evals, 85 benchmark cards in `benchmark-metadata.json`).
146
+
147
+ ### Coverage of inline `benchmark_card` (audited 2026-04-28)
148
+
149
+ - **88 / 587 evals (15.0%)** already have `benchmark_card` populated inline by the pipeline (in both `eval-list.json` and the per-eval JSONs under `evals/`).
150
+ - **499 / 587 evals (85.0%)** fall through to the runtime retry loop.
151
+ - **`eval-list.json` and per-eval `evals/*.json` agree** on which records carry inline cards (88 each β€” same set). The pipeline inlines symmetrically across both files.
152
+
153
+ ### Retry-loop position distribution (of the 499 lookups)
154
+
155
+ | Position | Summary path (name, name, key) | List path (name, key, name) |
156
+ |---|---|---|
157
+ | 0 (1st candidate hits) | 10 (2.0%) | 10 (2.0%) |
158
+ | 1 (2nd candidate hits) | 0 | 0 |
159
+ | 2 (3rd candidate hits) | 0 | 0 |
160
+ | -1 (no candidate hits) | 489 (98.0%) | 489 (98.0%) |
161
+
162
+ **The retry tail is dead code in production today.** Of the 499 lookups, the 1st candidate either hits (10) or no candidate ever hits (489). The 2nd and 3rd candidate retries never resolve anything. This is consistent with the data: the 489 misses are evaluations like `"artificial_analysis.median_output_tokens_per_second"` where no card exists in `benchmark-metadata.json` at all β€” not a lookup failure, just absence.
163
+
164
+ That said: the retry IS the only thing that adds the 88 inline + 10 retry-position-0 = 98 cards to the runtime view. The retry positions 1 and 2 are kept TS-as-spec β€” they may be load-bearing in past or future data, and don't cost anything to preserve until pipeline always inlines.
165
+
166
+ ### Asymmetric retry order between summary and list paths
167
+
168
+ ### Asymmetric retry order between summary and list paths
169
+
170
+ | Path | Order | Source |
171
+ |---|---|---|
172
+ | Summary attach | `[evaluation_name, composite_benchmark_name, composite_benchmark_key]` | `lib/model-data.ts:863-867` |
173
+ | List-item attach (both backends) | `[evaluation_name, composite_benchmark_key, composite_benchmark_name]` | `lib/duckdb-data.ts:140-144`, `lib/model-data.ts:1270` |
174
+
175
+ User-visible effect: an eval where `composite_benchmark_key` resolves to a different card than `composite_benchmark_name` would attach the wrong (or different) card depending on whether you reached it through the list page or the detail page. **In production today: 0 disagreements** (audited 2026-04-28). The asymmetry is theoretically observable but has no current impact β€” usually because the 1st candidate (`evaluation_name`) hits before either path reaches its swapped 2nd/3rd positions.
176
+
177
+ Reproduce-don't-improve: the pipeline-side fix is to inline `benchmark_card` so both call sites resolve to the same value. Do NOT pick one of the two retry orders and propagate it.
178
+
179
+ ### Map-build first-write-wins collisions
180
+
181
+ `readPipelineBenchmarkCards` indexes each card under `candidateBenchmarkKeys(card.benchmark_details.name)` and uses `if (!map.has(key)) map.set(key, card)`. If two cards have names that normalize to the same key, the **second** card is silently dropped from the map under that key (it may still be reachable via another candidate key it generates uniquely, but the colliding key permanently points at the first card encountered).
182
+
183
+ Order is `Object.values(cards)` β€” i.e. the JSON insertion order from `benchmark-metadata.json`. **Production count: 4 collisions** (audited 2026-04-28) involving 2 distinct duplicated names:
184
+
185
+ - `"Holistic Evaluation of Language Models (HELM)"` β€” `helm_capabilities` keeps the slot; `helm_instruct` is silently dropped under all colliding keys. The two cards have **different content** (overview lengths 466 vs 514). Anyone looking up "HELM" by name gets `helm_capabilities`; the `helm_instruct` card is unreachable via the runtime lookup unless the eval references the key `helm_instruct` directly (which would route through `composite_benchmark_key`, then `normalizeBenchmarkKey("helm_instruct")` β†’ `"helm instruct"` β†’ no match in the map either).
186
+ - `"LiveCodeBench"` β€” `livecodebenchpro` keeps the slot; `livecodebench_pro` is dropped. The two cards have **identical content** (same `overview` string). Benign.
187
+
188
+ The HELM case is a real data-correctness divergence: TS silently picks one of two distinct cards under the same name. Reproduce-don't-improve: the pipeline-side fix is to disambiguate the names in `benchmark-metadata.json` (or have evals reference cards by stable id rather than name), then inline the chosen card on each eval. Don't add disambiguation logic in TS.
189
+
190
+ Reproduce-don't-improve: pipeline should emit per-eval `benchmark_card` directly so this map-build path becomes dead code. No need to teach pipeline to detect collisions.
191
+
192
+ ### Reverse-lookup limitation
193
+
194
+ A card's `benchmark_details.name` is the only string indexed. If an eval references a benchmark by a different name (abbreviation, alternate casing, missing parens), the lookup misses unless one of the four `candidateBenchmarkKeys` variants happens to collide with a variant of the card's name.
195
+
196
+ Example: a card named `"BIG-Bench Hard (BBH)"` is indexed under `"big bench hard (bbh)"`, `"big-bench-hard-(bbh)"`, `"bigbenchhardbbh"`. An eval named `"BBH"` produces candidates `["bbh"]` only β€” miss. The audit script counts how many evals fall into this category.
197
+
198
+ Reproduce-don't-improve: this is the entire reason for migration item #17. Pipeline knows the card-to-eval mapping at build time; it shouldn't push a fuzzy-match problem to the runtime.
199
+
200
+ **Orphaned cards (audited 2026-04-28): 29 / 83 distinct cards** in `benchmark-metadata.json` are not reached by any eval through any lookup path β€” examples include `"arc_agi_v1_public_eval"`, `"arc_agi_v2_semi_private"`, the `bfcl_*` family of 7 cards, `"IMDB"`, `"NarrativeQA"`, `"NaturalQuestions (open-book)"`, `"RAFT"`. These cards are either (a) for benchmarks not yet evaluated in the corpus, or (b) cards whose `benchmark_details.name` doesn't normalize to anything any eval looks up. Pipeline owner should triage. (Not a TS bug β€” TS faithfully looks up; the cards are never asked for.)
201
+
202
+ ## Notes for pipeline implementer
203
+
204
+ The cleanest fix: **emit `benchmark_card: <BenchmarkCard>` inline on every eval** β€” both `eval-list.json` entries and every `evals/<id>.json` detail file. The pipeline already does this for 15% of evals; extend coverage to 100%.
205
+
206
+ Acceptance criteria:
207
+
208
+ 1. Every record in `eval-list.json` has `benchmark_card` populated when a card exists for that benchmark; `null` only when no card exists in `benchmark-metadata.json` for the benchmark.
209
+ 2. Every `evals/<id>.json` file has matching `benchmark_card` for the same eval (same value as the list entry β€” they should not disagree).
210
+ 3. The DuckDB-emitted parquet (consumed via `lib/duckdb-data.ts`) carries `benchmark_card` in the same column for every row.
211
+
212
+ Once pipeline meets criteria, all of the following delete:
213
+
214
+ - `lib/benchmark-metadata.ts` entirely (after migrating `/api/benchmark-metadata` to read from `benchmark-metadata.json` directly or from a pipeline-emitted reverse map).
215
+ - `lib/benchmark-metadata-utils.ts:candidateBenchmarkKeys`, `normalizeBenchmarkKey` β€” but the file may need to stay for the `/api/benchmark-metadata` route's `lookupBenchmarkCard` if any client component depends on it. Audit before deleting.
216
+ - `lib/model-data.ts:attachBenchmarkCardToSummary` (function + 3 call sites).
217
+ - `lib/model-data.ts` inline retry inside `getEvalListData` (lines 1264-1282).
218
+ - `lib/duckdb-data.ts:attachBenchmarkCardsToEvalListItems` (function + 1 call site) + `attachBenchmarkCardToSummary` import + the wrapping call in `toEvalSummary`.
219
+
220
+ Don't try to reproduce the 4-candidate `candidateBenchmarkKeys` derivation upstream. The point of the migration is to make it unnecessary.
221
+
222
+ ## Migration checklist
223
+
224
+ - [x] Spec written
225
+ - [x] Tests cover each rule branch (`tests/transformations/benchmark-card-attachment.test.ts`)
226
+ - [x] Audit script produced (`scripts/verify-benchmark-card-attachment.mjs`)
227
+ - [ ] Filed with pipeline owner (link)
228
+ - [ ] Pipeline emits `benchmark_card` inline on 100% of `eval-list.json` entries (currently 15%)
229
+ - [ ] Pipeline emits `benchmark_card` inline on 100% of `evals/*.json` files (currently 15%)
230
+ - [ ] DuckDB parquet carries `benchmark_card` column in `evalList` and `evalDetails` outputs
231
+ - [ ] TS code deleted; callers read pipeline field directly (7 call sites + 1 module)
232
+
233
+ ## Future product decision (deferred)
234
+
235
+ The `getAllBenchmarkCards()` reverse-flatten (used by `/api/benchmark-metadata` route) consumes the same map but produces a `Record<normalizedKey, card>` with `seen.has(card)` dedup, keyed by `normalizeBenchmarkKey(card.benchmark_details.name)` (which may not match what the map keys point at if there were collisions). Whether the route should keep using the map-derived form or read from `benchmark-metadata.json` directly is a separate decision for the cleanup pass β€” surface to pipeline owner.
notes/transformations/12-instance-level-data.md ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Per-instance JSONL normalization
2
+
3
+ Drafted 2026-04-28. Migration item #7 in `notes/migration-plan.md`.
4
+
5
+ ## Framing reminder
6
+
7
+ We are refactoring for UI efficiency. TS-as-is is the canonical spec for behaviour. This spec covers a per-record value transform that extracts UI-friendly strings (input/response/correctness/etc.) from rich per-sample objects emitted by the pipeline. Audited 2026-04-28 against the full corpus, the parser's many fallback branches mostly do not fire β€” pipeline emits a single canonical shape β€” but the parser preserves them as defensive scaffolding for older or different harness output formats.
8
+
9
+ **Architecture choice (cleaning-only, no SQL involvement):** the inline `instance_examples` preview (≀5 samples per result) gets normalized in pipeline; the per-result `source_url` pointing to a full JSONL dump (typically 50 samples) stays as an on-demand UI fetch, NOT pre-ingested into a parquet table. This avoids speculative pipeline ingestion work and matches the actual product need (lazy-load all samples on user click, not cross-corpus sample querying). The orphaned `fetchInstanceLevelData` (`lib/hf-data.ts:890-917`) gets re-wired to a future "show all samples" UI feature rather than deleted.
10
+
11
+ ## Rule (as TS implements it today)
12
+
13
+ ### `parseInstanceLevelData` (`lib/hf-data.ts:933-1043`)
14
+
15
+ Pure function: takes a JSON object, walks `instance_examples[]`, returns `SampleResult[]`.
16
+
17
+ Top-level guard:
18
+ - If `data` is null/non-object β†’ return `[]`
19
+ - If `data.instance_examples` is array β†’ use it
20
+ - Else if `data` itself is array β†’ use it
21
+ - Else β†’ `[]`
22
+
23
+ Per-example field extraction (each is a fallback chain):
24
+
25
+ **`input` (string)** β€” first non-empty wins:
26
+ 1. `raw.input` is string β†’ use as-is
27
+ 2. `raw.input.raw` is set β†’ `String(raw.input.raw)`
28
+ 3. `raw.prompt` β†’ use as-is
29
+ 4. `raw.question` β†’ use as-is
30
+ 5. `raw.doc.question` β†’ use as-is
31
+ 6. `raw.doc` exists β†’ `JSON.stringify(raw.doc).slice(0, 500)`
32
+ 7. (none) β†’ empty string
33
+
34
+ **`ground_truth` (string | undefined)** β€” first non-null wins:
35
+ 1. `raw.input.reference` is array β†’ `array.join(", ")`; else β†’ `String(...)`
36
+ 2. `raw.ground_truth` β†’ `String(...)`
37
+ 3. `raw.target` β†’ `String(...)`
38
+ 4. `raw.gold` β†’ `String(...)`
39
+ 5. `raw.doc.answer` β†’ `String(...)`
40
+ 6. (none) β†’ `undefined`
41
+
42
+ **`response` (string)** β€” first non-empty wins:
43
+ 1. `raw.output` is set β†’ string-as-is or `JSON.stringify(...)`
44
+ 2. `raw.response` β†’ use as-is
45
+ 3. `raw.model_output` β†’ use as-is
46
+ 4. `raw.answer_attribution` is non-empty array β†’ take last element's `extracted_value` (or empty string)
47
+ 5. `raw.messages` is non-empty array β†’ reverse + find last assistant message β†’ string content or stringified
48
+ 6. `raw.filtered_resps[0][0]` β†’ use
49
+ 7. `raw.resps[0][0]` β†’ use
50
+ 8. (none) β†’ empty string
51
+
52
+ **`is_correct` (boolean | undefined)** β€” first defined wins:
53
+ 1. `raw.evaluation.is_correct` (boolean)
54
+ 2. `raw.is_correct` (boolean)
55
+ 3. `raw.metrics.exact_match === 1` β†’ true; `=== 0` β†’ false; else undefined
56
+ 4. (none) β†’ `undefined`
57
+
58
+ **`metadata` (object | undefined)** β€” merged from (in order):
59
+ - `raw.evaluation` (if object)
60
+ - `raw.performance` (if object)
61
+ - `raw.metadata` (if object)
62
+ - `raw.metrics` (if object)
63
+ - If merged object is empty β†’ `undefined`
64
+
65
+ **`sample_id` (string)** β€” first non-null wins:
66
+ 1. `raw.sample_id` β†’ as-is
67
+ 2. `raw.doc_id` β†’ as-is
68
+ 3. `raw.id` β†’ as-is
69
+ 4. (none) β†’ `String(arrayIndex)` (positional fallback)
70
+
71
+ **`choices` (any | undefined)** β€” first non-null wins:
72
+ 1. `raw.choices`
73
+ 2. `raw.doc.choices`
74
+ 3. (none) β†’ `undefined`
75
+
76
+ If a row's first-pass map returns `null` (i.e. `raw` was null or non-object), it's filtered out via `.filter(s => s !== null)`.
77
+
78
+ ### `fetchInstanceLevelData` (`lib/hf-data.ts:890-917`) β€” currently orphaned
79
+
80
+ Takes a `(url, limit?)`. Fetches the URL via `fetch()`. Splits text on newlines (filters empty lines). For each line up to `limit` (or all if no limit), tries `JSON.parse`; skips malformed lines. Wraps the parsed array as `{ instance_examples: parsed }` and passes to `parseInstanceLevelData`. Returns `SampleResult[]` or `[]` on any error.
81
+
82
+ **Active call sites: zero.** Verified by grep across `app/`, `components/`, `scripts/`, other `lib/` files. Only mention is its own declaration. Git log shows one commit ("Refresh eval cards UI and backend data flow") in its history.
83
+
84
+ The function is intended for "load more / show all samples" UI feature β€” pipeline ships a `source_url` per row pointing to the full JSONL (typically 50 samples), but the inline `instance_examples` preview only carries 5. The fetcher would let UI request the full set on user demand. **This UI feature has never shipped.**
85
+
86
+ ## Classification
87
+
88
+ - **Unconditional normalization.** The function always runs on whatever shape is provided; never gates on a pre-existing canonical field. Pipeline-side fix: emit a canonical per-sample shape so the multi-field fallback chains become unnecessary.
89
+ - **Cleaning β†’ pipeline.** Pure value transform per sample. No aggregation, no reshape, no cross-record operations. Migration target: pipeline emits canonical per-sample shape on both the inline preview AND the URL JSONL files; TS parser shrinks to direct field reads or deletes entirely.
90
+ - **NOT reshape.** Per the architecture choice in the framing note above, samples stay as on-demand fetches via `source_url`; no parquet `instance_samples` table, no SQL queries over samples. (If a future product feature wants cross-model sample search/filter/comparison, that's a separate reshape spec.)
91
+
92
+ ## Inputs and expected outputs
93
+
94
+ ### Group A β€” Pipeline-canonical shape (the only shape that fires in production today)
95
+
96
+ Input shape (from cache `result.instance_level_data.instance_examples[i]`):
97
+
98
+ ```jsonc
99
+ {
100
+ "schema_version": "...",
101
+ "evaluation_id": "...",
102
+ "model_id": "...",
103
+ "evaluation_name": "...",
104
+ "sample_id": "...",
105
+ "sample_hash": "...", // sometimes present
106
+ "interaction_type": "multi_turn",
107
+ "input": { "raw": "..." }, // ALWAYS object with .raw in production
108
+ "output": "..." | { ... }, // sometimes
109
+ "messages": [{ role: "...", content: "..." }, ...], // typically present
110
+ "answer_attribution": [..., { "extracted_value": "..." }], // typically present
111
+ "evaluation": { "is_correct": true|false, ... }, // ALWAYS present
112
+ "performance": { ... },
113
+ "metadata": { ... },
114
+ "token_usage": { ... },
115
+ "error": null | "...",
116
+ "hierarchy": [...]
117
+ }
118
+ ```
119
+
120
+ Expected output (`SampleResult`):
121
+
122
+ | Output field | Source path that fires | Notes |
123
+ |---|---|---|
124
+ | `sample_id` | `raw.sample_id` (100% of production) | always present |
125
+ | `input` | `raw.input.raw` (100%) | branch #2 in the chain |
126
+ | `ground_truth` | `raw.input.reference` (100%) | branch #1 |
127
+ | `response` | `raw.answer_attribution` (97.31%) OR `raw.messages` (2.49%) OR `raw.output` (0.20%) | branches #4, #5, #1 |
128
+ | `is_correct` | `raw.evaluation.is_correct` (100%) | branch #1 |
129
+ | `choices` | `undefined` (100%) | no branch fires; field is unset in production |
130
+ | `metadata` | merged from `raw.evaluation`, `raw.performance`, `raw.metadata`, `raw.metrics` (always at least 2 of 4 present) | merged object |
131
+
132
+ ### Group B β€” Defensive fallback branches (zero firing rate in current production)
133
+
134
+ | Branch | Output field | Production hits | Origin (presumed) |
135
+ |---|---|---|---|
136
+ | `raw.input` (string) | input | 0 | older harness shapes |
137
+ | `raw.prompt` | input | 0 | lm-eval-harness |
138
+ | `raw.question` | input | 0 | other harnesses |
139
+ | `raw.doc.question` | input | 0 | HELM-style |
140
+ | `raw.doc` (JSON.stringify) | input | 0 | last-resort |
141
+ | `raw.ground_truth` | ground_truth | 0 | older shapes |
142
+ | `raw.target` | ground_truth | 0 | classification benchmarks |
143
+ | `raw.gold` | ground_truth | 0 | older lm-eval |
144
+ | `raw.doc.answer` | ground_truth | 0 | HELM-style |
145
+ | `raw.response` | response | 0 | older shapes |
146
+ | `raw.model_output` | response | 0 | older shapes |
147
+ | `raw.filtered_resps[0][0]` | response | 0 | lm-eval-harness format |
148
+ | `raw.resps[0][0]` | response | 0 | lm-eval-harness format |
149
+ | `raw.is_correct` | is_correct | 0 | flat shape |
150
+ | `raw.metrics.exact_match` | is_correct | 0 | metric-based correctness |
151
+ | `raw.doc_id` | sample_id | 0 | HELM-style |
152
+ | `raw.id` | sample_id | 0 | generic |
153
+ | index fallback | sample_id | 0 | last-resort |
154
+ | `raw.choices` | choices | 0 | multiple-choice |
155
+ | `raw.doc.choices` | choices | 0 | HELM multiple-choice |
156
+
157
+ These branches exist for shapes the pipeline currently does not emit. **Preserve verbatim** until pipeline-side guarantees the canonical shape across all data sources.
158
+
159
+ ### Group C β€” `fetchInstanceLevelData` JSONL parsing edge cases
160
+
161
+ | Input | Behavior |
162
+ |---|---|
163
+ | URL returns `!res.ok` (404, 500, etc.) | returns `[]` (no throw) |
164
+ | URL throws (network error) | logs warning to console, returns `[]` |
165
+ | Empty body | splits to `[]`, returns `[]` |
166
+ | Body with empty lines | `.filter(line => line.trim())` strips them |
167
+ | Body with malformed JSON line | swallowed in inner try-catch; line skipped, processing continues |
168
+ | `limit=0` or `limit=undefined` | parses ALL lines |
169
+ | `limit > lines.length` | parses all lines (capped via `Math.min`) |
170
+
171
+ ## Current TS implementation
172
+
173
+ | Concern | Location | Notes |
174
+ |---|---|---|
175
+ | `parseInstanceLevelData` | `lib/hf-data.ts:933-1043` | The parser; ~110 lines |
176
+ | `fetchInstanceLevelData` | `lib/hf-data.ts:890-917` | URL fetcher; orphaned (zero callers) |
177
+ | Active call site | `lib/hf-data.ts:1273` (inside `flattenHierarchyNode`) | `parseInstanceLevelData(result.instance_level_data)` β†’ `inlineSamples` |
178
+ | Internal call site | `lib/hf-data.ts:912` | inside `fetchInstanceLevelData` itself, recursive call to the parser |
179
+ | `SampleResult` type | `lib/benchmark-schema.ts:135` | Output shape definition |
180
+ | Output field | `BenchmarkEvaluation.detailed_evaluation_results_per_samples` | `lib/benchmark-schema.ts:33` |
181
+
182
+ ### Caller chain for `parseInstanceLevelData`
183
+
184
+ `getModelSummaryById` (lib/model-data.ts:1490+) β†’ `flattenModelEvaluations` β†’ `flattenHierarchyNode` (lib/hf-data.ts:1273) β†’ `parseInstanceLevelData(result.instance_level_data)` β†’ set as `inlineSamples` on each variant bucket β†’ propagated to `BenchmarkEvaluation.detailed_evaluation_results_per_samples`.
185
+
186
+ UI consumers (read `data.detailed_evaluation_results_per_samples`):
187
+ - `components/benchmark-detail.tsx:3869` β€” random sample for preview block
188
+ - `components/benchmark-detail.tsx:4174-4216` β€” sample preview UI in benchmark detail
189
+ - `components/benchmark-detail.tsx:4982` β€” variant-level sample availability check
190
+ - `components/benchmark-detail.tsx:5284-5286` β€” variant sample picker
191
+ - `components/benchmark-detail.tsx:5569-5608` β€” sample preview list with INSTANCE_PREVIEW_LIMIT and "see all" expansion
192
+
193
+ ### Caller chain for `fetchInstanceLevelData`
194
+
195
+ None. Function is exported and unreached. Preserved with the intent that a future "show all samples" UI consumer wires up to it.
196
+
197
+ ## Pipeline status
198
+
199
+ ### Side-by-side comparison
200
+
201
+ | Aspect | TS (this spec) | Pipeline today | Result for users |
202
+ |---|---|---|---|
203
+ | Inline preview shape | parser handles many variants | emits ONE canonical shape (`input.raw`, `evaluation.is_correct`, etc.) | parser's fallback branches almost all dead |
204
+ | URL JSONL shape | same parser handles | emits IDENTICAL canonical shape (verified by sampling one URL on 2026-04-28) | parser would work the same on URL data |
205
+ | Inline preview size | parser doesn't care | always exactly 5 samples per `instance_examples` array | UI capped at 5 today |
206
+ | Total samples per row | n/a | typically 50 (per `instance_count` field), one outlier 18 | only 10% accessible to UI today |
207
+ | URL-fetch use case | `fetchInstanceLevelData` exists | `source_url` always emitted | dead code on TS side; no UI consumer |
208
+
209
+ ### Concrete worked example with quantified scope
210
+
211
+ Audited 2026-04-28 against `.cache/hf-data/`. Verified by `scripts/verify-instance-level-data.mjs`.
212
+
213
+ **Prevalence:**
214
+ - Total model files: 5,830
215
+ - Files with any `instance_level_data`: **55 (0.94%)**
216
+ - Total `(metric Γ— model_result)` rows: 86,183
217
+ - Result rows with `instance_level_data`: **712 (0.83%)**
218
+ - Total inline preview examples (sum of `instance_examples.length`): **3,532** (always ≀5 per row)
219
+ - Total full samples (sum of `instance_count`): **66,057** (full set available via `source_url`, not loaded today; ~19Γ— larger than what UI currently shows)
220
+
221
+ **ild-level shape uniformity (712/712 rows):**
222
+ - Top-level keys are always exactly `{interaction_type, instance_count, source_url, instance_examples}`
223
+ - `interaction_type` is always `"multi_turn"` (no single_turn samples in cache)
224
+
225
+ **Per-example branch firing rates (3,532 examples):**
226
+ - `input`: `input.raw` 100%
227
+ - `ground_truth`: `input.reference` 100%
228
+ - `response`: `answer_attribution` 97.31%, `messages` 2.49%, `output` 0.20%
229
+ - `is_correct`: `evaluation.is_correct` 100%
230
+ - `sample_id`: `sample_id` 100%
231
+ - `choices`: nothing (always undefined)
232
+
233
+ The 7-branch input chain, 5-branch ground_truth chain, 4-branch is_correct chain, 4-branch sample_id chain, 2-branch choices chain are **defensive scaffolding** for shapes the pipeline does not currently emit. The 7-branch response chain has 3 active sub-branches.
234
+
235
+ **URL JSONL shape verification:** sampled one source_url (`anthropic__anthropic-claude-3-7-sonnet/swe_bench_verified_mini_...`); first line had identical 18 keys to the inline `instance_examples[0]`, with `input.raw` and `evaluation.is_correct` in expected paths. Pipeline emits the same canonical shape on both inline and URL paths.
236
+
237
+ ## Notes for pipeline implementer
238
+
239
+ - The pipeline already emits a canonical per-sample shape consistently. **No structural change needed to current emission.** The migration is to make this shape an explicit guarantee, not to change what's being emitted.
240
+ - Suggested guarantee: every `instance_examples[i]` (inline AND in JSONL at `source_url`) has at minimum `{sample_id, input.raw, input.reference?, evaluation.is_correct, answer_attribution? || messages?, metadata?}`.
241
+ - Once that guarantee is documented and verified, the TS parser shrinks dramatically: extract `raw.input.raw`, `raw.input.reference`, `raw.evaluation.is_correct`, `raw.sample_id` as direct field reads. The response field still needs the 3-branch fallback (answer_attribution β†’ messages β†’ output) until pipeline emits a single normalized `response` field.
242
+ - **Do NOT pre-ingest the URL JSONL into pipeline parquet** (per the architecture choice). The runtime UI fetches `source_url` on demand; this is the orphaned `fetchInstanceLevelData`'s intended use. The benefit of pre-ingestion (cross-corpus SQL queries over samples) is speculative; defer until a product feature demands it.
243
+ - The "shape uniformity" finding (712/712 rows have identical ild-level keys; all are `multi_turn`) suggests the pipeline already enforces the canonical shape. Worth documenting in the pipeline contract test (`tests/pipeline-contract.test.ts`).
244
+
245
+ ## Migration checklist
246
+
247
+ - [x] Spec written
248
+ - [x] Tests cover each rule branch (`tests/transformations/instance-level-data.test.ts`)
249
+ - [x] Audit script (`scripts/verify-instance-level-data.mjs`)
250
+ - [ ] Filed with pipeline owner with the spec + tests + audit script as acceptance criterion
251
+ - [ ] Pipeline contract: explicit guarantee of canonical per-sample shape (Tier A test asserting `every instance_example has input.raw, sample_id, evaluation.is_correct`)
252
+ - [ ] TS deleted: `parseInstanceLevelData` shrinks to direct field reads (~10 lines instead of 110), or fully deleted if pipeline emits already-flat normalized records. `fetchInstanceLevelData` stays orphaned-but-preserved for the future "show all samples" UI feature, OR is wired up if that feature ships.
253
+
254
+ ## Future product decisions (deferred)
255
+
256
+ - **"Show all samples" UI feature** β€” would un-orphan `fetchInstanceLevelData` and let users see the full 50-sample set instead of just the 5-sample preview. Lazy fetch on user click. This is the concrete capability the URL-fetch architecture supports; the spec assumes it's a product roadmap item, not committed scope.
257
+ - **Cross-model sample querying / search / filter** β€” would require `instance_samples.parquet` and SQL queries (the alternative architecture I initially proposed). Out of scope for this spec; revisit if/when product asks.
258
+ - **Single-turn samples** β€” pipeline currently only emits `interaction_type: multi_turn`. If pipeline starts emitting single_turn shapes that exercise dormant parser branches (e.g. flat `input` strings, `prompt`/`question` fields), the spec's "100% canonical shape" claim breaks and the parser fallbacks become live again.
notes/transformations/README.md ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Transformations registry
2
+
3
+ Canonical specs for data transformations the Next.js app currently performs that should ultimately live in the upstream pipeline (`eval_cards_backend_pipeline`).
4
+
5
+ ## Framing
6
+
7
+ The TS code in `lib/` does meaningful data transformation work β€” token canonicalization, variant grouping, source-metadata defaults, category inference, score normalization, etc. β€” most of which the Python pipeline does not yet do (or does differently). For this migration:
8
+
9
+ - **TS is the current source of truth** for what these transformations should produce. The TS rules have been refined over time against real evaluation data; they encode product decisions.
10
+ - **Each transformation is a candidate to move upstream.** The pipeline owns data shape and content; doing the transformation there means every downstream consumer (not just this Next.js app) gets the canonical form.
11
+ - **The migration path** for each item: write a spec here β†’ write executable tests sourced from the spec β†’ hand the spec + tests to the pipeline owner β†’ verify pipeline output matches across the full corpus β†’ delete the TS implementation.
12
+ - **Pre-processing in this repo (e.g. a one-shot Python script run at data-ingestion time) is allowed but not required.** Default is "transformation stays in TS until pipeline catches up." Front-load only when the TS implementation is so brittle or expensive that on-the-fly is intolerable.
13
+
14
+ ## One thing to watch: defaults vs unconditional normalization
15
+
16
+ For each rule, classify in the spec how it interacts with pre-existing data:
17
+
18
+ - **Default-only (don't overwrite when pipeline already emits a value)**: e.g. "if `source_metadata.evaluator_relationship` is missing, default to `other`". Pipeline-side fix is to emit the default upstream rather than letting consumers fill in.
19
+ - **Unconditional normalization (always overwrite)**: e.g. "lowercase the `v` in version tokens regardless of upstream input". Pipeline-side fix is to apply the rule before emitting; downstream consumers should not need to re-derive.
20
+
21
+ Mis-classifying these is the failure mode that bit us in Phase 3 (treating a normalization rule as if it were a default, ending up overwriting category data). Each spec must call out which kind it is.
22
+
23
+ ## Where it belongs: cleaning vs reshape
24
+
25
+ A second classification each spec should call out β€” *what kind of work* is this transformation?
26
+
27
+ - **Cleaning / standardization** (changes a value): license shorthand, developer name canonicalization, identity tokens, timestamp format, category labels. These belong in the pipeline; the per-item workflow above is built for them. Default-vs-normalization (the section above) is the sub-question.
28
+ - **Reshape / dedup / aggregate** (computes a derived view): variant dedup with "freshest wins", per-category counts, top-scores ranking, hierarchy flattening. These belong in **DuckDB SQL** β€” either materialized into pipeline parquet (the answer is the same for every consumer; emit it pre-computed) or expressed as a query at request time (consumers slice differently; let SQL do the work). The TS implementation here is scaffolding.
29
+
30
+ For the reshape class the migration target shape is itself a design choice β€” capture the *operation* (e.g. "max `retrieved_timestamp` wins per variant key, take its `source_metadata` along") and flag it for the parquet-schema / SQL conversation rather than mechanically translating the TS code line-by-line. See `notes/migration-plan.md` Β§ "Data direction" for the full framing.
31
+
32
+ ## Index
33
+
34
+ | # | Transformation | Spec | Tests | Pipeline status | Migration item |
35
+ |---|---|---|---|---|---|
36
+ | 01 | Model identity canonicalization | [01-identity-canonicalization.md](01-identity-canonicalization.md) | [tests/transformations/identity-canonicalization.test.ts](../../tests/transformations/identity-canonicalization.test.ts) | partial (model_family_id βœ…, model_family_name ❌ on 1,260 cards) | #1 |
37
+ | 02 | Setup-alias variant merging | [02-setup-alias-merging.md](02-setup-alias-merging.md) | [tests/transformations/setup-alias-merging.test.ts](../../tests/transformations/setup-alias-merging.test.ts) | not started (cache file shows pre-merge state; runtime normalizer is what merges) | #2 |
38
+ | 03 | License string normalization | [03-license-normalization.md](03-license-normalization.md) | [tests/transformations/license-normalization.test.ts](../../tests/transformations/license-normalization.test.ts) | not implemented; pipeline emits free-text `data_licensing` only | #18 |
39
+ | 04 | Dataset URL synthesis | [04-dataset-url-synthesis.md](04-dataset-url-synthesis.md) | [tests/transformations/dataset-url-synthesis.test.ts](../../tests/transformations/dataset-url-synthesis.test.ts) | not implemented; `dataset_url` field never populated in prod (564/587 use `url[0]`, 22/587 use `hf_repo` template) | #20 |
40
+ | 05 | Slug candidate generation (file lookup) | [05-slug-candidates.md](05-slug-candidates.md) | [tests/transformations/slug-candidates.test.ts](../../tests/transformations/slug-candidates.test.ts) | not implemented; 39% of model lookups + 43% of developer lookups need a non-zero retry position in production | #19 |
41
+ | 06 | Developer name canonicalization | [06-developer-name-canonicalization.md](06-developer-name-canonicalization.md) | [tests/transformations/developer-name-canonicalization.test.ts](../../tests/transformations/developer-name-canonicalization.test.ts) | not implemented; pipeline emits raw `developer` string. TS map covers 1.8% of devs / 11.9% of cards; title-case fallback fires on 55.6% of devs / 48.4% of cards | #9 |
42
+ | 07 | Timestamp normalization | [07-timestamp-normalization.md](07-timestamp-normalization.md) | [tests/transformations/timestamp-normalization.test.ts](../../tests/transformations/timestamp-normalization.test.ts) | not implemented; production is 99.99% unix-seconds-strings (86,178/86,183) + 5 ISO datetime. Three different TS variants exist with subtly different semantics β€” pipeline canonicalization to ISO 8601 collapses them | #13 |
43
+ | 08 | Benchmark display names | [08-benchmark-display-names.md](08-benchmark-display-names.md) | [tests/transformations/benchmark-display-names.test.ts](../../tests/transformations/benchmark-display-names.test.ts) | not implemented; 30-entry hand-curated map covers ~74% of distinct suite keys but only ~3% of distinct `benchmark` values; ~97% fall through to a `humanizeToken` fallback that mangles acronyms (`MMLU-PRO` β†’ `MMLU PRO`, `helm_air_bench` β†’ `Helm Air Bench`). A second functionally-dead duplicate exists in `lib/eval-processing.ts` with substring-match semantics that disagree with the active path. | #8 |
44
+ | 09 | Metric display name expansion | [09-metric-display-name-expansion.md](09-metric-display-name-expansion.md) | [tests/transformations/metric-display-name-expansion.test.ts](../../tests/transformations/metric-display-name-expansion.test.ts) | defensive scaffolding; both rules fire 0 times against current corpus (0/86,183 result rows for generic-name expansion; 0/587 eval-list entries for prefersBenchmarkName heuristic) | #10 |
45
+ | 10 | Params billions parsing | [10-params-parsing.md](10-params-parsing.md) | [tests/transformations/params-parsing.test.ts](../../tests/transformations/params-parsing.test.ts) | partial; `model-cards.json.params_billions` is clean number for 87% of cards (5072/5830); per-row `additional_details.params_billions` is string for 31.7% of rows (27,361/86,183), 47.2% (40,648) have no resolved value at all. Five different TS parsers diverge on edge cases β€” Variant C's "context-window beats param count" quirk fires on 472 rows (0.55%, names like `Yi-1.5-34B-32K`) | #12 |
46
+ | 11 | Benchmark-card attachment (per-eval lookup join) | [11-benchmark-card-attachment.md](11-benchmark-card-attachment.md) | [tests/transformations/benchmark-card-attachment.test.ts](../../tests/transformations/benchmark-card-attachment.test.ts) | partial; pipeline inlines `benchmark_card` on 88/587 evals (15%), other 499 fall through to runtime retry. Of those, 10 hit at position 0 and 489 miss entirely (most lack any matching card in `benchmark-metadata.json`). Map-build first-write-wins silently drops the `helm_instruct` card (different content from kept `helm_capabilities`, both named "HELM"); 29/83 cards orphaned | #17 |
47
+ | 12 | Per-instance JSONL normalization | [12-instance-level-data.md](12-instance-level-data.md) | [tests/transformations/instance-level-data.test.ts](../../tests/transformations/instance-level-data.test.ts) | pipeline emits canonical shape; parser's defensive fallback branches mostly dead (input/ground_truth/is_correct/sample_id all 100% via the canonical path; response splits 97.31% answer_attribution / 2.49% messages / 0.20% output). 712/86,183 result rows (0.83%) have inline samples; full sets (~35k samples) sit behind `source_url` and are accessible only via the orphaned `fetchInstanceLevelData` (no current UI consumer). | #7 |
48
+
49
+ (More entries land as we work each migration item. See `notes/migration-plan.md` for the full backlog.)
50
+
51
+ ## File format
52
+
53
+ Each spec follows the same structure so pipeline owner can read them uniformly. Template:
54
+
55
+ ```markdown
56
+ # <Transformation name>
57
+
58
+ ## Rule
59
+ [Plain-English description of what the transformation does]
60
+
61
+ ## Classification
62
+ - [ ] Default-only (do not overwrite when value present)
63
+ - [ ] Unconditional normalization (always apply)
64
+ [explanation]
65
+
66
+ - [ ] Cleaning / standardization β†’ pipeline (changes a value's content; use per-item workflow)
67
+ - [ ] Reshape / dedup / aggregate β†’ DuckDB SQL (computes a derived view; capture operation, flag for parquet-schema/SQL conversation)
68
+ [explanation β€” if both halves apply, explain the split]
69
+
70
+ ## Inputs and expected outputs
71
+ [Table: input | expected output | notes / which rule branch this hits]
72
+
73
+ ## Current TS implementation
74
+ - [file:line references]
75
+ - [key helpers/constants]
76
+
77
+ ## Pipeline status
78
+ [Per-rule status against full live cache: matches / disagrees / not implemented]
79
+
80
+ ## Divergences detected
81
+ [Concrete examples of pipeline-vs-TS disagreement, with row counts]
82
+
83
+ ## Migration checklist
84
+ - [ ] Spec written
85
+ - [ ] Tests cover each rule branch
86
+ - [ ] Filed with pipeline owner (link)
87
+ - [ ] Pipeline emits matching values across full corpus
88
+ - [ ] TS code deleted; callers read pipeline fields directly
89
+ ```
notes/transformations/reshape-design.md ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reshape-class operations: parquet schema + SQL plan
2
+
3
+ Drafted 2026-04-28. Synthesis of 5 per-item operation catalogs in `notes/transformations/reshape/` plus the reshape halves of #2 (setup-alias merging) and #13 (timestamp normalization). Reads against the principle in `notes/migration-plan.md` Β§ "Data direction".
4
+
5
+ This doc is **a discussion artifact for the pipeline owner**, not a unilateral architecture commitment. It proposes a parquet schema delta, sketches the SQL each reshape becomes, recommends materialize-vs-query-time per item, and orders the dependency graph. Pipeline owner has authority to push back on any of it.
6
+
7
+ ## Inventory
8
+
9
+ | Item | Operation | Per-item doc | Recommendation |
10
+ |---|---|---|---|
11
+ | #2 (reshape half) | Variant bucket reduction (`GROUP BY variant_key, MAX(retrieved_timestamp)`) | `02-setup-alias-merging.md` Β§ "Dual class" | Pipeline emits already-deduped rows in `model_results` parquet (materialize-by-emission) |
12
+ | #3 | Hierarchy flatten + family summary | `reshape/03-hierarchy-flatten.md` | Materialize Steps 1+2 (flat `model_results`); query-time Step 3 (family rollup, category bucketing) |
13
+ | #5 | Composite eval rollup (`/evals/aggregate__<suite>`) | `reshape/05-composite-eval-rollup.md` | Materialize per-(suite, model) rollup table |
14
+ | #6 | Matrix leaderboard synthesis (`/evals/matrix__<suite>`) | `reshape/06-matrix-leaderboard.md` | Materialize wide matrix; query-time row filter |
15
+ | #13 (reshape half) | Timestamp comparison / dedup | `07-timestamp-normalization.md` Β§ "Classification" | SQL inline (`MAX(retrieved_timestamp)`, `ROW_NUMBER()`); no materialization needed |
16
+ | #14 | Score summary stats (per-eval aggregations) | `reshape/14-score-summary-stats.md` | Materialize 9 columns into `eval-list.json` extension |
17
+ | #16 | Per-category benchmark counts (`COUNT(DISTINCT benchmark) GROUP BY model, category`) | `reshape/16-per-category-counts.md` | Materialize `category_stats` per model (current TS is *known wrong*) |
18
+
19
+ ## Required parquet schema delta
20
+
21
+ The current schema (`scripts/pipeline.py:write_experimental_parquet_table`) is 11 typed metadata columns + `payload_json VARCHAR`. SQL can route on metadata columns but cannot see inside the blob. **Every reshape item above is bottlenecked on the same schema delta: promote nested per-result fields to a relational table.**
22
+
23
+ ### Proposed: `model_results.parquet` (one row per metric Γ— model_result, post-#2 dedup)
24
+
25
+ The unifying schema across #3, #5, #6, #14, #16 needs roughly the same columns. Designing it once unlocks all of them.
26
+
27
+ ```
28
+ -- Routing / partition keys
29
+ model_family_id VARCHAR -- joins to model_summaries.parquet
30
+ model_route_id VARCHAR -- joins to model_summaries.parquet
31
+ developer VARCHAR -- already a metadata column on model_summaries
32
+
33
+ -- Eval / benchmark identity
34
+ eval_summary_id VARCHAR -- already a metadata column
35
+ benchmark VARCHAR
36
+ benchmark_family_key VARCHAR -- already a metadata column on eval_summaries
37
+ benchmark_family_name VARCHAR
38
+ benchmark_parent_key VARCHAR
39
+ benchmark_parent_name VARCHAR
40
+ benchmark_leaf_key VARCHAR
41
+ benchmark_leaf_name VARCHAR
42
+ display_name VARCHAR -- post-inheritance from buildFlattenHierarchyContext
43
+ canonical_display_name VARCHAR
44
+ benchmark_display_name VARCHAR -- post #8 cleaning; the user-facing label
45
+ slice_key VARCHAR
46
+ slice_name VARCHAR
47
+ category_key VARCHAR -- raw "agentic"/"reasoning"/etc.; mapped form derived via SQL CASE or pipeline-emit (see open question 7); #11 affects accuracy
48
+
49
+ -- Metric identity
50
+ metric_summary_id VARCHAR
51
+ metric_key VARCHAR
52
+ metric_name VARCHAR
53
+ metric_display_name VARCHAR -- post #10 cleaning
54
+ metric_canonical_display_name VARCHAR
55
+ metric_unit VARCHAR
56
+ min_score DOUBLE -- from metric_config; needed by #5, #14
57
+ max_score DOUBLE -- from metric_config; needed by #5, #14
58
+ lower_is_better BOOLEAN -- from metric_config; needed by #5, #6, #14
59
+ evaluation_description VARCHAR -- from metric_config; needed by #5
60
+
61
+ -- Per-result fields
62
+ evaluation_id VARCHAR -- result-level
63
+ raw_model_id VARCHAR
64
+ model_id VARCHAR
65
+ model_result_route_id VARCHAR
66
+ model_name VARCHAR
67
+ score DOUBLE
68
+ retrieved_timestamp VARCHAR -- ISO 8601 once #13 cleaning ships; today unix-seconds-string
69
+ sample_size BIGINT
70
+ has_generation_config BOOLEAN -- presence flag for #14
71
+ detailed_evaluation_results VARCHAR
72
+ instance_level_data JSON
73
+ source_data JSON -- inherited; could be promoted further if hot
74
+
75
+ -- Source metadata (promoted from struct to flat columns; needed by #5, #6, #14)
76
+ source_metadata.evaluator_relationship VARCHAR -- enum: first_party / third_party / other
77
+ source_metadata.source_type VARCHAR
78
+ source_metadata.source_name VARCHAR
79
+ source_metadata.source_organization_name VARCHAR
80
+
81
+ -- Variant identity (depends on #2 cleaning)
82
+ variant_key VARCHAR -- pre-resolved per #2 setup-alias normalization
83
+ variant_label VARCHAR
84
+
85
+ -- Cleaning-driven derived columns (depend on cleaning items)
86
+ benchmark_card JSON -- depends on migration #17 inlining (specced as `notes/transformations/11-benchmark-card-attachment.md`)
87
+ ```
88
+
89
+ ### Sidecar tables
90
+
91
+ ```
92
+ -- composite_membership.parquet β€” one row per (suite_key, sub_eval_summary_id), drives #5
93
+ suite_key VARCHAR
94
+ eval_summary_id VARCHAR
95
+ suite_display_name VARCHAR -- from eval-hierarchy.json family display_name; #8 affects this
96
+ ```
97
+
98
+ ### What stays in `payload_json`
99
+
100
+ After this delta, `payload_json` still carries the nested original shape for debugging / migration parity. Once parity is verified per item, fields consumed only via the relational columns can be dropped from the blob.
101
+
102
+ ### Pipeline-side family-membership filter
103
+
104
+ Important: pipeline should apply the `belongsToModelFamily` filter at emission time so every row in `model_results.parquet` is already correctly assigned to its `model_family_id`. SQL then runs `WHERE model_family_id = ?` and the TS family-membership logic disappears. (Currently the filter happens in TS β€” `lib/hf-data.ts:1110-1131`. Lifting it upstream eliminates the ~6-line filter from the SQL replacement and avoids the "raw_model_ids set βˆͺ variant.raw_model_ids βˆͺ model_info.id βˆͺ model_family_id" CTE.)
105
+
106
+ ## Per-item SQL sketches
107
+
108
+ Each operation's full SQL is in its catalog file. This section gives one-paragraph summaries to read alongside the inventory.
109
+
110
+ ### #3 hierarchy flatten β€” Steps 1+2 materialized, Step 3 query-time
111
+ Tree walk β†’ flat list with variant bucket reduction = **what `model_results.parquet` IS**. Pipeline does it once at build. Consumer Step 3 (family summary, category bucketing) becomes a ~10-line SQL query against `model_results` per request. Removes `flattenHierarchyNode` (~150 lines), `createModelFamilySummary` (~80 lines), `createModelSummary` (~65 lines), `getAggregatedVariantDescriptor`, `sortVariants`, `buildVariantLookup`, `resolveVariantMeta`, `belongsToModelFamily`, `buildModelInfoForVariant`. See `reshape/03-hierarchy-flatten.md` for the full SQL.
112
+
113
+ ### #5 composite eval rollup β€” fully materialize
114
+ Pipeline pre-computes per-(suite, model) rows + suite-level summary into `composite_eval_rollup.parquet`. Eliminates the 2-21 (outlier 471) `fetchHFEvalDetail` calls per page view. Removes `aggregateBenchmarkSummaries` (168 lines) and the fan-out loop in `getEvalSummaryById`. The rollup SQL itself (per-eval normalize β†’ per-model average β†’ suite stats) is in `reshape/05-composite-eval-rollup.md`.
115
+
116
+ ### #6 matrix leaderboard β€” materialize wide matrix, query-time row filter
117
+ Pipeline pre-computes per-(suite, model_id) β†’ values map + reconciled metadata using DuckDB `PIVOT` semantics. Eliminates the per-cell walk (median 6, max 471 sub-evals Γ— ~91 models = ~10⁴ tuples per request for `llm_stats`). Row-filter knobs (developer, source_type, top-K) stay query-time SQL β€” emerging UI need. Removes `buildSingleMetricSuiteMatrixSummary` (~165 lines).
118
+
119
+ ### #13 timestamp comparison β€” SQL inline, no separate materialization
120
+ Once cleaning #13 lands ISO 8601 timestamps, the comparisons embedded in #3, #5, #6, #14 collapse to `MAX(retrieved_timestamp)` and `ROW_NUMBER() OVER (... ORDER BY retrieved_timestamp DESC)`. The 3 TS normalizers + 8 callers all delete as a unit. No separate parquet artifact.
121
+
122
+ ### #14 score summary stats β€” materialize 9 columns into eval-list extension
123
+ Pipeline emits all 9 aggregated columns (`models_count`, `avg_score`, `avg_score_norm`, `best_model`, `worst_model`, `evaluator_names`, `source_types`, `latest_source_name`, `third_party_ratio`, `missing_generation_config_count`) per eval. Pattern matches today's existing `eval-list.json` materialization of `models_count` + `top_score`. SQL is textbook `GROUP BY eval_summary_id` + arithmetic + set aggregations. Removes the finalisation loop in `groupEvaluationsByBenchmark` (~45 lines) and the aggregation block in `hfEvalDetailToSummary` (~45 lines).
124
+
125
+ ### #16 per-category counts β€” materialize `category_stats` per model
126
+ Smallest, clearest reshape. Pipeline emits `category_stats: Record<Category, number>` per model via `COUNT(DISTINCT benchmark_family_key) GROUP BY model_route_id, category`. Replaces the fake `Math.floor(total / categories.length)` distribution in `lib/model-data.ts:369-379` AND the real-but-different distinct-count in `lib/eval-processing.ts:653-666`. Same model gets the same answer everywhere after the migration. **Blocked on #11 category accuracy** β€” without it, 84% of evals collapse into one `other` bucket.
127
+
128
+ ## Materialize vs query-time β€” rationale per item
129
+
130
+ | Item | Recommendation | Why |
131
+ |---|---|---|
132
+ | #2 reshape | Materialize via #3's `model_results` | Bucket reduction is invariant; every consumer needs the same dedup |
133
+ | #3 Steps 1+2 | Materialize | Tree walk is expensive and invariant; every model-detail page needs it |
134
+ | #3 Step 3 | Query-time | Consumer-shape varies (per-variant for detail, per-benchmark for eval-detail); SQL lets each consumer slice without paying for others |
135
+ | #5 | Materialize | Identical answer per consumer; only 13 multi-eval families today; eliminates 2-471 detail fetches per page view |
136
+ | #6 wide matrix | Materialize | Column shape + per-cell winners are deterministic |
137
+ | #6 row filter | Query-time | Forthcoming UI: developer / source-type / top-K filters are consumer-driven |
138
+ | #13 reshape | SQL inline | No artifact needed; comparisons embed in #3/#5/#6/#14 queries |
139
+ | #14 | Materialize | 9 columns Γ— ~587 evals; per-eval scope; no consumer slices these per-category |
140
+ | #16 | Materialize | Trivial size (≀9 categories Γ— ~5,830 models); identical for every consumer |
141
+
142
+ The pattern: **default to materialize for invariant work**; reserve query-time SQL for consumer-driven slicing (row filters, top-N where N varies, custom faceting).
143
+
144
+ ## Dependency order
145
+
146
+ ```
147
+ Cleaning items (must land FIRST β€” they emit canonical values that reshape SQL reads)
148
+ #2 setup-alias merging cleaning half β†’ variant_key column
149
+ #13 timestamp normalization cleaning β†’ ISO 8601 retrieved_timestamp
150
+ #11 category accuracy improvement β†’ meaningful category column (84% currently "other")
151
+ #8 benchmark display names β†’ benchmark_display_name column
152
+ #10 metric display name expansion β†’ metric_display_name column
153
+
154
+ Schema delta (pipeline-owner conversation; ~one PR in pipeline repo)
155
+ Promote nested fields to relational `model_results.parquet`
156
+ Add sidecar `composite_membership.parquet`
157
+ Apply pipeline-side family-membership filter at emission time
158
+
159
+ Reshape items (in roughly ascending complexity; all unblocked once schema lands)
160
+ #16 per-category counts β€” smallest; gated on #11 (category accuracy)
161
+ #14 score summary stats β€” gated on #2, #13; #4 already shipped
162
+ #3 hierarchy flatten β€” gated on #2, #13
163
+ #5 composite eval rollup β€” gated on #13, #8, #17 (benchmark-card); benefits from #14 landing first
164
+ #6 matrix leaderboard β€” gated on #13, #8, #17 (benchmark-card), #1 (identity canonicalization); benefits from #3 landing first
165
+ ```
166
+
167
+ The schema delta is the load-bearing pipeline-owner conversation. Once `model_results.parquet` exists in the right shape, **the 6 reshape items can be implemented in parallel**, each as a single SQL query in `lib/duckdb-data.ts` replacing the current `JSON.parse(payload_json) β†’ TS adapter` chain.
168
+
169
+ ## Cross-cutting TS-as-spec quirks for pipeline-owner attention
170
+
171
+ These are decisions the pipeline owner will face when implementing the schema + emission. Not "fix these" β€” "these are choices to make".
172
+
173
+ 1. **`>=` vs `>` tie-break in bucket reduction.** TS uses `>=` (last-iteration-order wins on timestamp tie) in #3, #6, #14. SQL `ROW_NUMBER() OVER (... ORDER BY retrieved_timestamp DESC)` is implementation-defined on ties unless an explicit secondary key is added. Recommendation: tie-break on `evaluation_id DESC` for stability. Document the divergence; tie-collisions are rare in production (timestamps are floats with microsecond precision).
174
+
175
+ 2. **Variant identity computed twice in #3.** `lib/hf-data.ts resolveVariantMeta` (Step 1b) and `lib/eval-processing.ts getAggregatedVariantDescriptor` (Step 3) re-derive variant identity from different inputs. They can disagree (e.g. `"20240620-thinking"` vs `"2024-06-20"`). Should reconcile to a single canonical `variant_key` once #2 cleaning lands. Likely produces subtle off-by-one variant counts today.
176
+
177
+ 3. **`evaluator_names` always `[]` on the active path.** `hfEvalDetailToSummary` initializes to `[]` and never populates. The eval-card UI's "Evaluators" pill shows 0 for every eval today β€” latent bug. Pipeline can fix-by-canonicalization (emit the sorted DISTINCT set) and accept that the pill will start showing real numbers. Flag as deferred product decision.
178
+
179
+ 4. **`models_count` divergence.** TS recomputes it post-#2-dedup; pipeline emits it pre-#2-dedup. Disagrees for any model with merged variants (notably anything with `additional_details.mode` ∈ {prompt/fc/thinking}). Resolves naturally when #2 lands and pipeline's emitted value matches TS's recomputed.
180
+
181
+ 5. **Sort direction in #5 from FIRST sub-eval's `lower_is_better`.** Fragile when a suite mixes higher-is-better and lower-is-better metrics. Order of `summaries[0]` is whatever `eval-hierarchy.json family.eval_summary_ids` lists first. Pipeline must preserve order or replicate the choice. Recommend: pick `lower_is_better=false` if any sub-eval is higher-is-better.
182
+
183
+ 6. **Two implementations of `category_stats` produce different answers** (#16). Path A (grid) is fake distribution; Path B (detail page) is real `COUNT(DISTINCT benchmark)`. Same model, two pages, two answers. Materialization eliminates both implementations.
184
+
185
+ 7. **Cell tie-break in #6 is "last in iteration order".** Whether SQL `ROW_NUMBER() ORDER BY retrieved_timestamp DESC` matches TS depends on whether pipeline emits `metric.model_results[]` in retrieved_timestamp-DESC order. **Verify this assumption before flipping the SQL on.** If pipeline order is non-deterministic, the SQL is a "freshest wins" *upgrade* over TS's "iteration order wins" β€” likely fine, but call out in the migration commit.
186
+
187
+ 8. **Score normalization in #5 happens BEFORE averaging.** `normalize(score)` per sub-eval (using each sub-eval's own min/max), then arithmetic mean across sub-evals. The "obvious" alternative (average raw scores then normalize) produces different numbers when sub-evals have different score ranges. SQL must do `AVG(per_eval.normalized_score)`, not `AVG(suite_components.normalized_score)`.
188
+
189
+ 9. **Suite-level `avg_score` in #5 is avg-of-per-model-avgs, not avg-of-all-component-scores.** Diverges when sub-eval coverage is unbalanced. SQL `AVG(per_model.avg_normalized_score)`, not `AVG(suite_components.normalized_score)`.
190
+
191
+ 10. **Empty-metric short-circuit in #14 puts a benchmark display name into `latest_source_name`.** `lib/model-data.ts:785` sets `latest_source_name = getBenchmarkDisplayName(benchmarkKey)` when an eval has zero metrics β€” a *display name* string in a *source name* field. Almost certainly a placeholder bug. Pipeline can fix-by-canonicalization (emit `null` and chase down any consumer that breaks); flag as deferred product decision.
192
+
193
+ 11. **Two GROUP BY entry points in #14 with different score-source semantics.** `groupEvaluationsByBenchmark` iterates `eval_.evaluation_results` (multi-metric possible); `hfEvalDetailToSummary` iterates `metric.model_results` of *only* the first metric. Active read path for eval-detail pages is `hfEvalDetailToSummary` β€” pipeline emission should match its single-primary-metric semantics, since that's what users see today.
194
+
195
+ ## Open questions for pipeline owner
196
+
197
+ 1. **How relational should parquet go?** Promote nested fields to typed columns (this doc's recommendation) or stay closer to current `payload_json` blob and rely on DuckDB's JSON functions? Going relational unlocks ~10Γ— more SQL work but is a bigger schema change.
198
+ 2. **Where does `composite_membership` live?** Sidecar parquet table (this doc's recommendation) or stays nested in `eval-hierarchy.json`?
199
+ 3. **Score-normalization ownership.** Pipeline pre-normalizes `score_norm` as a column, OR DuckDB does at query time using `min_score`/`max_score`/`lower_is_better` columns? This doc assumes the latter (per-row columns); pre-normalizing is also viable.
200
+ 4. **Pipeline emission order of `metric.model_results[]`.** Is it deterministic? Sorted by anything? Affects whether SQL's `ROW_NUMBER() ORDER BY retrieved_timestamp DESC` matches TS's "last-wins-iteration" semantics for #6 cell tie-breaks.
201
+ 5. **Pipeline-side family-membership filter.** This doc recommends pipeline filters at emission time (so SQL just `WHERE model_family_id = ?`). Alternative: SQL replicates the `belongsToModelFamily` set logic. Pipeline-side is much cleaner.
202
+ 6. **Variant identity reconciliation (#3 quirk #2).** Double-pass should collapse to single canonical `variant_key` once #2 cleaning lands. Pipeline owner picks the canonical rule.
203
+ 7. **`category_stats` vs `category` column for #16.** Pipeline emits `category_stats: Record<Category, number>` directly on each model card (consumer-shape), OR pipeline emits per-row `category` and DuckDB pivots at query time? Consumer-shape is simpler; query-time gives flexibility for free.
204
+
205
+ ## Migration sequencing
206
+
207
+ ### Phase 1 β€” cleaning items land (in pipeline)
208
+ Pipeline emits canonical values per the existing per-item workflow (`notes/migration-plan.md` Β§ "Per-item workflow"). Status as of 2026-04-28:
209
+
210
+ - **Specced + ready for handoff:** #2 (variant_key), #13 (ISO 8601), #8 (benchmark_display_name), #10 (metric_display_name). Plus #17 benchmark-card inlining (specced as `11-benchmark-card-attachment.md`) and #1 identity canonicalization. See per-item specs in `notes/transformations/`.
211
+ - **Blocked, not part of Phase 1:** #11 category accuracy is gated on pipeline-side classification work β€” pipeline currently emits `category: "other"` on 84% of evals. #16 reshape is gated on #11; if #11 doesn't land in time, #16 ships against the current "84% other" data and shows the limitation, OR ports `inferCategoryFromBenchmark` upstream as a workaround.
212
+
213
+ **No reshape SQL touches yet** in this phase.
214
+
215
+ ### Phase 2 β€” schema delta lands (in pipeline)
216
+ Pipeline emits `model_results.parquet` (the unifying relational table) alongside the existing `payload_json` blob. Both columns live in parquet during the parity window β€” TS continues to read `payload_json`, the new SQL reads relational columns. This is a **single PR in `eval_cards_backend_pipeline`** for the schema, plus the family-membership filter.
217
+
218
+ ### Phase 3 β€” DuckDB queries replace TS reshape, one item at a time
219
+ Per item (in any order, since they're independent once schema lands):
220
+ 1. Write SQL query in `lib/duckdb-data.ts` (alongside existing `JSON.parse(payload_json) β†’ TS adapter` path).
221
+ 2. Reshape-class snapshot test becomes the **TS-vs-SQL parity gate** (per `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum"). Snapshot is committed; SQL output is computed at test time; equality is the gate.
222
+ 3. Once parity holds across the full corpus (verified via `pnpm audit-adapters --diff`), delete the TS implementation. Callers switch to read the SQL/materialized output.
223
+
224
+ Recommended order within Phase 3 (smallest unblocked first to build confidence):
225
+ 1. **#14 score summary stats** β€” clean Phase-1 dependencies (#2, #13). Fixes the 0-evaluators latent bug, materializes 9 columns. Best first item.
226
+ 2. **#16 per-category counts** β€” smallest reshape mechanically. *Caveat:* gated on #11 category accuracy (currently blocked on pipeline-side classification work β€” pipeline emits `category: "other"` on 84% of evals). Either ship against the "84% other" data and show the limitation, OR port `inferCategoryFromBenchmark` upstream as part of Phase 1.
227
+ 3. **#3 hierarchy flatten** β€” the foundational reshape; Steps 1+2 are the unifying `model_results` use, Step 3 is the consumer surface. Clean Phase-1 dependencies.
228
+ 4. **#5 composite eval rollup** β€” eliminates the 2-471 detail-fetch fan-out. Best after #14 ships (so per-eval stats are materialized first).
229
+ 5. **#6 matrix leaderboard** β€” biggest behavior change (PIVOT); benefits from #3 + #5 patterns being in place.
230
+
231
+ ### Phase 4 β€” cleanup
232
+ Once all reshape items are SQL/materialized: drop unused fields from `payload_json` blobs (or leave for debugging). Delete the runtime `flattenModelEvaluations` β†’ `createModelFamilySummary` chain in `lib/duckdb-data.ts toModelSummary`. Update `notes/transformations/README.md` index to reflect completed items.
233
+
234
+ ## Cross-references
235
+
236
+ - `notes/migration-plan.md` Β§ "Data direction" β€” the cleaning vs reshape principle this doc operates under
237
+ - `notes/transformations/README.md` Β§ "Where it belongs" β€” per-spec classification framework
238
+ - `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum" β€” how reshape items get tested (TS-vs-SQL parity)
239
+ - `notes/transformations/02-setup-alias-merging.md` β€” reshape half of #2 (cited above)
240
+ - `notes/transformations/07-timestamp-normalization.md` β€” reshape half of #13 (cited above)
241
+ - `notes/transformations/reshape/{03,05,06,14,15,16}-*.md` β€” full per-item operation catalogs
242
+
243
+ ## Open items for follow-up
244
+
245
+ 1. **`PIPELINE_CATEGORY_MAP` location decision.** TS-side map vs pipeline-emit-mapped vs SQL `CASE`. Coupled to #11.
246
+ 2. **Audit script for TS-vs-SQL parity per reshape item** β€” pattern to be established with #14 (the first reshape to ship), then templated for #16, #3, #5, #6.
247
+ 3. **Pipeline-owner review.** This doc is a proposal; nothing is committed until pipeline owner has weighed in on the schema delta and the open questions above.
notes/transformations/reshape/03-hierarchy-flatten.md ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hierarchy flatten + family summary β€” reshape operation
2
+
3
+ Drafted 2026-04-28. Migration item #3 in `notes/migration-plan.md`. First entry in the reshape catalog (companion to nothing in `notes/transformations/` today β€” those are cleaning specs and follow a different shape). Will be referenced from the synthesis at `notes/transformations/reshape-design.md` when it lands.
4
+
5
+ ## Framing reminder
6
+
7
+ This is a **reshape**, not a cleaning item. The migration target is *not* "pipeline emits these exact `BenchmarkEvaluation[]` objects." It's "pipeline emits relational rows that DuckDB can flatten + group + dedup with a query." The TS implementation is the operational spec; the SQL replacement preserves behavior, not implementation.
8
+
9
+ Per `notes/migration-plan.md` Β§ "Data direction": cleaning belongs in pipeline emission, reshape belongs in DuckDB SQL. This item is the reference reshape. It is also the canonical example for the schema-relationality conversation β€” the existing parquet schema (one `payload_json VARCHAR` blob per record) cannot do this work; pipeline must promote the nested fields to typed columns first.
10
+
11
+ ## Operation (in SQL terms)
12
+
13
+ ### Inputs
14
+ - **Source rows:** model-summary records in `model_summaries.parquet` (one per `model_family_id`). Today: each row is `(record_type, model_route_id, model_family_id, …, payload_json)` and `payload_json` carries the entire `HFModelDetail` blob β€” `hierarchy_by_category`, `variants[]`, `raw_model_ids`, `model_info`.
15
+ - **Nested fields the operation reads (currently inside `payload_json`):**
16
+ - `hierarchy_by_category: Record<categoryKey, HFModelHierarchyNode[]>` β€” the tree. Walk recursively via `subtasks`.
17
+ - For each leaf `metric` on each node: `metric.model_results[]` β€” submission rows with `score`, `retrieved_timestamp`, `source_metadata`, `raw_model_id`, `model_id`, `model_route_id`, `model_name`, `developer`, `evaluation_id`.
18
+ - Tree context fields propagated down: `eval_summary_id`, `benchmark`, `benchmark_family_key/name`, `benchmark_parent_key/name`, `benchmark_leaf_key/name`, `display_name`, `canonical_display_name`, `category` (the category key from `hierarchy_by_category` map, mapped via `PIPELINE_CATEGORY_MAP`).
19
+ - Metric-level fields: `metric_summary_id`, `metric_key`, `metric_name`, `display_name`, `canonical_display_name`, `metric_config`, `slice_key`, `slice_name`.
20
+ - For variant identity: `detail.variants[]` (`variant_key`, `variant_label`, `raw_model_ids[]`).
21
+ - For ownership filtering: `detail.raw_model_ids[]` βˆͺ `variant.raw_model_ids[]` βˆͺ `model_info.id` βˆͺ `model_family_id`.
22
+
23
+ ### Step 1 β€” flatten the tree (one row per metric Γ— model_result)
24
+ Recursively walk `hierarchy_by_category` (root nodes per category, then `node.subtasks[]`), inheriting context (eval_summary_id, benchmark, family/parent/leaf names, source_data) down through subtask levels. For each leaf-metric-with-results, emit one row per `model_result` filtered to those that belong to this model family (see Step 1a).
25
+
26
+ Conceptual emitted shape per row:
27
+ ```
28
+ (model_family_id, eval_summary_id, category_key,
29
+ benchmark, benchmark_family_key, benchmark_parent_key, benchmark_leaf_key,
30
+ benchmark_family_name, benchmark_parent_name, benchmark_leaf_name,
31
+ display_name, canonical_display_name,
32
+ slice_key, slice_name, source_data,
33
+ metric_summary_id, metric_key, metric_name, metric_config,
34
+ evaluation_id, raw_model_id, model_id, model_route_id, model_name, developer,
35
+ score, retrieved_timestamp, source_metadata,
36
+ detailed_evaluation_results, instance_level_data,
37
+ variant_key /* resolved per Step 1b */)
38
+ ```
39
+
40
+ #### Step 1a β€” `belongsToModelFamily` filter
41
+ A `model_result` belongs to this model family iff any of:
42
+ - `normalize(result.model_route_id) == normalize(detail.model_route_id)`, OR
43
+ - `normalize(result.raw_model_id) ∈ rawModelIds`, OR
44
+ - `normalize(result.model_id) ∈ rawModelIds`
45
+
46
+ where `rawModelIds = detail.raw_model_ids βˆͺ flat(variants[].raw_model_ids) βˆͺ detail.model_info.id βˆͺ detail.model_family_id`, all lowercased + trimmed. (See `lib/hf-data.ts:1110-1131` and `:1386-1395`.)
47
+
48
+ #### Step 1b β€” `resolveVariantMeta` lookup
49
+ Given a `model_result`, derive its `variant_key`:
50
+ 1. Build a lookup `variantLookup: Map<normalized_raw_model_id, variant_key>` from `detail.variants[*].raw_model_ids`.
51
+ 2. Try `result.raw_model_id` then `result.model_id` against the lookup.
52
+ 3. If no match AND `detail.variants.length === 1` β†’ use that single variant's key/label.
53
+ 4. Else fallback to first non-empty candidate id, or `detail.model_info.variant_key`, or literal `"default"`.
54
+
55
+ This is the same `variant_key` produced by item #2 (setup-alias merging) with normalization already applied β€” so post-#2 the result of this lookup should equal the canonicalized `setup_alias_key` the pipeline emits.
56
+
57
+ ### Step 2 β€” group by `(eval_summary_id, metric_summary_id, variant_key)` within each model
58
+
59
+ Each leaf-metric Γ— variant-bucket becomes ONE `BenchmarkEvaluation`. Inside a bucket multiple `model_results` are merged:
60
+ - `evaluation_results[]` ← append every result's score record (concatenate, no dedup).
61
+ - `latestTimestamp` ← `MAX(retrieved_timestamp)` across the bucket.
62
+ - `source_metadata` ← the `source_metadata` of the row whose `retrieved_timestamp == latestTimestamp` (first one wins on ties because `>=`, see TS-quirk #1 below).
63
+ - `inlineSamples` ← first non-empty `parseInstanceLevelData(result.instance_level_data)` encountered; subsequent rows do not overwrite if `existing.inlineSamples` already has values.
64
+
65
+ The output `evaluation_id` for the merged record is `${metric.metric_summary_id}__${variantKey}`.
66
+
67
+ ### Step 3 β€” group by `family-id` and reshape into `ModelEvaluationSummary`
68
+
69
+ Take the flat `BenchmarkEvaluation[]` and:
70
+ 1. **Re-bucket by variant** (in `createModelFamilySummary`, `lib/eval-processing.ts:453-532`) using `getAggregatedVariantDescriptor(eval.model_info)`. Note: this is a SECOND variant resolution that re-derives variant identity from `model_info` rather than using the `variant_key` set in Step 1b. The two should agree post-#2; today they can disagree (see TS-quirk #2).
71
+ 2. **Per variant:** call `createModelSummary` (`lib/eval-processing.ts:280-344`) which:
72
+ - Buckets evaluations by `category` (the `BenchmarkEvaluation.category` field set in Step 1, falling back to `inferCategoryFromBenchmark(result.evaluation_name)` when missing).
73
+ - Computes `total_evaluations = SUM(evaluations[i].evaluation_results.length)`.
74
+ - Computes `last_updated = MAX(retrieved_timestamp)` rendered as ISO string.
75
+ - Lists `categories_covered = DISTINCT(category)`.
76
+ 3. **Sort variants** by `version_date DESC, total_evaluations DESC, variant_label ASC` (`sortVariants`, `lib/eval-processing.ts:436-451`).
77
+ 4. **Family-level rollup:** call `createModelSummary(allEvaluations)` β€” same shape but at the family level β€” then overlay `model_family_id`, `model_route_id`, `model_family_name` from `getCanonicalModelIdentity(evaluations[0].model_info)`, and `raw_model_ids = SORTED DISTINCT(eval.model_info.id)`.
78
+
79
+ ### Output
80
+ - Intermediate: `BenchmarkEvaluation[]` (the flat-list output of `flattenModelEvaluations`).
81
+ - Final: `ModelEvaluationSummary` (the family-rollup output of `createModelFamilySummary`).
82
+
83
+ Both are presentation shapes consumed by `lib/model-data.ts` getters and the model-detail pages.
84
+
85
+ ## Current TS implementation
86
+
87
+ | Concern | Location | Notes |
88
+ |---|---|---|
89
+ | Tree walk + per-metric flatten + variant bucket reduction | `lib/hf-data.ts:1228-1378` (`flattenHierarchyNode`) | Recursive; inherits context via `buildFlattenHierarchyContext` |
90
+ | Public entry (per-model) | `lib/hf-data.ts:1384-1414` (`flattenModelEvaluations`) | Iterates `hierarchy_by_category` keys; maps category via `PIPELINE_CATEGORY_MAP` (`lib/hf-data.ts:1425-1435`) |
91
+ | Variant lookup builder | `lib/hf-data.ts:1063-1079` (`buildVariantLookup`) | |
92
+ | Per-result variant resolver | `lib/hf-data.ts:1081-1108` (`resolveVariantMeta`) | 4-tier fallback |
93
+ | Family-membership filter | `lib/hf-data.ts:1110-1131` (`belongsToModelFamily`) | |
94
+ | Per-variant model_info synthesizer | `lib/hf-data.ts:1133-1155` (`buildModelInfoForVariant`) | |
95
+ | Inline-samples parser | `lib/hf-data.ts:933` (`parseInstanceLevelData`) | Tolerant JSON/array parser |
96
+ | Hierarchy context builder | `lib/hf-data.ts:1175-1226` (`FlattenHierarchyContext` + `buildFlattenHierarchyContext`) | Inheritance-with-defaults for tree context |
97
+ | Bucket-merge timestamp comparator | `lib/hf-data.ts:1049-1061` (`toComparableTimestamp`) | Variant B from spec #07 β€” has the `parseFloat` quirk |
98
+ | Family rollup | `lib/eval-processing.ts:453-532` (`createModelFamilySummary`) | |
99
+ | Per-summary aggregation | `lib/eval-processing.ts:280-344` (`createModelSummary`) | Buckets by category, sums totals, max-timestamp |
100
+ | Variant descriptor (re-derived) | `lib/eval-processing.ts:360-434` (`getAggregatedVariantDescriptor`) | Re-runs setup-alias logic, this time from `model_info` |
101
+ | Variant sort | `lib/eval-processing.ts:436-451` (`sortVariants`) | |
102
+
103
+ ### Caller sites
104
+ - `lib/model-data.ts:1495, 1497, 1518, 1520, 1530, 1532` β€” `getModelSummaryById` and slug-retry siblings.
105
+ - `lib/duckdb-data.ts:189-194` (`toModelSummary`) β€” DuckDB read path currently re-runs both adapters against the `payload_json` blob to match JSON-path output exactly. **This is the call site the SQL replacement targets.**
106
+ - `lib/eval-processing.ts:825` β€” batch path in `processEvaluationsToCards` (developer-aggregate flow).
107
+
108
+ ## Required parquet columns
109
+
110
+ The current parquet schema (per `notes/migration-plan.md` Β§ "Data direction"): 11 typed metadata columns + `payload_json VARCHAR`. SQL can route on the metadata columns but cannot see inside the blob. To do this operation in SQL, pipeline needs to promote the nested fields to a separate relational table.
111
+
112
+ ### Proposed: `model_results.parquet` (one row per metric Γ— model_result, post-flatten)
113
+
114
+ ```
115
+ model_family_id VARCHAR -- routing key (matches model_summaries.parquet)
116
+ model_route_id VARCHAR -- routing key (matches model_summaries.parquet)
117
+ eval_summary_id VARCHAR -- from hierarchy node
118
+ category_key VARCHAR -- raw pipeline key ("agentic", "reasoning", ...) β€” TS maps via PIPELINE_CATEGORY_MAP at read time
119
+ benchmark VARCHAR
120
+ benchmark_family_key VARCHAR
121
+ benchmark_family_name VARCHAR
122
+ benchmark_parent_key VARCHAR
123
+ benchmark_parent_name VARCHAR
124
+ benchmark_leaf_key VARCHAR
125
+ benchmark_leaf_name VARCHAR
126
+ display_name VARCHAR -- post-inheritance from buildFlattenHierarchyContext
127
+ canonical_display_name VARCHAR -- post-inheritance
128
+ slice_key VARCHAR
129
+ slice_name VARCHAR
130
+ source_data JSON -- inherited; either struct or string[]
131
+ metric_summary_id VARCHAR
132
+ metric_key VARCHAR
133
+ metric_name VARCHAR
134
+ metric_display_name VARCHAR
135
+ metric_canonical_display_name VARCHAR
136
+ metric_config JSON
137
+ evaluation_id VARCHAR -- result-level
138
+ raw_model_id VARCHAR
139
+ model_id VARCHAR
140
+ model_result_route_id VARCHAR
141
+ model_name VARCHAR
142
+ developer VARCHAR
143
+ score DOUBLE
144
+ retrieved_timestamp VARCHAR -- ISO 8601 once #13 ships; today unix-seconds-string
145
+ source_metadata JSON
146
+ detailed_evaluation_results VARCHAR
147
+ instance_level_data JSON
148
+ variant_key VARCHAR -- pre-resolved per Step 1b (depends on #2 emitting it)
149
+ ```
150
+
151
+ ### Cross-item dependencies for the schema
152
+ - **#2 setup-alias merging** β€” `variant_key` must be the *normalized* key (post-`isSetupAliasQualifier` rules). Without this, Step 1b stays in TS or in a CTE that re-derives.
153
+ - **#13 timestamp normalization** β€” `retrieved_timestamp` must be a single canonical format (recommended: ISO 8601 string, lexicographic sort = chronological sort). Until then SQL needs `epoch_ms(CAST(retrieved_timestamp AS DOUBLE) * 1000)` plus a fallback for ISO strings β€” workable but the TS quirks (Variant B's `parseFloat` bug per spec #07) become unobservable only once the format is unified.
154
+ - **#4 source-metadata** (DONE) β€” pipeline already emits `source_metadata` on every row; the typed `source_metadata JSON` column is straightforward.
155
+ - **Category map** β€” TS currently maps the raw `category_key` ("agentic" β†’ "Agentic", "coding" β†’ "General", etc.) via `PIPELINE_CATEGORY_MAP` at read time. For this reshape we recommend leaving the raw key in parquet and applying the case mapping in SQL via a small CASE expression (or keeping it client-side until #11 lands).
156
+ - **Family-membership filter** β€” Step 1a needs `raw_model_ids` set per family. Easiest path: pipeline filters at emission time so every row in `model_results.parquet` is already guaranteed to belong to its `model_family_id`. Then SQL just `WHERE model_family_id = ?` and the TS `belongsToModelFamily` logic disappears.
157
+
158
+ ### Independent of schema decisions
159
+ - **The variant bucket reduction itself** is pure SQL once timestamps are comparable; doesn't need any new schema beyond the row-per-result table.
160
+ - **`createModelSummary`'s category bucketing + totals** is also pure SQL once a row-per-result table exists.
161
+
162
+ ## Sketch SQL query
163
+
164
+ Assumes the proposed `model_results.parquet` schema, ISO-8601 timestamps, and pipeline-side family-membership filtering.
165
+
166
+ ### Step 1+2 β€” flat list, with variant-bucket dedup applied (one row per `(eval_summary_id, metric_summary_id, variant_key)`)
167
+
168
+ ```sql
169
+ WITH
170
+ results AS (
171
+ SELECT *
172
+ FROM read_parquet('model_results.parquet')
173
+ WHERE model_family_id = ? -- single-model query
174
+ ),
175
+ -- Within a (eval_summary_id, metric_summary_id, variant_key) bucket, pick the
176
+ -- row whose retrieved_timestamp is freshest. Aggregate the rest as arrays.
177
+ freshest AS (
178
+ SELECT *
179
+ FROM results
180
+ QUALIFY ROW_NUMBER() OVER (
181
+ PARTITION BY eval_summary_id, metric_summary_id, variant_key
182
+ ORDER BY retrieved_timestamp DESC, evaluation_id DESC -- tie-break stable
183
+ ) = 1
184
+ ),
185
+ bucket_results AS (
186
+ SELECT
187
+ eval_summary_id,
188
+ metric_summary_id,
189
+ variant_key,
190
+ list({
191
+ evaluation_name: metric_name,
192
+ display_name: metric_display_name,
193
+ canonical_display_name: metric_canonical_display_name,
194
+ metric_summary_id: metric_summary_id,
195
+ metric_key: metric_key,
196
+ evaluation_timestamp: retrieved_timestamp,
197
+ source_data: source_data,
198
+ metric_config: metric_config,
199
+ score_details: { score: score },
200
+ detailed_evaluation_results_url: detailed_evaluation_results
201
+ } ORDER BY retrieved_timestamp) AS evaluation_results,
202
+ -- first non-empty inline-samples in insertion order
203
+ list_filter(list(instance_level_data ORDER BY retrieved_timestamp), x -> x IS NOT NULL)[1]
204
+ AS inline_samples
205
+ FROM results
206
+ GROUP BY eval_summary_id, metric_summary_id, variant_key
207
+ )
208
+ SELECT
209
+ '0.2.2' AS schema_version,
210
+ f.eval_summary_id,
211
+ f.metric_summary_id || '__' || f.variant_key AS evaluation_id,
212
+ f.retrieved_timestamp,
213
+ f.benchmark,
214
+ f.display_name,
215
+ f.canonical_display_name,
216
+ f.category_key AS category, -- map to CategoryType in app layer or via CASE here
217
+ f.benchmark_family_key, f.benchmark_family_name,
218
+ f.benchmark_parent_key, f.benchmark_parent_name,
219
+ f.benchmark_leaf_key, f.benchmark_leaf_name,
220
+ f.slice_key, f.slice_name,
221
+ f.source_data,
222
+ f.source_metadata,
223
+ -- model_info synthesized per buildModelInfoForVariant
224
+ struct_pack(
225
+ id := COALESCE(f.raw_model_id, f.model_id),
226
+ name := f.model_name,
227
+ developer := f.developer,
228
+ model_version := CASE WHEN f.variant_key <> 'default' THEN f.variant_key ELSE NULL END
229
+ ) AS model_info,
230
+ br.evaluation_results,
231
+ br.inline_samples AS detailed_evaluation_results_per_samples
232
+ FROM freshest f
233
+ JOIN bucket_results br USING (eval_summary_id, metric_summary_id, variant_key);
234
+ ```
235
+
236
+ ### Step 3 β€” family summary (rolling up the flat list)
237
+
238
+ ```sql
239
+ WITH flat AS ( /* the query above */ ),
240
+ per_variant AS (
241
+ SELECT
242
+ variant_key,
243
+ MAX(retrieved_timestamp) AS last_updated,
244
+ SUM(len(evaluation_results)) AS total_evaluations,
245
+ array_agg(DISTINCT category) AS categories_covered,
246
+ -- evaluations grouped by category as a struct of arrays
247
+ map_from_entries(
248
+ array_agg(struct_pack(category := category, eval := flat))
249
+ OVER (PARTITION BY variant_key) -- pseudocode; real shape uses GROUP BY + LIST_AGG
250
+ ) AS evaluations_by_category
251
+ FROM flat
252
+ GROUP BY variant_key
253
+ ),
254
+ family AS (
255
+ SELECT
256
+ MAX(retrieved_timestamp) AS last_updated,
257
+ SUM(len(evaluation_results)) AS total_evaluations
258
+ FROM flat
259
+ )
260
+ SELECT * FROM per_variant, family;
261
+ ```
262
+
263
+ (The `evaluations_by_category` map is awkward in SQL β€” most consumers will end up assembling it client-side from the flat-list query. That's fine; the operationally important reshape is Steps 1+2.)
264
+
265
+ ## Materialize vs query-time
266
+
267
+ - **Materialize upstream if:** the answer is identical for every consumer. The flat `model_results` table absolutely should be materialized β€” every consumer of model-detail pages needs it, and re-walking the tree on every request is exactly what we're trying to leave behind.
268
+ - **Query-time SQL if:** consumers slice differently. Step 3 (family summary) is consumer-shape β€” different pages want different slices (per-category, per-variant, per-benchmark). Run it as a query.
269
+
270
+ **Recommendation: materialize Steps 1+2 (the flat post-bucket-reduction table) into `model_results.parquet`; compute Step 3 (family summary, category bucketing) at query time.**
271
+
272
+ Rationale: the tree-walk + variant-bucket reduction is invariant work that every model-detail page needs identically β€” pre-computing it removes the recursive walk from the request hot path. The category bucketing and variant rollup, by contrast, are presentation-shape transforms that vary per page (model-detail wants per-variant, eval-detail wants per-benchmark, etc.) β€” keeping them in SQL lets each consumer slice without paying for the others' shapes.
273
+
274
+ A nice property: this split also lets `eval_summaries.parquet` and `developer_summaries.parquet` reuse the same `model_results.parquet` rows β€” they're currently re-walking the same tree from their own angles.
275
+
276
+ ## TS-as-spec quirks
277
+
278
+ The reshape SQL must preserve these or explicitly defer them as product decisions. Don't unilaterally fix.
279
+
280
+ 1. **`>=` not `>` in the bucket-merge timestamp comparison.** `lib/hf-data.ts:1310-1313`: when a later result ties an earlier result's timestamp exactly, the LATER one wins (overwrites `latestTimestamp` and `sourceMetadata`). With `ROW_NUMBER() … ORDER BY retrieved_timestamp DESC` SQL gets implementation-defined tie-breaking unless you add an explicit secondary sort. Recommendation: tie-break on `evaluation_id DESC` (or another stable secondary key) and document that it differs from TS's "later in iteration order wins" for cross-bucket ties. Quantify on actual data before shipping; if collisions are rare, the divergence is acceptable.
281
+
282
+ 2. **Variant identity is computed twice with potentially different rules.** Step 1b (`resolveVariantMeta` in `lib/hf-data.ts`) uses `variants[].raw_model_ids` lookup β€” strict id match. Step 3 (`getAggregatedVariantDescriptor` in `lib/eval-processing.ts:394-434`) re-derives from `model_info.additional_details.mode` + `getCanonicalModelIdentity`. These can disagree: e.g. a result whose Step-1b `variant_key` is `"20240620-thinking"` becomes Step-3 `variant_key: "2024-06-20"` (setup-alias merging collapses the qualifier). The SQL replacement should produce a single canonical `variant_key` per result post-#2 and not re-bucket later. Today's TS double-bucketing is a likely source of subtle off-by-one variant counts; capture it as observed behavior and decide the desired single-pass semantics with the pipeline owner before reshaping.
283
+
284
+ 3. **Inline-samples merge is "first non-empty wins, no overwrite"**, NOT "freshest wins." If the first result emitted into a bucket has no `instance_level_data` and a later result does, that later one's samples are kept. If both have samples, the FIRST one's are kept regardless of timestamp. This is a separate code path from the timestamp-keyed metadata merge. SQL replacement preserves this with `list_filter(list(instance_level_data ORDER BY <iteration order>), x -> x IS NOT NULL)[1]` β€” but iteration order in TS is `metric.model_results[]` array order from the parquet, which SQL needs an explicit sort to mimic. This is fragile; flag for synthesis.
285
+
286
+ 4. **Category in Step 1 is the per-node `category_key` from `hierarchy_by_category` keys, not from each `model_result`.** Two results landing in different category branches but for the same `(metric_summary_id, variant_key)` pair would produce two separate `BenchmarkEvaluation` entries, not one merged. This is structural to how the tree walk works; SQL needs to include `category_key` (or `eval_summary_id`, which is per-leaf) in the GROUP BY.
287
+
288
+ 5. **`PIPELINE_CATEGORY_MAP` lossy collapses.** `coding`, `instruction_following`, `language_understanding` all map to `"General"`. This is intentional (per the comment at `lib/hf-data.ts:1419-1424`) until #11 (category accuracy) ships. The SQL should preserve the same mapping at read time, or push the map into a typed column emitted by pipeline.
289
+
290
+ ## Cross-item dependencies
291
+
292
+ ### Cleaning items that must land first
293
+ - **#2 setup-alias merging** β€” provides the canonical `variant_key` Step 1b currently re-derives. Without it, the SQL needs a CTE that re-runs the alias-qualifier rules in DuckDB (workable but ugly).
294
+ - **#13 timestamp normalization** β€” provides comparable `retrieved_timestamp` so `MAX()` / `ROW_NUMBER() ORDER BY` are correct. Today's mixed-format strings sort lexicographically wrong (`"1774096306"` < `"2024-..."`).
295
+ - **#4 source-metadata** β€” DONE. Step 2's "freshest source_metadata wins" needs every row to carry it; this is already true.
296
+
297
+ ### Reshape items this feeds
298
+ - **#5 composite eval rollup** (`aggregateBenchmarkSummaries`) β€” also walks model evaluations per benchmark; would consume the flat `model_results` table directly rather than re-walking trees.
299
+ - **#6 matrix leaderboard synthesis** β€” same.
300
+ - **#14 score summary stats** β€” finalizes the per-benchmark grouping; consumes the flat table.
301
+
302
+ ### Pipeline-side prerequisites
303
+ - Pipeline must emit a relational `model_results.parquet` (or equivalent table) with the columns above. This is the schema-relationality conversation flagged in `notes/migration-plan.md`.
304
+ - Pipeline-side family-membership filter (so the SQL doesn't have to replicate `belongsToModelFamily`).
305
+ - Decide where `PIPELINE_CATEGORY_MAP` lives (pipeline emits mapped string vs SQL CASE vs TS-side map).
306
+
307
+ ## Migration checklist
308
+
309
+ - [x] Operation cataloged
310
+ - [ ] Schema delta proposed (in synthesis doc `notes/transformations/reshape-design.md`)
311
+ - [ ] SQL query reviewed by pipeline owner
312
+ - [ ] Pipeline emits `model_results.parquet` with the relational columns
313
+ - [ ] DuckDB query implemented in `lib/duckdb-data.ts` (replaces `toModelSummary`'s `flattenModelEvaluations(payload) β†’ createModelFamilySummary(...)` chain at line 189-194)
314
+ - [ ] Parity test (TS vs SQL output) passes β€” see `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum" for the snapshot-as-parity-gate pattern. Snapshot lives at `tests/adapters/flatten-model-evaluations.test.ts` (per the deferred plan in testing-strategy.md Β§ "Test-additions deferred to specific migration items") and `tests/adapters/create-model-family-summary.test.ts`.
315
+ - [ ] TS code deleted: `flattenHierarchyNode`, `flattenModelEvaluations`, `buildFlattenHierarchyContext`, `buildVariantLookup`, `resolveVariantMeta`, `belongsToModelFamily`, `buildModelInfoForVariant` in `lib/hf-data.ts`; `createModelFamilySummary`, `createModelSummary`, `getAggregatedVariantDescriptor`, `sortVariants` in `lib/eval-processing.ts`. Callers in `lib/model-data.ts:1495-1532` and `lib/eval-processing.ts:825` switch to the SQL-backed reader.
316
+
317
+ ## Future product decisions (deferred)
318
+
319
+ - Whether the double-pass variant resolution (Step 1b vs Step 3) should be reconciled to a single `variant_key`. Likely yes once #2 lands; flag for the synthesis.
320
+ - Whether `>=` (TS today) or `>` (more conventional "first wins ties") is the desired bucket-merge tie-break. Current TS quirk is observable but rare in production.
321
+ - Whether `evaluations_by_category` should be re-shaped client-side from a flat-list query or carried as a nested column. The latter is awkward in SQL/Parquet; recommendation is the former.
notes/transformations/reshape/05-composite-eval-rollup.md ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Composite eval rollup
2
+
3
+ Drafted 2026-04-28. Migration item #5 in `notes/migration-plan.md`. Reshape-class operation catalog (not a rule-table replication). See `notes/transformations/README.md` Β§ "Where it belongs: cleaning vs reshape" and `notes/migration-plan.md` Β§ "Data direction" for the framing this spec follows.
4
+
5
+ ## Framing reminder
6
+
7
+ This is a **reshape** item, not a cleaning item. The output captures the *operation* (group-by keys, aggregation, ordering) so the parquet-schema + SQL conversation can happen. We are not line-by-line porting `aggregateBenchmarkSummaries` into Python; we're describing what the SQL needs to compute. TS-as-is is the spec for behaviour β€” including the score-normalization-then-average choice β€” but the implementation target is SQL or pre-materialized parquet, not a TS-shaped Python function.
8
+
9
+ ## Migration item
10
+
11
+ - **Item:** #5 β€” composite eval rollup (`/evals/aggregate__<suite_key>` route).
12
+ - **TS implementation:** `lib/model-data.ts:877-1044` (`aggregateBenchmarkSummaries`, ~168 lines).
13
+ - **Trigger / call site:** `lib/model-data.ts:1543-1568` (`getEvalSummaryById`, the `evalId.startsWith("aggregate__")` branch). Fired by the route handler at `app/evals/[id]/page.tsx` whenever the URL is `/evals/aggregate__<suite_key>`.
14
+ - **Per-request cost (today):** for an aggregate URL, TS calls `fetchHFEvalDetail(eval_summary_id)` once per sub-eval (lines 1558-1563 do `Promise.all(matchingEvals.map(...))`). Typical composite is **2-21 sub-evals** (per `eval-hierarchy.json`: `reward_bench` 2, `livecodebenchpro` 3, `fibble_arena` 5, `helm_capabilities` 6, `helm_safety` 6, `multi_swe_bench` 6, `helm_lite` 10, `helm_classic` 15, `artificial_analysis_llms` 21). One outlier family `llm_stats` has 471 sub-evals. Each sub-eval detail file is the fully-expanded per-model results blob. This happens at request time, on every page view, with no caching beyond the underlying `fetchHFEvalDetail` LRU.
15
+ - **Aggregate URL β†’ suite_key:** strip the `aggregate__` prefix; the remainder is matched against `eval-hierarchy.json` family keys via `e.benchmark.toLowerCase().replace(/[-.\s]+/g, "_").replace(/^_+|_+$/g, "")` to find sub-evals (`lib/model-data.ts:1550-1552`).
16
+
17
+ ## Operation in SQL terms
18
+
19
+ For a given `suite_key` (composite), produce one row per `(suite_key, model_id)` containing:
20
+
21
+ 1. The per-model average of normalized sub-eval scores (`AVG(normalize(score, min_score, max_score))` across the suite's sub-evals).
22
+ 2. Latest evaluation timestamp + source metadata across that model's components (winner of `MAX(retrieved_timestamp)` is "latest component"; its `source_metadata`, `source_data`, `result.*` are inherited).
23
+ 3. A pre-rolled `aggregate_components[]` list of the per-sub-eval contributions (raw score, normalized score, source attribution) used by `eval-detail.tsx` to render the per-row drill-down.
24
+
25
+ Then, suite-level rollups on top of those per-model rows:
26
+
27
+ 4. `models_count = COUNT(DISTINCT model_id)`.
28
+ 5. `avg_score = AVG(per_model_avg_normalized_score)` (avg-of-avgs; see TS quirk #2 below).
29
+ 6. `best_model` / `worst_model` = first / last after sorting by `avg_score_normalized` (DESC if `lower_is_better=false`, ASC if true). The sort key is the lower-cased single-metric direction taken from the **first sub-eval's** `metric_config.lower_is_better` (TS quirk #4).
30
+ 7. `evaluator_names = sorted DISTINCT UNION` of every sub-eval's `evaluator_names`.
31
+ 8. `source_types = sorted DISTINCT UNION` of every sub-eval's `source_types`.
32
+ 9. `third_party_ratio = SUM(third_party result rows) / SUM(all underlying result rows)` across all sub-evals' `model_results` arrays (computed pre-rollup, over raw rows β€” not over the rolled-up models).
33
+ 10. `missing_generation_config_count = SUM(...)` across sub-evals.
34
+ 11. `latest_source_name`: when there's exactly one sub-eval, copy its name; when there are multiple, the literal string `"Multiple sources"` (TS quirk #5).
35
+ 12. A `metric_config` synthesized from the first sub-eval's metric_config but with `min_score=0`, `max_score=1`, `unit="normalized average"`, and `evaluation_description = "Average normalized score across <list of sub-eval names sorted A-Z>"` when more than one source.
36
+
37
+ Steps 4-12 are scalar/vector reductions over the result of steps 1-3.
38
+
39
+ ## Required parquet columns
40
+
41
+ To do this in SQL, the pipeline needs (a) a relational result table and (b) a relational composite-membership table:
42
+
43
+ ### Table A: `result_rows` (one row per (eval_summary_id, model_id, variant, retrieved_timestamp))
44
+
45
+ Existing logical fields, promoted from `payload_json`:
46
+
47
+ | Column | Type | Source |
48
+ |---|---|---|
49
+ | `eval_summary_id` | VARCHAR | already in metadata column |
50
+ | `model_id` | VARCHAR | currently nested in `model_results[].model_info.id` |
51
+ | `score` | DOUBLE | currently nested in `model_results[].score` |
52
+ | `retrieved_timestamp` | TIMESTAMP (ISO) | nested; see reshape spec #07 for canonicalization |
53
+ | `evaluator_relationship` | VARCHAR | nested in `model_results[].source_metadata` |
54
+ | `source_name` | VARCHAR | nested in `model_results[].source_metadata` |
55
+ | `source_type` | VARCHAR | nested in `model_results[].source_metadata` |
56
+ | `source_organization_name` | VARCHAR | nested in `model_results[].source_metadata` |
57
+ | `sample_size` | BIGINT | nested in `model_results[].score_details.sample_size` |
58
+ | `missing_generation_config` | BOOLEAN | implied β€” currently surfaced as a count field on the eval summary |
59
+
60
+ ### Table B: `eval_metric_config` (one row per eval_summary_id)
61
+
62
+ | Column | Type | Source |
63
+ |---|---|---|
64
+ | `eval_summary_id` | VARCHAR | key |
65
+ | `metric_min_score` | DOUBLE | currently nested in `metric_config.min_score` |
66
+ | `metric_max_score` | DOUBLE | currently nested in `metric_config.max_score` |
67
+ | `lower_is_better` | BOOLEAN | currently nested in `metric_config.lower_is_better` |
68
+ | `evaluation_name` | VARCHAR | already on summary; needed for the sort and join |
69
+ | `category` | VARCHAR | already in metadata column |
70
+
71
+ ### Table C: `composite_membership` (one row per (suite_key, sub_eval_summary_id))
72
+
73
+ | Column | Type | Source |
74
+ |---|---|---|
75
+ | `suite_key` | VARCHAR | family `key` from `eval-hierarchy.json` (e.g. `helm_lite`) |
76
+ | `eval_summary_id` | VARCHAR | each entry of family.eval_summary_ids |
77
+ | `suite_display_name` | VARCHAR | family `display_name` (currently TS overrides via `getBenchmarkDisplayName(suite_key)` lookup; see TS quirk #6) |
78
+
79
+ This table is the new structural artifact the pipeline owes us. Today the pipeline has the data in `eval-hierarchy.json` but it's nested JSON; promoting to a relational table is what unlocks the `GROUP BY` below.
80
+
81
+ ## Sketch SQL query
82
+
83
+ Two scenarios as the prompt asked: (a) sub-eval rows live relationally, (b) composite membership lives relationally. Both apply here.
84
+
85
+ ```sql
86
+ -- Per-model component-level rows for the requested composite
87
+ WITH suite_components AS (
88
+ SELECT
89
+ cm.suite_key,
90
+ cm.suite_display_name,
91
+ r.eval_summary_id,
92
+ emc.evaluation_name AS sub_eval_name,
93
+ r.model_id,
94
+ r.score,
95
+ r.retrieved_timestamp,
96
+ r.source_name,
97
+ r.source_type,
98
+ r.source_organization_name,
99
+ r.evaluator_relationship,
100
+ r.sample_size,
101
+ -- TS quirk #1: normalize FIRST, average LATER
102
+ CASE
103
+ WHEN (emc.metric_max_score - emc.metric_min_score) > 0
104
+ THEN (r.score - emc.metric_min_score) / (emc.metric_max_score - emc.metric_min_score)
105
+ ELSE r.score
106
+ END AS normalized_score,
107
+ emc.lower_is_better
108
+ FROM composite_membership cm
109
+ JOIN result_rows r USING (eval_summary_id)
110
+ JOIN eval_metric_config emc USING (eval_summary_id)
111
+ WHERE cm.suite_key = ? -- the param from /evals/aggregate__<suite_key>
112
+ ),
113
+
114
+ -- Per-(suite, model) rollup
115
+ per_model AS (
116
+ SELECT
117
+ suite_key,
118
+ model_id,
119
+ AVG(normalized_score) AS avg_normalized_score,
120
+ SUM(COALESCE(sample_size, 0)) AS total_sample_size,
121
+ -- "Latest component" wins for source_metadata + result fields
122
+ arg_max(STRUCT_PACK(
123
+ source_name, source_type, source_organization_name,
124
+ evaluator_relationship
125
+ ), retrieved_timestamp) AS latest_source_metadata,
126
+ MAX(retrieved_timestamp) AS evaluation_timestamp,
127
+ -- aggregate_components[] for drill-down rendering
128
+ list(STRUCT_PACK(
129
+ eval_summary_id,
130
+ composite_benchmark_name := sub_eval_name,
131
+ score, normalized_score, retrieved_timestamp,
132
+ source_name, source_type, source_organization_name, evaluator_relationship
133
+ ) ORDER BY sub_eval_name) AS aggregate_components
134
+ FROM suite_components
135
+ GROUP BY suite_key, model_id
136
+ ),
137
+
138
+ -- Suite-level rollup on top
139
+ suite_summary AS (
140
+ SELECT
141
+ suite_key,
142
+ COUNT(DISTINCT model_id) AS models_count,
143
+ AVG(avg_normalized_score) AS avg_score, -- TS quirk #2: avg-of-avgs
144
+ SUM(third_party_count) AS total_third_party,
145
+ SUM(underlying_count) AS total_underlying
146
+ FROM (
147
+ SELECT
148
+ suite_key, model_id, avg_normalized_score,
149
+ COUNT(*) FILTER (WHERE evaluator_relationship = 'third_party') AS third_party_count,
150
+ COUNT(*) AS underlying_count
151
+ FROM suite_components
152
+ GROUP BY suite_key, model_id, avg_normalized_score
153
+ )
154
+ GROUP BY suite_key
155
+ )
156
+
157
+ -- Final shape: per-model rows ordered by score (direction depends on lower_is_better
158
+ -- of the FIRST sub-eval, see TS quirk #4)
159
+ SELECT
160
+ pm.*,
161
+ ss.models_count,
162
+ ss.avg_score,
163
+ ss.total_third_party::DOUBLE / NULLIF(ss.total_underlying, 0) AS third_party_ratio
164
+ FROM per_model pm
165
+ JOIN suite_summary ss USING (suite_key)
166
+ ORDER BY pm.avg_normalized_score DESC; -- flip to ASC if first sub-eval is lower_is_better
167
+ ```
168
+
169
+ The `evaluator_names` and `source_types` unions, the `aggregate_sources[]` list, and the `missing_generation_config_count` are scalar reductions on top β€” straightforward and omitted from the sketch.
170
+
171
+ ## Materialize vs query-time
172
+
173
+ **Recommendation: materialize.** Pipeline emits one row per `(suite_key, model_id)` into a new parquet table (`composite_eval_rollup` or equivalent) plus the suite-level rollup as a sibling table. Runtime DuckDB just does `SELECT * WHERE suite_key = ?` β€” no JOIN, no AVG, no normalization at request time.
174
+
175
+ Rationale:
176
+
177
+ - **The answer is identical for every consumer.** No user-driven slicing on top of the composite (no per-category filtering, no per-developer cut). The aggregate page renders the same table to everyone who hits the same suite URL.
178
+ - **Composite count is small.** ~13 multi-eval families today, growing slowly; cheap to recompute on every pipeline run.
179
+ - **Sub-eval fan-out is the single biggest per-request cost on this route.** Today's TS path issues 2-21 (occasionally 471) `fetchHFEvalDetail` calls per page view. Materialization eliminates them entirely.
180
+ - **Score-normalization choice is a product decision.** Baking it into the parquet means the choice is committed once at pipeline build time. Anyone consuming the column gets the canonical answer; no consumer needs to remember "normalize before averaging."
181
+
182
+ Honest tradeoff:
183
+
184
+ - **Pipeline-side recompute on every run.** Pipeline already does a full rebuild (see `migration-plan.md` "Cross-repo coordination"), so this is "another job in the existing batch," not "a new orchestration burden."
185
+ - **Schema growth.** Adds two new tables (per-model rollup + suite-level rollup) plus a new `aggregate_components[]` STRUCT column. Worth it given the alternative is JSON-blob extraction at every request.
186
+ - **Query-time would be viable** if we wanted to support arbitrary user-specified composites (e.g. "build me an aggregate of `mmlu_pro` + `gpqa` + `humaneval`"). We don't have that product feature today and there are no signals we will. If we add it, then `suite_components` CTE above runs query-time over the relational `result_rows`/`eval_metric_config` tables; that's still cheaper than the current TS fan-out.
187
+
188
+ ## TS-as-spec quirks
189
+
190
+ These are TS choices the pipeline must reproduce. Don't "fix" them β€” capture, ship, talk later.
191
+
192
+ 1. **Score normalization happens BEFORE averaging.** `lib/model-data.ts:937-941`: `components.map(... normalizeSummaryScore(summary, modelResult.score)) ... reduce(...) / length`. This is min-max normalization per sub-eval (using each sub-eval's own `metric_config.min_score` and `metric_config.max_score`), then arithmetic mean across sub-evals. The "obvious" alternative β€” average raw scores then normalize once β€” would produce different numbers when sub-evals have different score ranges (which is the whole point of normalizing). TS's order is correct for cross-metric aggregation; the SQL must do the same. Captured in the sketch CTE: `normalize` lives in `suite_components`, `AVG` lives in `per_model`.
193
+
194
+ 2. **Suite-level `avg_score` is avg-of-per-model-avgs, not avg-of-all-component-scores.** `lib/model-data.ts:990-991`: `aggregatedModelResults.reduce((sum, r) => sum + r.score, 0) / aggregatedModelResults.length`. With unbalanced sub-eval coverage (some models present in only some sub-evals), the two formulations diverge. TS picks the per-model-mean grouping; SQL must do `AVG(per_model.avg_normalized_score)`, NOT `AVG(suite_components.normalized_score)`.
195
+
196
+ 3. **"Latest component wins" for the per-model `evaluation_timestamp` and `source_metadata`.** `lib/model-data.ts:943-947`: sort components DESC by `normalizeEvalTimestamp(evaluation_timestamp)`, take the first. The aggregate row's `result.*`, `source_metadata`, `source_data` all inherit from that single latest sub-eval. So a model that has 6 sub-evals rolled up shows the source metadata of whichever sub-eval was most recent, not a synthesized view. (Note the dependency on reshape spec #07 for timestamp normalization β€” see "Cross-item dependencies".)
197
+
198
+ 4. **Sort direction comes from the FIRST sub-eval's `lower_is_better`.** `lib/model-data.ts:987-988`: `const lowerIsBetter = first.metric_config.lower_is_better`. If the suite mixes higher-is-better and lower-is-better metrics (rare today but possible β€” pipeline doesn't enforce homogeneity), TS picks whichever direction `summaries[0]` happens to use. The order of `summaries` is whatever `getEvalSummaryById` produces from `Promise.all(matchingEvals.map(...))`, which is the order of `eval-hierarchy.json` family.eval_summary_ids. Pipeline must preserve that order or replicate the choice (e.g. "pick `lower_is_better=false` if any sub-eval is higher-is-better").
199
+
200
+ 5. **`latest_source_name` is the literal string `"Multiple sources"` when len > 1.** `lib/model-data.ts:1021-1022`. Single-sub-eval composites get the real name; multi-sub-eval composites get a fixed sentinel. Don't try to be smart and concatenate names β€” the consumer (`eval-detail.tsx:540-541`) already does that separately from the `aggregate_sources[]` array.
201
+
202
+ 6. **Suite display name comes from the `BENCHMARK_NAMES` lookup, NOT from `eval-hierarchy.json`'s `display_name`.** `lib/model-data.ts:903`: `getBenchmarkDisplayName(aggregationKey)` falls through to the `humanizeToken` fallback if not in the hand-curated map. This is migration item #8 (benchmark display names) β€” capture as a dependency, but in the rollup itself the display name is derived from the `suite_key` not joined from the hierarchy.
203
+
204
+ 7. **Within-composite sub-eval ordering is alphabetical by `composite_benchmark_name`.** Both `aggregateSources` (line 900) and per-model `aggregate_components` (line 962) are `.sort((a, b) => a.composite_benchmark_name.localeCompare(b.composite_benchmark_name))`. Stable English-locale sort. SQL `ORDER BY sub_eval_name` reproduces this.
205
+
206
+ 8. **`composite_benchmark_name` for each component uses the SUB-EVAL's own `evaluation_name`, not the parent suite name.** `lib/model-data.ts:894`: `composite_benchmark_name: summary.evaluation_name`. The field name is misleading; in the per-component context it means "the sub-eval's own display name" (this is what the drill-down UI in `eval-detail.tsx:1046+` renders).
207
+
208
+ 9. **`metric_config` for the aggregate is synthesized.** `lib/model-data.ts:924-933`: takes `first.metric_config` (i.e. first sub-eval's), then forces `min_score=0`, `max_score=1`, `unit="normalized average"`, and rewrites `evaluation_description` to `"Average normalized score across <comma-separated sorted sub-eval names>"` when more than one source. The `lower_is_better` and `score_type` are inherited from the first sub-eval verbatim β€” so if the composite mixes `binary` and `continuous` sub-evals, the aggregate is reported as whatever the first one is.
209
+
210
+ ## Cross-item dependencies
211
+
212
+ - **#7 timestamp normalization** β€” the "latest component wins" logic (TS quirk #3) calls `normalizeEvalTimestamp` (Variant A from the timestamp spec). Once timestamps are ISO 8601 upstream and the reshape half lives in SQL, `MAX(retrieved_timestamp)` and `arg_max(..., retrieved_timestamp)` replace the TS sort. Order of operations: timestamp canonicalization should land first (or co-land), so the SQL's lexicographic `MAX` is correct.
213
+ - **#8 benchmark display names** β€” `getBenchmarkDisplayName(suite_key)` provides the suite's user-facing label (TS quirk #6). When #8 ships and the pipeline emits canonical display names, the rollup table can drop `suite_display_name` and join against the canonical table.
214
+ - **#3 hierarchy flatten** — the family→sub-eval mapping (`composite_membership` table above) is what `eval-hierarchy.json` already encodes as nested JSON. #3 will likely promote the hierarchy to a relational table; this rollup spec depends on that promotion (or a sidecar table built specifically for composites).
215
+ - **#11 benchmark-card attachment** β€” TS calls `attachBenchmarkCardToSummary(hfEvalDetailToSummary(detail))` per sub-eval before passing to `aggregateBenchmarkSummaries` (line 1562). The `benchmark_card` of `summaries[0]` becomes the aggregate's `benchmark_card`. Once benchmark cards are inlined upstream by #11, this attachment step disappears.
216
+ - **Composite of all four**: only when 7+8+3 (and ideally 11) are landed can the whole rollup move to materialized parquet cleanly. Without 7 the latest-component logic is brittle; without 3 there's no clean way to drive the rollup loop on the pipeline side; without 8 the suite display name has to be a TS lookup post-hoc.
217
+
218
+ ## Migration checklist
219
+
220
+ - [x] Spec written (operation captured in SQL terms; TS quirks documented)
221
+ - [ ] Pipeline-schema conversation: decide whether to (a) promote `result_rows`/`eval_metric_config` to relational columns and `composite_membership` to a sidecar table, then materialize the rollup, or (b) ship a single pre-computed `composite_eval_rollup` parquet that bakes in TS's choices as columns. Recommendation: (b) for the rollup itself, with (a) as a parallel deliverable so other reshape items (matrix, top-scores, summary stats) can share the relational base.
222
+ - [ ] Pipeline emits the materialized rollup; verify per-model `avg_normalized_score`, suite `avg_score`, `latest_source_metadata`, sort order, `aggregate_components[]` for at least the 13 multi-eval families.
223
+ - [ ] Update `getEvalSummaryById` to read the rollup directly when `evalId.startsWith("aggregate__")` instead of fanning out `fetchHFEvalDetail` calls.
224
+ - [ ] Delete `aggregateBenchmarkSummaries` (`lib/model-data.ts:877-1044`) and the `Promise.all` fan-out in `getEvalSummaryById` (`lib/model-data.ts:1543-1568`).
225
+
226
+ ## Future product decisions (deferred)
227
+
228
+ - Whether to support **user-defined composites** (build an aggregate from arbitrary sub-evals at request time). Today the answer is no; if it becomes yes, the relational `result_rows`/`eval_metric_config` path becomes the load-bearing one and the materialized rollup becomes the cached fast-path for the curated 13.
229
+ - Whether to expose **non-normalized averages** alongside the normalized one. TS hides the raw average; consumers asking "what's the actual MMLU score?" have to look at individual sub-evals. A pre-materialized rollup makes both columns equally cheap to surface.
230
+ - Whether the **`avg_score = avg-of-per-model-avgs`** choice (TS quirk #2) is the right one when sub-eval coverage is unbalanced. Don't fix in this migration.
notes/transformations/reshape/06-matrix-leaderboard.md ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Matrix leaderboard synthesis
2
+
3
+ Drafted 2026-04-28. Migration item #6 in `notes/migration-plan.md`. **Reshape-class** β€” per `notes/migration-plan.md` Β§ "Data direction", the destination is DuckDB SQL (either materialized into parquet or computed at query time), not a pipeline value-emit.
4
+
5
+ ## Framing reminder
6
+
7
+ We are **refactoring for UI efficiency**, not fixing correctness. TS-as-is is the canonical spec β€” including its filters and tie-breakers. The deliverable here is an **operation catalog** (GROUP BY keys, aggregations, joins, PIVOT shape) rather than a rule-by-rule replication, so the pipeline-schema / SQL conversation can happen against a precise target.
8
+
9
+ ## Migration item
10
+
11
+ - **Item:** #6 matrix leaderboard synthesis
12
+ - **TS location:** `lib/model-data.ts:1046-1210` (`buildSingleMetricSuiteMatrixSummary`, ~165 lines), called from `lib/model-data.ts:1570-1596` inside `getEvalSummaryById`
13
+ - **Trigger:** request to `/evals/matrix__<suite_key>` (URL-side prefix `matrix__` is matched at line 1570; `suite_key` is the `benchmark_parent_key` / `benchmark_family_key` / `benchmark` of a group of sub-evals)
14
+ - **Output:** a synthetic `BenchmarkEvalSummary` whose `leaderboard_metrics` are columns (subtasks) and `leaderboard_rows` are rows (models Γ— score per subtask)
15
+
16
+ ## Classification
17
+
18
+ - **Reshape / dedup / aggregate / pivot.** Not a value cleanup — it's a long→wide pivot over many sub-eval `model_results` rows.
19
+ - **Both materialize and query-time are viable, with a split** (see "Materialize vs query-time" below).
20
+
21
+ ## Operation in SQL terms
22
+
23
+ Conceptually the operation is: **for each suite, PIVOT every (sub-eval, model) score into a model Γ— subtask matrix, taking the most-recent submission per cell.**
24
+
25
+ In one sentence: `PIVOT scores ON subtask USING max(score) FILTER (most-recent submission per (model, subtask)) GROUP BY model`.
26
+
27
+ Pre-pivot input shape (one row per submission of a model on a sub-eval / metric):
28
+
29
+ ```
30
+ suite_key, subtask_key, subtask_name, metric_summary_id, metric_key, metric_name,
31
+ model_id, model_name, developer, model_route_id,
32
+ score, retrieved_timestamp, source_metadata, source_data
33
+ ```
34
+
35
+ The TS implementation is a manual pivot built from JSON: it walks each sub-eval detail, takes its sole metric's `model_results[]`, and folds each row into `rowStates: Map<modelId, {values: {[columnKey]: score}, ...}>` keyed by `model_id`. Dedup-on-`(model_id, columnKey)` happens implicitly because each later assignment overwrites the previous; the per-row "winning timestamp" is the **highest seen** across all column writes (see TS-as-spec quirk #2 below).
36
+
37
+ ### Filtering rules applied before the pivot
38
+
39
+ 1. **Eligibility filter on sub-evals (column gate):** keep `detail` only if `detail.metrics.length === 1` AND `extractDetailSubtasks(detail).length === 0`. In SQL: keep sub-evals that have exactly one root metric and zero subtasks of their own. Suites that contain a multi-metric or multi-subtask sub-eval **silently drop that sub-eval as a column** (no diagnostic).
40
+ 2. **Suite-eligibility floor:** if fewer than 2 sub-evals survive the filter, return null (no matrix). Same floor on `matchingEvals` at the call site (line 1585: `if (matchingEvals.length < 2) return null`).
41
+ 3. **Metrics-count floor:** after the loop, `if (leaderboardMetrics.length < 2) return null` (line 1158).
42
+ 4. **Summary-score exclusion:** at the call site (line 1575), `entry.is_summary_score === true` rows are excluded from the candidate set entirely.
43
+
44
+ ### Column derivation
45
+
46
+ ```
47
+ column_key = "subtask:" || subtask_key || ":" || metric_token
48
+ ```
49
+ where `subtask_key = detail.benchmark_leaf_key || slugify(detail.eval_summary_id)` and `metric_token = metric.metric_summary_id || metric.metric_key || slugify(metric.display_name)`. Column order is **alphabetical by `benchmark_leaf_name || eval_summary_id`** (line 1059-1061), not by the column_key itself.
50
+
51
+ ### Row derivation
52
+
53
+ Rows are keyed by `model_id = modelResult.model_id || modelResult.model_name`. Models with neither id nor name are dropped silently (line 1118-1120).
54
+
55
+ ### Cell value
56
+
57
+ `row.values[columnKey] = modelResult.score ?? null`. Last write wins per `(model_id, columnKey)` pair β€” but in TS the loop visits each `(detail, model_result)` tuple exactly once, and `columnKey` is unique per `detail`, so "last write" only fires when a single sub-eval contains multiple `model_results` rows for the same `model_id` (i.e. multiple submissions of the same model to the same sub-eval). When that happens, **the last one in iteration order wins**, with no explicit tie-break β€” see TS-as-spec quirk #1.
58
+
59
+ ### Per-row timestamp & source-metadata reconciliation
60
+
61
+ For each row, three fields (`evaluation_timestamp`, `source_metadata`, `source_data`) are reconciled across all the cells written to that row. The rule: whichever cell write had the **highest `normalizeEvalTimestamp`** wins these fields (line 1149-1154). If timestamps tie, the first-seen cell keeps them (the comparison is `>=`, but the first write happens in the no-existing branch on line 1127-1142 which initializes them; later writes only overwrite if strictly greater than or equal).
62
+
63
+ ## Required parquet columns (input to the SQL pivot)
64
+
65
+ The pivot needs one row per `(eval_summary_id, model_id, retrieved_timestamp)` with these fields exposed as typed columns (today they're nested in `payload_json`):
66
+
67
+ | Column | Source in TS | Used for |
68
+ |---|---|---|
69
+ | `eval_summary_id` | `detail.eval_summary_id` | filter to suite, also slug fallback |
70
+ | `benchmark_parent_key` | `entry.benchmark_parent_key` | suite routing (matched against `suite_key` from URL) |
71
+ | `benchmark_family_key` | `entry.benchmark_family_key` | suite routing fallback |
72
+ | `benchmark` | `entry.benchmark` | suite routing fallback |
73
+ | `is_summary_score` | `entry.is_summary_score` | exclude rollup rows |
74
+ | `benchmark_leaf_key` | `detail.benchmark_leaf_key` | column key |
75
+ | `benchmark_leaf_name` | `detail.benchmark_leaf_name` | column display + sort key |
76
+ | `metric_count_in_detail` | derived: `len(metrics)` | column-eligibility filter |
77
+ | `subtask_count_in_detail` | derived: `len(extractDetailSubtasks(detail))` | column-eligibility filter |
78
+ | `metric_summary_id` | `metric.metric_summary_id` | column key + metric_config |
79
+ | `metric_key` | `metric.metric_key` | column key fallback |
80
+ | `metric_name` | `metric.metric_name` | display |
81
+ | `metric_display_name` | `metric.display_name` | column key fallback (slugified) |
82
+ | `lower_is_better` | `metric.lower_is_better` | column metadata |
83
+ | `unit` | `metric.unit` | column metadata |
84
+ | `evaluation_description` | `metric.evaluation_description` | suite metric_config |
85
+ | `min_score`, `max_score`, `score_type` | `metric.metric_config.*` | suite metric_config |
86
+ | `model_id` | `result.model_id` | row key |
87
+ | `model_name` | `result.model_name` | row display + row key fallback |
88
+ | `model_route_id` | `result.model_route_id` | model linkout |
89
+ | `developer` | `result.developer` | row display |
90
+ | `score` | `result.score` | cell value |
91
+ | `retrieved_timestamp` | `result.retrieved_timestamp` | per-row reconciliation tie-breaker |
92
+ | `source_metadata` (struct) | `result.source_metadata` | per-row reconciliation winner |
93
+ | `source_data` (struct) | `detail.source_data` | per-row reconciliation winner |
94
+ | `benchmark_card` (struct) | `detail.benchmark_card` | first-seen β†’ suite-level field |
95
+
96
+ The `(detail, metric, model_result)` triple is what TS already iterates β€” promoting these to flat parquet rows is the schema change.
97
+
98
+ ## Sketch SQL query
99
+
100
+ DuckDB syntax. Three CTEs: (1) filter to eligible sub-evals, (2) pick winning submission per `(model, subtask)`, (3) PIVOT.
101
+
102
+ ```sql
103
+ WITH suite_rows AS (
104
+ -- Pre-pivot: one row per (sub-eval, model, submission)
105
+ -- Suite-eligibility + column-eligibility filters live here
106
+ SELECT
107
+ eval_summary_id,
108
+ benchmark_leaf_key,
109
+ benchmark_leaf_name,
110
+ metric_summary_id,
111
+ metric_key,
112
+ metric_name,
113
+ metric_display_name,
114
+ lower_is_better,
115
+ unit,
116
+ model_id,
117
+ model_name,
118
+ developer,
119
+ model_route_id,
120
+ score,
121
+ retrieved_timestamp,
122
+ source_metadata,
123
+ source_data,
124
+ -- Synthetic column key matching TS's "subtask:<subtask_key>:<metric_token>"
125
+ 'subtask:' ||
126
+ coalesce(benchmark_leaf_key, slugify(eval_summary_id)) || ':' ||
127
+ coalesce(metric_summary_id, metric_key, slugify(metric_display_name))
128
+ AS column_key
129
+ FROM eval_results_flat
130
+ WHERE
131
+ -- Suite routing β€” matches TS's normalizeBenchmarkKeyForLookup
132
+ normalize_bench_key(coalesce(benchmark_parent_key, benchmark_family_key, benchmark)) = ?
133
+ AND NOT is_summary_score
134
+ -- Column eligibility: single root metric, no subtasks (TS line 1058)
135
+ AND metric_count_in_detail = 1
136
+ AND subtask_count_in_detail = 0
137
+ ),
138
+ ranked AS (
139
+ -- Pick the winning submission per (model, subtask) cell.
140
+ -- TS uses "last write wins" because each (detail Γ— model) pair is visited once
141
+ -- in iteration order; multiple submissions to the same sub-eval by the same model
142
+ -- collapse to whichever appears last in model_results[]. We approximate that with
143
+ -- ROW_NUMBER ordered by retrieved_timestamp DESC; see TS-as-spec quirk #1.
144
+ SELECT *,
145
+ ROW_NUMBER() OVER (
146
+ PARTITION BY model_id, column_key
147
+ ORDER BY retrieved_timestamp DESC
148
+ ) AS rn
149
+ FROM suite_rows
150
+ WHERE model_id IS NOT NULL OR model_name IS NOT NULL
151
+ ),
152
+ winners AS (
153
+ SELECT * FROM ranked WHERE rn = 1
154
+ )
155
+ -- The pivot. DuckDB PIVOT syntax:
156
+ PIVOT winners
157
+ ON column_key
158
+ USING max(score)
159
+ GROUP BY model_id, model_name, developer, model_route_id;
160
+ ```
161
+
162
+ Then a second pass over `winners` derives the per-row reconciled `evaluation_timestamp / source_metadata / source_data` (highest-timestamp cell wins) and the `metrics_present` count (count of non-null cells per row).
163
+
164
+ For the suite-level fields (`leaderboard_metrics` array, `metric_config`, `benchmark_card`, `evaluation_name`), a separate aggregation over `winners` collects the distinct columns (`SELECT DISTINCT column_key, benchmark_leaf_name, metric_name, …`) sorted by `benchmark_leaf_name`, plus a `MIN()` or first-row pick for `benchmark_card` and `metric_config`.
165
+
166
+ The "single row per model with a values map" output shape is still ergonomic to assemble TS-side from the PIVOT result; the heavy lifting (filter, dedup, pivot) is in SQL.
167
+
168
+ ## Materialize vs query-time
169
+
170
+ Two natural splits, both can ship:
171
+
172
+ - **Materialize the column shape and per-cell winners.** A suite's column set is fixed by its sub-eval inventory; the per-cell winner across submissions is deterministic given the data. Both can be written into parquet at pipeline build time as a derived `matrix_<suite_key>` table (or one wide `eval_matrix` table partitioned by suite). This eliminates request-time work for the dominant case.
173
+ - **Compute the row filter at query time.** Today TS doesn't filter rows at all (every model with a `model_id` shows up). Forthcoming UI work may want consumer-driven row filters: "only show models with `models_count > N` evaluations", "only third-party submissions", "filter by developer", "top-K by mean score". Those are query-time concerns and want SQL β€” `WHERE` / `LIMIT` against the materialized matrix.
174
+
175
+ **Recommendation:** materialize the wide-form `(suite_key, model_id) β†’ values map + reconciled metadata` table; expose row-filter / sort knobs as query-time SQL parameters. Recompute the materialized table on every pipeline build (cheap relative to the rest of the build); recompute on demand if a single benchmark family is added.
176
+
177
+ The column-eligibility filter (`metric_count = 1 AND subtask_count = 0`) is a build-time concern β€” it never varies per request. The row-filter is the only thing that should remain query-time.
178
+
179
+ ## TS-as-spec quirks
180
+
181
+ These are deliberate behaviours of the current TS that the SQL replacement must preserve until a separate product call says otherwise.
182
+
183
+ 1. **Cell tie-break on multiple submissions of the same model to the same sub-eval is "last in iteration order wins", with no explicit ordering of `model_results[]`.** TS line 1117 iterates `metric.model_results ?? []` directly; the order is whatever the pipeline emitted. There is no `MAX(score)` or "freshest wins" sort applied β€” the loop just overwrites `row.values[columnKey]` on each pass. In SQL this maps cleanly to `ROW_NUMBER() OVER (PARTITION BY model_id, column_key ORDER BY retrieved_timestamp DESC) = 1` only if the pipeline already emits `model_results[]` in retrieved_timestamp-DESC order. **Verify this assumption against the pipeline's actual emission order before flipping the SQL on.** If pipeline order is non-deterministic, the SQL will produce different cell values than TS for cells with multiple submissions β€” the migration must either (a) accept the divergence as a "freshest wins" upgrade, or (b) reproduce pipeline's exact array order, which is hostile.
184
+
185
+ 2. **Per-row timestamp reconciliation uses `>=` not `>`.** Line 1149: `if (nextTimestamp >= existing._timestampValue)`. Combined with quirk #1, this means for a model with multiple cells at identical timestamps, the **last-written cell's** source_metadata / source_data win. The SQL replacement should pick the source_metadata of the row with the highest `MAX(retrieved_timestamp)` across all of the model's cells; ties resolve to whatever DuckDB picks (non-deterministic, but identical-timestamp ties are rare in practice).
186
+
187
+ 3. **Silent column drops.** Sub-evals failing the `metric_count === 1 && subtask_count === 0` filter are excluded with no diagnostic. In production this means HELM Lite (10 sub-evals) might silently surface fewer columns than expected if any sub-eval has nested metrics. The pipeline owner should know this is "by design" until the product team weighs in.
188
+
189
+ 4. **`column_key` collisions on multi-metric same-subtask are impossible by construction.** Because the eligibility filter forces `metric_count === 1` per sub-eval, each `(subtask_key, metric_token)` column is unique. If the filter is loosened later, the column_key derivation (`subtask:K:M`) is collision-safe.
190
+
191
+ 5. **No row filtering applied.** Every model id encountered is rendered as a row (subject to having a `model_id` or `model_name`). Row-count for popular suites: `helm_lite` β†’ 91 distinct models in the dominant sub-eval alone. This is fine for now; capture as a known query-time-extension point.
192
+
193
+ 6. **Score of 0 vs null distinction.** `modelResult.score ?? null` β€” only `undefined` becomes null; numeric 0 is preserved. SQL `MAX(score)` over a single row preserves the 0; over multiple rows with one being NULL, it returns the non-null. Equivalent in this case because the dedup happens before the pivot.
194
+
195
+ 7. **Timestamp normalization uses Variant A (`normalizeEvalTimestamp` from `lib/model-data.ts:76-81`),** which has its own quirks documented in `notes/transformations/07-timestamp-normalization.md` (negative-string fallback, empty-string returns 0, etc.). This is shared with the composite rollup (#5) and other model-data call sites β€” once #13 ships pipeline-emitted ISO 8601 timestamps, the timestamp comparison in this query becomes a plain `MAX(retrieved_timestamp)` (lexicographic on ISO strings).
196
+
197
+ 8. **Suite-level metric_config takes the first eligible sub-eval's config** (line 1083-1085: `if (!metricConfig) metricConfig = ...`). If sub-evals disagree on `min_score / max_score / lower_is_better`, **the alphabetically-first sub-eval's values are used for the whole suite**. Document this; SQL's equivalent is `FIRST(metric_config ORDER BY benchmark_leaf_name)`.
198
+
199
+ 9. **`benchmark_card` takes the first sub-eval that has one** (line 1087-1089: `if (!benchmarkCard && detail.benchmark_card) benchmarkCard = ...`). Same pattern as #8.
200
+
201
+ 10. **Cross-row `sharedMetricName`** is set only if every sub-eval reports the same `metric_name` (line 1162). When metrics differ across sub-evals, the suite-level `evaluation_description` falls back to the first sub-eval's metric description, but the suite is still rendered. SQL: `MIN(metric_name) FILTER (WHERE metric_name IS NOT NULL)` only when `COUNT(DISTINCT metric_name) = 1`, else null.
202
+
203
+ ## Cross-item dependencies
204
+
205
+ - **#13 timestamp normalization** β€” the `MAX(retrieved_timestamp)` per `(model, subtask)` only works correctly once timestamps are in a comparable canonical form. Until pipeline emits ISO 8601, this query depends on Variant A's seconds-vs-ms quirks. Ship #13 first.
206
+ - **#5 composite rollup** β€” sibling reshape that walks the same `details[]` set. The two should share the parquet schema (a flat `eval_results_flat` row table). Spec'd separately but coordinate.
207
+ - **#11 benchmark-card attachment** β€” the matrix synthesis result is wrapped in `attachBenchmarkCardToSummary()` after build (line 1595). That join is a separate item; the matrix spec assumes it runs as-is.
208
+ - **#1 identity canonicalization** β€” the matrix uses `model_id` as the row key. If pipeline-side identity canonicalization changes any model ids, matrix rows that previously merged will split (or vice versa). Migration order: ship #1 first, then re-run snapshot.
209
+ - **#8 benchmark display names** β€” the suite-level `evaluation_name = getBenchmarkDisplayName(suiteKey)` depends on the BENCHMARK_NAMES map. Once #8 ships pipeline-emitted display names, replace inline.
210
+
211
+ ## Scope (informs "happens on every page view" framing)
212
+
213
+ Audited 2026-04-28 against `.cache/hf-data/eval-list-lite.json`:
214
+
215
+ - **587** total eval-list rows; **34** distinct parent buckets (`benchmark_parent_key || benchmark_family_key || benchmark`).
216
+ - **13** matrix-eligible buckets (β‰₯2 sub-evals after `is_summary_score` exclusion). Each one fires `buildSingleMetricSuiteMatrixSummary` exactly once per `/evals/matrix__<suite_key>` request.
217
+ - Sub-eval count per matrix request:
218
+ - **median 6**, **mean 43.5**, **max 471** (`llm_stats`), **min 2** (`reward_bench`).
219
+ - Other notable suites: `artificial_analysis_llms` (21), `helm_classic` (15), `helm_lite` (10), `swe_polybench` (8), `helm_instruct` (7), `hfopenllm_v2` / `helm_safety` / `helm_capabilities` / `multi_swe_bench` (6).
220
+ - Each sub-eval detail file carries one metric with up to ~91 `model_results` (sampled `helm_lite_*`).
221
+
222
+ **Per-request work today:** for `helm_lite`, one matrix render fetches 10 detail files (up to ~10 KB each from cache) and walks ~10 Γ— ~91 = ~910 (detail, model_result) tuples in TS. For `llm_stats`, that's 471 detail fetches and tens of thousands of tuples β€” every page view. SQL materialization eliminates this entirely; query-time SQL with row filters bounds it.
223
+
224
+ ## Migration checklist
225
+
226
+ - [x] Spec written (TS-as-is, including quirks)
227
+ - [ ] Snapshot test for `buildSingleMetricSuiteMatrixSummary` against curated detail set (Tier B; gate for SQL replacement β€” see `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum")
228
+ - [ ] Pipeline-schema decision: promote `(eval_summary_id, model_id, metric, retrieved_timestamp)` to typed parquet columns for the matrix input set
229
+ - [ ] SQL implementation in `lib/duckdb-data.ts` (or pipeline-side materialized view) reproducing the pivot
230
+ - [ ] Parity gate: TS-vs-SQL diff zero across all 13 matrix-eligible suites
231
+ - [ ] TS deleted: `buildSingleMetricSuiteMatrixSummary` (lines 1046-1210), call site collapsed to `getEvalSummaryById` reading the materialized matrix or invoking the SQL
232
+
233
+ ## Future product decision (deferred)
234
+
235
+ - Whether silent column drops (TS-as-spec quirk #3) should surface a UI affordance ("3 sub-evals omitted because they have nested metrics").
236
+ - Whether row filtering should be exposed as a UI knob (developer / source_type / models_count threshold) β€” quirk #5.
237
+ - Whether suite-level `metric_config` should be a hard error when sub-evals disagree, rather than alphabetically-first-wins (quirk #8).
238
+ - Whether to replace "last-write-wins on duplicate submissions" with explicit "freshest-timestamp wins" (quirk #1) β€” likely a no-op in production but a real semantic upgrade.
notes/transformations/reshape/14-score-summary-stats.md ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Score summary stats (per-eval aggregations)
2
+
3
+ Drafted 2026-04-28. Migration item #14 in `notes/migration-plan.md`. Reshape-class.
4
+
5
+ ## Framing reminder
6
+
7
+ Refactoring for UI efficiency, not fixing data correctness. TS-as-is is the canonical spec β€” its quirks (in particular, *re-deriving* `models_count` and effectively-`top_score` even though pipeline already emits them per eval; ignoring the metric primary-vs-leaderboard distinction; using `metric_config.{min,max}_score` defaults of 0/1 to "normalize" already-0-to-1 scores into themselves) must be reproduced when the operation moves to SQL or be explicitly accepted as divergences.
8
+
9
+ This is a textbook reshape: a `GROUP BY eval_summary_id` over the underlying `model_results` rows with arithmetic + set aggregations. It depends on item #2 (variant bucket reduction) being landed first β€” the per-eval rows the GROUP BY runs over are *already-deduped* model_result rows, not raw submissions.
10
+
11
+ ## Operation in SQL terms
12
+
13
+ Input: one row per `(eval_summary_id, model_result)` from the post-#2-dedup `metric.model_results[]` array (one row per `(model_id Γ— variant_key Γ— evaluation_metric)` after the variant bucket reduction). Each row carries `score`, `evaluation_timestamp` / `retrieved_timestamp`, `source_metadata.{source_type, source_organization_name, evaluator_relationship, source_name}`, `generation_config` presence flag.
14
+
15
+ Output: one row per `eval_summary_id` with the following aggregated columns:
16
+
17
+ | Column | Aggregation | Notes |
18
+ |---|---|---|
19
+ | `models_count` | `COUNT(*)` | over the deduped model_results, NOT distinct model_id |
20
+ | `avg_score` | `AVG(score)` | raw score, no min/max scaling |
21
+ | `avg_score_norm` | `(AVG(score) - min_score) / (max_score - min_score)` | from per-eval `metric_config`; default `min=0`, `max=1`, β†’ `range=1` so score already-in-[0,1] is unchanged |
22
+ | `best_model` | `(model_name, score)` of `MIN(score)` if `lower_is_better` else `MAX(score)` | tie-break: input order (TS uses stable sort) |
23
+ | `worst_model` | mirror of best_model | |
24
+ | `evaluator_names` | `array_agg(DISTINCT source_organization_name)` | TS preserves *insertion order* (no sort); see TS-as-spec quirks |
25
+ | `source_types` | `array_agg(DISTINCT source_type ORDER BY source_type)` | locale-compare sort |
26
+ | `latest_source_name` | `arg_max(source_name, comparable_timestamp)` over result rows | with `>=` tiebreak (not `>`) β†’ last-wins on ties; see #13 timestamp normalization caveat |
27
+ | `third_party_ratio` | `COUNT(*) FILTER (WHERE evaluator_relationship = 'third_party') / COUNT(*)` | denominator is `models_count` |
28
+ | `missing_generation_config_count` | `COUNT(*) FILTER (WHERE generation_config IS NULL)` | absent/null/empty all count as missing in TS |
29
+
30
+ The grouping happens **per `eval_summary_id`** (which corresponds to one `(benchmark_leaf_key, primary_metric)` slice). Composite-benchmark rollups across multiple eval_summary_ids are item **#5** (`aggregateBenchmarkSummaries`), out of scope here.
31
+
32
+ ## Current TS implementation
33
+
34
+ | Concern | Location | Notes |
35
+ |---|---|---|
36
+ | Multi-source-of-truth groupby finalisation | `lib/eval-processing.ts:946-991` (`groupEvaluationsByBenchmark`) | runs over `BenchmarkEvaluation[]` loaded from caches; legacy path. Builds the GROUP BY by accumulating into an object, then iterates and computes the aggregations in a separate `for` loop. |
37
+ | HF-detail-derived single-eval finalisation | `lib/model-data.ts:751-853` (`hfEvalDetailToSummary`) | runs over a single `HFEvalDetail` (one `eval_summary_id`); reads `metric.model_results` of the `primaryMetric` (= `allMetrics[0]`). This is the *active* path used by `app/evals/[id]/page.tsx` via `getEvalSummaryById` (line 1601, 1562). |
38
+ | HF-detail empty-metric short-circuit | `lib/model-data.ts:772-804` | when no primary metric, returns a zero-stats summary with `models_count=0`, `avg_score=0`, `best/worst_model=null`, `latest_source_name = getBenchmarkDisplayName(benchmarkKey)` β€” note this is the **benchmark display name**, not a real source name. TS-as-spec quirk #1. |
39
+ | Score normalization helper | `lib/model-data.ts:83-88` (`normalizeSummaryScore`) | `min=0`, `max=1`, `range=1` defaults β€” so `avg_score_norm = avg_score` for the 0-1 score case. |
40
+ | Per-row timestamp normalization (inline) | `lib/model-data.ts:76-81` (`normalizeEvalTimestamp`) and inline duplicate at `lib/eval-processing.ts:961-967` | Variant A from `notes/transformations/07-timestamp-normalization.md`; latest-wins comparison uses `>=` (last-wins on tie). |
41
+ | Per-row score timestamp source field | `lib/eval-processing.ts:933` uses `result.evaluation_timestamp`; `lib/model-data.ts:717` uses `mr.retrieved_timestamp ?? ""` then sets `evaluation_timestamp` from it | The two paths read different upstream fields under the hood; pipeline must ensure both resolve to the same canonical timestamp. |
42
+
43
+ Confirmed line numbers (verified 2026-04-28):
44
+ - `groupEvaluationsByBenchmark` body: `lib/eval-processing.ts:893-994`
45
+ - Finalisation loop: `lib/eval-processing.ts:946-991`
46
+ - `hfEvalDetailToSummary` body: `lib/model-data.ts:751-854`
47
+ - `normalizeEvalTimestamp`: `lib/model-data.ts:76-81`
48
+ - `normalizeSummaryScore`: `lib/model-data.ts:83-88`
49
+
50
+ ## Required parquet columns
51
+
52
+ For SQL to do this work directly without pulling `payload_json`, parquet needs (per deduped model_result row, post-#2):
53
+
54
+ - `eval_summary_id` (already a metadata column)
55
+ - `model_id` / `model_route_id` / `model_name` (currently nested in `metrics[].model_results[].*` inside payload)
56
+ - `score` (currently nested)
57
+ - `retrieved_timestamp` (currently nested; ISO-canonicalized after item #13)
58
+ - `source_metadata.source_type` (currently nested)
59
+ - `source_metadata.source_organization_name` (currently nested)
60
+ - `source_metadata.evaluator_relationship` (currently nested)
61
+ - `source_metadata.source_name` (currently nested)
62
+ - `generation_config` presence boolean β€” `has_generation_config` (currently nested under `model_results[].generation_config`)
63
+
64
+ Per-eval scalars (already available somewhere, but need to be on the result row or joinable):
65
+
66
+ - `metric_config.min_score` / `max_score` / `lower_is_better` (per `(eval_summary_id, primary_metric)` β€” pipeline currently inlines under `metrics[]` in `eval-detail.json`)
67
+
68
+ Today's parquet schema has `eval_summary_id`, `models_count`, plus `payload_json`. Doing this in SQL would require a relational promotion of `metric.model_results[]` (one row per result, joined to per-eval `metric_config`). That's the open design question flagged in `notes/migration-plan.md` Β§ "Data direction".
69
+
70
+ ## Sketch SQL query
71
+
72
+ ```sql
73
+ -- Per-eval summary stats over deduped model_results.
74
+ -- Assumes a relational table `eval_results` with one row per
75
+ -- (eval_summary_id, model_result) post-item-#2 variant dedup,
76
+ -- and per-eval metric_config columns inlined (or joined from a sibling table).
77
+ SELECT
78
+ eval_summary_id,
79
+ COUNT(*) AS models_count,
80
+ AVG(score) AS avg_score,
81
+ CASE WHEN (max_score - min_score) > 0
82
+ THEN (AVG(score) - min_score) / (max_score - min_score)
83
+ ELSE 0
84
+ END AS avg_score_norm,
85
+ -- best / worst: argmax/argmin over score (direction depends on lower_is_better)
86
+ CASE WHEN lower_is_better
87
+ THEN STRUCT_PACK(name := arg_min(model_name, score), score := MIN(score))
88
+ ELSE STRUCT_PACK(name := arg_max(model_name, score), score := MAX(score))
89
+ END AS best_model,
90
+ CASE WHEN lower_is_better
91
+ THEN STRUCT_PACK(name := arg_max(model_name, score), score := MAX(score))
92
+ ELSE STRUCT_PACK(name := arg_min(model_name, score), score := MIN(score))
93
+ END AS worst_model,
94
+ -- evaluator names: TS preserves insertion order, NOT sorted (quirk)
95
+ list(DISTINCT source_organization_name) AS evaluator_names,
96
+ -- source_types: TS sorts by localeCompare
97
+ list_sort(list(DISTINCT source_type)) AS source_types,
98
+ -- latest source_name: arg_max with >= tie-break (last-wins)
99
+ arg_max(source_name, retrieved_timestamp) AS latest_source_name,
100
+ -- third_party_ratio: filtered count over total
101
+ COUNT(*) FILTER (WHERE evaluator_relationship = 'third_party')::DOUBLE
102
+ / NULLIF(COUNT(*), 0) AS third_party_ratio,
103
+ COUNT(*) FILTER (WHERE generation_config IS NULL) AS missing_generation_config_count
104
+ FROM eval_results
105
+ GROUP BY eval_summary_id, min_score, max_score, lower_is_better;
106
+ ```
107
+
108
+ Notes on the sketch:
109
+ - `arg_max(source_name, retrieved_timestamp)` is DuckDB-native; the `>=` tie-break in TS would need an explicit `ORDER BY retrieved_timestamp DESC, row_order DESC LIMIT 1` if exact byte-parity is required (see TS-as-spec quirks).
110
+ - `list(DISTINCT …)` in DuckDB returns insertion order of distinct values in the partition β€” should match TS `Array.from(new Set([...]))` for `evaluator_names`.
111
+ - The `STRUCT_PACK` for best/worst is illustrative; in practice we'd materialize `best_model_name` and `best_model_score` as separate columns.
112
+ - `score` and `min_score`/`max_score` are read from the same row group; if `metric_config` is sibling-joined, `GROUP BY` keys must include those columns (or use a window).
113
+
114
+ ## Materialize vs query-time
115
+
116
+ **Recommendation: materialize per-eval at pipeline emission time.** Pipeline already emits `eval-list.json` with `models_count` and `top_score` per eval, which proves materialization is the established pattern. Extending that to the full set above (`avg_score`, `avg_score_norm`, `evaluator_names`, `source_types`, `latest_source_name`, `third_party_ratio`, `missing_generation_config_count`, `best_model`, `worst_model`) is the same operation, just more outputs.
117
+
118
+ Why materialize:
119
+
120
+ - **No consumer slices these by category, by model-developer, or by anything else within an eval.** Audit (2026-04-28) of `lib/`, `app/`, `components/`: every read is per-eval scalar (`summary.avg_score`, `summary.third_party_ratio`, `summary.evaluator_names.length`, etc.). The sole "category breakdown" use (`benchmark-detail.tsx:5727`) is over models within a benchmark, computed independently β€” not over these summary stats.
121
+ - **The aggregation is deterministic and consumer-invariant** β€” every consumer would compute the same numbers, which is exactly the materialize-when-the-answer-is-the-same heuristic from `notes/migration-plan.md` Β§ "Data direction".
122
+ - **Per-eval scope is small** (one row per eval, ~587 evals in production today); blob size is trivial.
123
+ - **It removes the "TS recomputes what pipeline already provides" divergence** flagged below β€” instead of expanding the divergence by adding 7 more recomputed fields, we collapse the existing one by lifting all 9 to pipeline.
124
+
125
+ When query-time SQL would be the call instead: if a future consumer wants e.g. "third_party_ratio for this eval *restricted to a developer subset*", that's a query-time aggregation. Today no such consumer exists. Re-evaluate when one shows up.
126
+
127
+ The composite-benchmark rollup (item #5 `aggregateBenchmarkSummaries`) and matrix synthesis (item #6) are query-time-shaped because they slice across eval_summary_ids in different ways per request. Item #14 is the pre-aggregate that those build on.
128
+
129
+ ## TS-as-spec quirks
130
+
131
+ These are TS behaviors the spec preserves. Pipeline must reproduce or each is an explicitly-accepted divergence.
132
+
133
+ ### 1. TS recomputes `models_count` and an effective `top_score` even though pipeline already provides them per eval
134
+
135
+ Pipeline's `eval-list.json` emits per-eval `models_count` (e.g. 4492) and `top_score` (e.g. 0.8269), verified 2026-04-28 against `.cache/hf-data/eval-list.json`. TS ignores both:
136
+
137
+ - `models_count` is recomputed at `lib/eval-processing.ts:948` as `summary.model_results.length`, and at `lib/model-data.ts:825` as `modelResults.length`.
138
+ - `top_score` is not stored, but `best_model.score` is computed at `lib/eval-processing.ts:984-989` and `lib/model-data.ts:831-836` from the sorted-by-score model_results β€” which should be the same value as `top_score` *for the primary metric* assuming neither side filters differently.
139
+
140
+ **Do they disagree today?** Almost certainly yes for some rows. Pipeline's `models_count` counts pre-#2-dedup rows (raw model_result entries on the eval); TS's recomputed value counts post-#2-dedup rows (after `normalizeSingleModelCardEntry` collapses thinking-budget variants etc.). For evals with thinking-budget submissions (e.g. `openai/gpt-5.2`), pipeline's count will be higher than TS's. This is the same root cause as the #2 spec's "pipeline emits 7 variants, TS shows 2". Surface as a known divergence; **do not fix in this spec β€” it is properly resolved by landing #2 first**, after which pipeline's emitted `models_count` will match what TS recomputes (and the recomputation can be dropped).
141
+
142
+ For `top_score` vs TS-derived `best_model.score`: similar story. Pipeline's `top_score` is `MAX(score)` over pre-#2-dedup rows; TS's is over post-dedup rows. Different denominators, potentially different maxima (though MAX is more robust to dedup than COUNT or AVG).
143
+
144
+ ### 2. The empty-metric short-circuit returns a zero-stats summary with `latest_source_name = getBenchmarkDisplayName(benchmarkKey)`
145
+
146
+ `lib/model-data.ts:785` sets `latest_source_name` to a *benchmark display name string* (e.g. "MMLU Professional") β€” not a source name β€” when there are no metrics. Downstream UI then renders this as if it were a source label. This is almost certainly a placeholder bug, but it's TS behavior today and any consumer that special-cases `latest_source_name` matching a benchmark name is reading this. Pipeline should reproduce by emitting `null`-or-display-name in the same condition, OR by accepting this as a fix-by-canonicalization (recommended: emit `null` and chase down any consumer that breaks, since this only fires when the eval has zero metrics β€” a degenerate case).
147
+
148
+ ### 3. Two GROUP BY entry points, different score-source semantics
149
+
150
+ `groupEvaluationsByBenchmark` (eval-processing.ts:893) iterates `eval_.evaluation_results`; `hfEvalDetailToSummary` (model-data.ts:751) iterates `metric.model_results` of the *first* metric (`allMetrics[0]`). These are different shapes feeding the same aggregation, and they apply different filters: the eval-processing path takes *every* `evaluation_result` (potentially multi-metric); the model-data path takes only one metric's results. The active read path for eval-detail pages is `hfEvalDetailToSummary`. Pipeline emission should match `hfEvalDetailToSummary`'s semantics (single primary metric per eval_summary_id) β€” that's what users actually see today.
151
+
152
+ ### 4. `evaluator_names` is insertion-ordered, not sorted
153
+
154
+ `lib/eval-processing.ts:940-942` pushes into a deduped array as it iterates; `hfEvalDetailToSummary` initializes to `[]` (line 826) and never populates it at all. Two contradicting behaviors in the same codebase β€” for the active path (`hfEvalDetailToSummary`), `evaluator_names` is **always empty**. The eval-card UI (`components/eval-card.tsx:162`) reads `summary.evaluator_names.length`, so eval-detail pages today always render "0 evaluators". This is a latent bug, but it's TS behavior. Pipeline should decide whether to (a) reproduce the empty-array behavior to preserve UI byte-parity, or (b) fix-by-canonicalization and emit the sorted DISTINCT set, accepting that the "Evaluators" pill will start showing real numbers. Recommended: (b), and call it out in the migration commit.
155
+
156
+ ### 5. Latest-source uses `>=` tie-break (last-wins)
157
+
158
+ `lib/eval-processing.ts:968` uses `timestamp >= latestTimestamp`, so on a timestamp tie the *later iteration order* wins. SQL `arg_max` is implementation-defined on ties. If exact byte-parity matters across the migration, the SQL needs an explicit secondary sort. Otherwise accept as a divergence on the rare tied-timestamp case.
159
+
160
+ ### 6. `avg_score_norm` defaults make it a no-op for 0-1 scores
161
+
162
+ When `metric_config.min_score` and `max_score` are absent (the common case), defaults are `0` and `1` β†’ `range = 1` β†’ `avg_score_norm = avg_score`. The "normalization" only does work when the metric explicitly carries non-default min/max. Reproducing in SQL is the `CASE WHEN range > 0` branch in the sketch above; matches TS's `range > 0 ? … : 0` (note: TS returns 0 not score when range == 0; minor edge-case divergence with `normalizeSummaryScore` which returns `score`).
163
+
164
+ ## Cross-item dependencies
165
+
166
+ **Hard dependencies (this item cannot land cleanly without them):**
167
+
168
+ - **#2 setup-alias variant merging (reshape half).** The aggregations are over deduped variants. If pipeline emits stats over raw rows, TS-recomputed stats over deduped rows will continue to diverge for any model with merged variants. Land #2's reshape half first; then the per-eval row set that #14's GROUP BY runs over is the same set TS uses today.
169
+ - **#13 timestamp normalization.** `latest_source_name` requires a comparable timestamp. Today TS uses `normalizeEvalTimestamp` (Variant A from #13's spec) inline; once pipeline emits ISO 8601, SQL `arg_max(source_name, retrieved_timestamp)` works lexicographically with no parsing.
170
+
171
+ **Already-shipped dependencies:**
172
+
173
+ - **#4 source-metadata synthesis fallback.** Done. Pipeline now emits `source_metadata.{source_type, source_organization_name, evaluator_relationship, source_name}` on every model_result row, so the `evaluator_names`, `source_types`, `third_party_ratio`, and `latest_source_name` aggregations have non-null inputs to read. Without #4 these aggregations would silently drop rows.
174
+
175
+ **Soft dependencies (independent but adjacent):**
176
+
177
+ - **#5 composite eval rollup.** Builds on per-eval summaries. Once #14 lands as materialized per-eval stats, #5's `aggregateBenchmarkSummaries` becomes a query-time roll-up over already-aggregated rows (cheaper) instead of a re-aggregation from raw model_results.
178
+ - **#6 matrix leaderboard synthesis.** Same pattern β€” reads per-eval summaries when slicing across a multi-metric suite.
179
+
180
+ ## Migration checklist
181
+
182
+ - [x] Spec written (TS-as-is, including quirks)
183
+ - [ ] Pipeline-side schema decision: relational promotion of `metric.model_results[]` to parquet rows, OR materialize the 9 aggregated columns into `eval-list.json` directly. Recommended: materialize (per "Materialize vs query-time" section).
184
+ - [ ] Pipeline emits the 9 columns per eval_summary_id matching this spec across the full corpus
185
+ - [ ] Verify against `.cache/hf-data/eval-list.json` extended shape; today only `models_count` and `top_score` are present
186
+ - [ ] Audit script (none yet β€” could mirror `scripts/verify-timestamp.mjs` against the recomputed-vs-emitted values for the 9 columns)
187
+ - [ ] TS deleted: `lib/eval-processing.ts:946-991` finalisation loop; `lib/model-data.ts:806-853` aggregation block; `lib/model-data.ts:83-88` (`normalizeSummaryScore`). Callers read pipeline-emitted fields directly.
188
+ - [ ] Snapshot test (`tests/transformations/score-summary-stats.test.ts` β€” currently absent; reshape-class snapshots double as TS-vs-SQL parity gates per `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum")
189
+
190
+ ## Future product decisions (deferred)
191
+
192
+ - Whether the empty-metric `latest_source_name = display_name` placeholder (TS quirk #2) should be `null` or kept as a backstop string.
193
+ - Whether `evaluator_names` should be empty (TS active-path behavior) or populated with the sorted DISTINCT set (TS legacy-path behavior).
194
+ - Whether `models_count` should mean "distinct model_id count" or "deduped result count" β€” TS today uses the latter; users may expect the former. Resolves naturally once #2 lands.
notes/transformations/reshape/16-per-category-counts.md ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Per-category benchmark counts
2
+
3
+ Drafted 2026-04-28. Migration item **#16** in `notes/migration-plan.md`. **Reshape class** β€” the operation lives in DuckDB SQL (materialized into the parquet artifact). Use this spec as the canonical "obvious SQL" example: it is the smallest, clearest reshape item in the migration.
4
+
5
+ ## Framing reminder
6
+
7
+ We are refactoring for UI efficiency, not data correctness. TS-as-is is the canonical spec for *behavior preservation* β€” except this is the rare reshape item where the current TS is **demonstrably wrong** (it ships a fake distribution; see TS-as-spec quirks below). The migration replaces wrong-with-correct, so the snapshot delta on switch-over is *expected* and the snapshot itself cannot be the gate. The gate is: "real per-category counts populate the same `category_stats: Record<Category, number>` shape; UI renders without divergence." See `notes/testing-strategy.md` Β§ "Reshape-class items: testing addendum".
8
+
9
+ ## Migration item
10
+
11
+ | Field | Value |
12
+ |---|---|
13
+ | Item | #16 |
14
+ | Class | Reshape |
15
+ | Operation | per-(model, category) DISTINCT-benchmark count |
16
+ | Materialize? | Yes β€” small fixed shape, identical for every consumer |
17
+ | Target column | `category_stats: Record<CategoryType, number>` on the model card payload (already the consumer-facing field) |
18
+ | TS files affected | `lib/model-data.ts:369-379` (the wrong fake), `lib/eval-processing.ts:653-666` (the correct heavy-data path) |
19
+ | Pipeline file expected | `scripts/pipeline.py` model-card writer; populate `category_stats` in `model-cards.json` summary entries |
20
+
21
+ ## Operation in SQL terms
22
+
23
+ Group every model_result row by `(model_route_id, category)` and count distinct `benchmark_family_key`. The result is the per-model, per-category benchmark-coverage count consumed by the model card UI.
24
+
25
+ This is one statement. It has no edge cases beyond null/empty handling and the choice of grouping key (route-id vs family-id) β€” both already settled by the existing parquet schema.
26
+
27
+ ## Current TS implementation
28
+
29
+ There are **two implementations** of `category_stats` in TS today, depending on which data path supplies the model card:
30
+
31
+ ### Path A β€” `lib/model-data.ts:360-380` (`hfModelCardToEvaluationCardData`)
32
+
33
+ Input: `HFModelCardEntry` (the lightweight `model-cards.json` summary; `entry.categories_covered: string[]`, `entry.total_evaluations: number`, no per-category breakdown).
34
+
35
+ Logic at lines 369-379 (verbatim):
36
+
37
+ ```ts
38
+ // Distribute total evaluations across categories proportionally
39
+ const categoryStats: Record<string, number> = {}
40
+ const perCat = categories.length > 0
41
+ ? Math.max(1, Math.floor(entry.total_evaluations / categories.length))
42
+ : 0
43
+ let remaining = entry.total_evaluations
44
+ for (let i = 0; i < categories.length; i++) {
45
+ const count = i === categories.length - 1 ? remaining : Math.min(perCat, remaining)
46
+ categoryStats[categories[i]] = count
47
+ remaining -= count
48
+ }
49
+ ```
50
+
51
+ This is the **fake distribution**. It does not look at any per-category data because the input doesn't carry any. It just slices `total_evaluations` evenly across `categories.length`, with the last category taking the rounding remainder.
52
+
53
+ Callers of `hfModelCardToEvaluationCardData` (the listing/grid pages where this fake fires):
54
+ - `lib/model-data.ts:1235, 1242` β€” model index sorting
55
+ - `lib/model-data.ts:1386, 1416, 1455` β€” developer detail pages, comparison pages
56
+ - `lib/duckdb-data.ts:163` β€” DuckDB shadow read parity path
57
+
58
+ ### Path B β€” `lib/eval-processing.ts:653-666` (`createModelFamilySummary` β†’ `categoryStats`)
59
+
60
+ Input: full `BenchmarkEvaluation[]` (per-eval-detail data, with `evaluations_by_category` populated).
61
+
62
+ Logic at lines 653-666 (verbatim):
63
+
64
+ ```ts
65
+ // Calculate category stats (count of unique benchmarks per category)
66
+ const categoryStats: Record<CategoryType, number> = {} as any
67
+
68
+ for (const category of summary.categories_covered) {
69
+ const evals = summary.evaluations_by_category[category] || []
70
+ const categoryBenchmarks = new Set<string>()
71
+
72
+ for (const eval_ of evals) {
73
+ for (const result of eval_.evaluation_results) {
74
+ categoryBenchmarks.add(getBenchmarkName(eval_, result))
75
+ }
76
+ }
77
+ categoryStats[category] = categoryBenchmarks.size
78
+ }
79
+ ```
80
+
81
+ This is the **real distribution**: COUNT(DISTINCT benchmark) per category, computed from the heavy nested data. Used on model-detail pages where the full payload is loaded.
82
+
83
+ Callers of `createModelFamilySummary`:
84
+ - `lib/model-data.ts:1497, 1520, 1532` β€” model-detail pages
85
+ - `lib/duckdb-data.ts:194` β€” DuckDB shadow read for model detail
86
+
87
+ The two paths produce **different `category_stats` for the same model** today. The grid/index UI sees the fake distribution; the detail page sees the real one. No code reconciles them.
88
+
89
+ ## Required parquet columns
90
+
91
+ The pipeline already emits the columns needed (per the schema documented in `notes/migration-plan.md` Β§ "Data direction" β€” `record_type`, `model_route_id`, `model_family_id`, `eval_summary_id`, `developer_route_id`, `developer`, `category`, `benchmark_family_key`, `models_count`, `total_evaluations`, `last_updated`, `payload_json`). For this aggregation:
92
+
93
+ - `model_route_id` β€” group key (per-model)
94
+ - `category` β€” group key (per-category)
95
+ - `benchmark_family_key` β€” distinct-count target
96
+
97
+ No schema change needed for the SQL to run. Confirm against `scripts/pipeline.py:write_experimental_parquet_table` in the pipeline repo when handing off β€” the column list above is from documentation, not direct inspection.
98
+
99
+ ## Sketch SQL query
100
+
101
+ ```sql
102
+ SELECT
103
+ model_route_id,
104
+ category,
105
+ COUNT(DISTINCT benchmark_family_key) AS benchmark_count
106
+ FROM model_results
107
+ WHERE record_type = 'model_result' -- if needed; depends on whether row is pre-filtered
108
+ AND benchmark_family_key IS NOT NULL
109
+ GROUP BY model_route_id, category
110
+ ```
111
+
112
+ To materialize as the `category_stats: Record<CategoryType, number>` shape that the consumer expects, pivot per model:
113
+
114
+ ```sql
115
+ SELECT
116
+ model_route_id,
117
+ MAP_FROM_ENTRIES(
118
+ LIST({k: category, v: COUNT(DISTINCT benchmark_family_key)})
119
+ ) AS category_stats
120
+ FROM model_results
121
+ WHERE benchmark_family_key IS NOT NULL
122
+ GROUP BY model_route_id
123
+ ```
124
+
125
+ (DuckDB syntax for assembling a `MAP(category VARCHAR, count BIGINT)`. The pipeline writer can also assemble the dict in Python after running the basic GROUP BY β€” equivalent.)
126
+
127
+ Verification: against the live cache, `total_evaluations` for a given model should approximately equal `SUM(benchmark_count)` across its categories β€” *approximately* because today's TS fake guarantees the sum, but the real count is "distinct benchmarks per category" which can sum to less (a benchmark in two categories gets counted once per category) or more than `total_evaluations` (which is row-count, not distinct-benchmark-count). This sum-divergence is the load-bearing finding for "the fake was always wrong, not just imprecise."
128
+
129
+ ## Materialize vs query-time
130
+
131
+ **Materialize.** This is the unambiguous case for materialization:
132
+
133
+ - The aggregation is small (one row per model Γ— number-of-categories ≀ 9, so ≀ ~50k cells across the full corpus of 5,830 models).
134
+ - The answer is identical for every consumer β€” no slicing, no filtering. Both the grid and the detail page want the same `Record<Category, number>`.
135
+ - The consumer shape is already known and stable: `category_stats: Record<CategoryType, number>` on the model card.
136
+ - Materializing into `model-cards.json` (summary path) eliminates Path A's fake entirely; the detail path can keep its own computation initially, but eventually both reads from the materialized field.
137
+
138
+ Recommended landing: pipeline emits `category_stats` as a dict on each model card summary entry. Both TS Path A and Path B then read the field directly; both implementations get deleted.
139
+
140
+ ## TS-as-spec quirks (Path A β€” the fake)
141
+
142
+ These are the precise behaviors today. Document them β€” even though they're wrong β€” because they may have shaped UI design:
143
+
144
+ 1. **Always-equal counts.** With `Math.floor(total / categories.length)`, every category gets an identical `perCat` value (modulo rounding). For a model with `total_evaluations = 12` and `categories = ["Reasoning", "Coding", "Math", "Knowledge"]`, every category gets `3`. Real data would not look like this.
145
+
146
+ 2. **`Math.max(1, …)` floor.** When `total_evaluations < categories.length`, `Math.floor` would yield `0`, but `Math.max(1, …)` forces at least `1`. So a model with `total_evaluations = 2` and `categories.length = 4` ends up with the first 2 categories at `1` each and the remainder distributed via `Math.min(perCat, remaining)` β€” practically: category 0 β†’ 1, category 1 β†’ 1, category 2 β†’ 0 (because remaining = 0 and `Math.min(1, 0) = 0`), category 3 β†’ 0 (last category takes `remaining = 0`). So you get `{ cat0: 1, cat1: 1, cat2: 0, cat3: 0 }` for total=2, categories=4. **Not always-equal** in this branch β€” a quirk worth flagging.
147
+
148
+ 3. **Last-category-takes-remainder.** The terminal category collects `remaining` instead of `perCat`, so it can be larger than the others when `total % categories.length != 0`. For `total = 13, categories.length = 4`, you get `{ 3, 3, 3, 4 }`. The last category in the array (alphabetical or insertion order from `mapHFCategories`) silently looks more covered than the others.
149
+
150
+ 4. **Zero-categories edge.** `categories.length === 0` β†’ `perCat = 0`, loop never runs, `categoryStats = {}`. The consumer gets an empty record. UI must handle. (`benchmark-evaluation-card.tsx:248-257` filters `count > 0` before rendering, so empty is safe; verified.)
151
+
152
+ 5. **Zero-total-evaluations edge.** `total_evaluations = 0`, `categories.length > 0` β†’ `perCat = Math.max(1, 0) = 1`. Then `remaining = 0`, loop iterates: `i = 0`, `count = Math.min(1, 0) = 0`, `categoryStats[cat0] = 0`, `remaining = 0`. Subsequent iterations same. Last iteration: `count = remaining = 0`. Final: `{ cat0: 0, cat1: 0, … }`. The `Math.max(1, …)` doesn't bite here because `Math.min(perCat, remaining=0) = 0`. UI sees all-zeros, filters them out via `count > 0` check. Safe.
153
+
154
+ 6. **No relation to actual benchmark distribution.** A model could have all 50 of its evaluations in `Knowledge` and 0 in `Reasoning`, but if the categories list is `["Knowledge", "Reasoning"]` (because some other model in the same category exposure was tagged that way, or because the upstream `categories_covered` was a union), the fake shows `25` and `25`. There is no signal in the input that lets the fake do better.
155
+
156
+ 7. **Path A vs Path B disagreement.** Same model, two pages, two answers. Today's grid shows fake; today's detail shows real. The pipeline materialization eliminates this.
157
+
158
+ ### TS-as-spec quirks (Path B β€” the real one)
159
+
160
+ Path B is essentially correct (COUNT(DISTINCT benchmark) per category) but uses `getBenchmarkName(eval_, result)` as the distinct key rather than `benchmark_family_key`. Two divergences:
161
+
162
+ - The display-name path (#8 in the migration catalog) is messy β€” different evals can map to the same display name even with different family keys. The SQL `COUNT(DISTINCT benchmark_family_key)` is the right key going forward; expect small count differences vs Path B today on models where a benchmark family has multiple display-named members. Document as expected when running parity.
163
+ - Path B counts at the (eval, result) level β€” one eval can carry multiple results, each with its own `benchmark`. The SQL on `model_results` rows is at the same granularity (one row per result), so the COUNT(DISTINCT benchmark_family_key) GROUP BY (model_route_id, category) will line up.
164
+
165
+ ## UI components to audit when going from fake-equal to real-uneven counts
166
+
167
+ 1. **`components/benchmark-evaluation-card.tsx:248-257`** β€” `categoryCoverage` derived value. Filters `count > 0` (safe under both fake and real), sorts by `count desc, category asc`. Today the sort is effectively a tie-break on category name because counts are always equal under fake; once counts are real, the sort starts ordering by actual coverage. **Visual change: bar/chip ordering shifts.** Worth a screenshot diff.
168
+
169
+ 2. **`components/benchmark-evaluation-card.tsx:240-247`** β€” `topDomains` (above the category coverage block). Independent of `category_stats`, but the visual proximity means if categoryCoverage starts looking lopsided, design might want to revisit ordering of the two adjacent blocks.
170
+
171
+ 3. **Card grid alignment.** If grid cells display category counts as side-by-side bars (need to grep templates), today every model's bars are equal width within itself; under real counts they'll be uneven. CSS that assumes "always-equal" widths might overflow or look awkward.
172
+
173
+ 4. **Filter UI / faceted browse (if any).** Searching by "models with > N benchmarks in Reasoning" today returns weird answers (e.g. a 12-eval model with 4 categories registers as 3 in Reasoning even if it has 0 actual reasoning benchmarks). After the migration, this becomes meaningful β€” and any test fixtures that assumed "all models register in all their listed categories" need updating.
174
+
175
+ No widespread breakage is expected; the consumer shape is unchanged. The risk is cosmetic (sort order, bar widths) and statistical (filters that were lying now tell truth).
176
+
177
+ ## Cross-item dependencies
178
+
179
+ - **#11 category inference.** The SQL groups by the pipeline's `category` column. Today 84% of evals emit `category: "other"` (per `notes/migration-plan.md`), which would collapse most distinct-benchmark counts into one giant `other` bucket β€” defeating the point of the per-category breakdown. **#16 is blocked on #11 producing useful category labels.** Or alternatively: the pipeline-side aggregation must run after applying TS's regex category inference, by porting `inferCategoryFromBenchmark` upstream first.
180
+ - **#8 benchmark display names.** Affects the *labels* shown next to the count, not the count itself. Independent.
181
+ - **#3 hierarchy flatten.** The SQL groups by `model_route_id`, which is the post-flatten family identity. If hierarchy flattening changes the route-id assignment, recompute. Should be stable for now.
182
+
183
+ ## Migration checklist
184
+
185
+ - [x] Spec written
186
+ - [ ] Tests cover the materialized-output shape (`Record<CategoryType, number>` with non-negative integers, sum vs total_evaluations divergence accepted)
187
+ - [ ] Confirm parquet schema columns (`model_route_id`, `category`, `benchmark_family_key`) against `scripts/pipeline.py:write_experimental_parquet_table` in pipeline repo
188
+ - [ ] Filed with pipeline owner β€” recommend materializing `category_stats` into `model-cards.json` summary entries (smallest, clearest reshape) and **flag dependency on #11 category accuracy**
189
+ - [ ] Pipeline emits `category_stats` populated for all 5,830 model card entries
190
+ - [ ] Adapter snapshot regenerated; review the diff (it WILL be large because the fake is replaced) β€” the snapshot is *not* the gate, the materialized-shape contract is
191
+ - [ ] TS deleted: remove the fake at `lib/model-data.ts:369-379`, remove the categoryStats block at `lib/eval-processing.ts:653-666`, both paths read `entry.category_stats` from the pipeline field
192
+ - [ ] UI screenshot diff on `components/benchmark-evaluation-card.tsx` to confirm the now-uneven bars render acceptably; design tweak if not
notes/ts-to-pipeline-migration.md ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Move TypeScript data processing into the dataset pipeline
2
+
3
+ Working notes β€” drafted 2026-04-26, updated 2026-04-27. Cleaning/reshape annotations added 2026-04-28.
4
+
5
+ > **Framing update (2026-04-28).** This doc was written assuming every item moves to pipeline emission. The Data direction principle (see `notes/migration-plan.md` Β§ "Data direction: cleaning upstream, reshape in SQL") refines that: **cleaning** items (value transforms on a single record) belong in pipeline emission and follow the per-item workflow in `notes/migration-plan.md`. **Reshape** items (dedup, aggregation, groupby, sort+select, hierarchy flatten) belong in DuckDB SQL β€” either materialized into pipeline parquet or computed at query time. Each item below is now annotated. For the active per-item migration view, see `notes/transformations/` and `notes/migration-plan.md`.
6
+
7
+ ## Status
8
+
9
+ - βœ… **#4 source-metadata synthesis fallback** β€” deleted 2026-04-27. Pipeline (commit 9090cc5) now carries `source_metadata` on every hierarchy `model_result` row; verified 86 183/86 183 production rows + 43 859/43 859 eval-detail rows. Removed `getCanonicalSourceMetadata` (lib/hf-data.ts), `buildSourceMetadataIndex` (lib/hf-data.ts), and three duplicate inline fallbacks in lib/model-data.ts (`buildBenchmarkLeaderboardMatrix`, `toModelResultsForMetric`, `buildSingleMetricSuiteMatrixSummary`). Added a runtime `assertSourceMetadata(result, context)` guard at each read site so a future pipeline regression fails loud (with model + eval IDs) instead of silently emitting `undefined` into the UI which dereferences `.evaluator_relationship` unguarded. The 1st/3rd-party badge collapse bug is now structurally impossible.
10
+ - **Intended behaviour change:** every model_result on eval pages now shows the real first/third-party badge instead of the previously-hardcoded "Other". 86 183/86 183 rows reclassified (3 119 first_party, 83 056 third_party, 8 still other). This was the explicit goal of item #4 ("known correctness gap").
11
+ - ⚠️ **#11 category-inference fallback (mostly reverted)** β€” narrowed 2026-04-27. Pipeline emits `category` on every eval-detail and every hierarchy node, BUT 84% of eval-details (496/587) carry `category: "other"` β€” the pipeline currently does NOT replicate the regex's classification work. Replacing the regex fallback would silently General-ify ~6 700 model rows including 4 known Safety evals (`helm_safety`, `helm_safety_simplesafetytests`, `helm_classic_truthfulqa`, `llm_stats_truthfulqa`) and 12+ hierarchy `(catKey, displayName)` pairs covering 1 500+ Safety-classified model rows (RewardBench safety, AIRBench subtasks, etc.).
12
+ - **Done safely:** added `coding`, `instruction_following`, `language_understanding` to `PIPELINE_CATEGORY_MAP` (all β†’ "General", matching prior `inferCategoryFromBenchmark` returns). Removed `?? inferCategoryFromBenchmark(c)` fallback in `mapHFCategories`, replaced with `?? "General"` β€” provably equivalent for all 9 currently-emitted pipeline keys.
13
+ - **Reverted (left for follow-up):** the `inferCategoryFromBenchmark` calls in `hfEvalDetailToSummary` (lib/model-data.ts:760, :803), `groupEvaluationsByBenchmark` (lib/eval-processing.ts:906), and `buildSingleMetricSuiteMatrixSummary` (lib/model-data.ts:1170) β€” keeping the regex inference until the pipeline emits accurate `category` for currently-`other` benchmarks.
14
+ - **Pre-existing inconsistency, not addressed:** `hfEvalEntryToListItem` (lib/model-data.ts:436) reads pipeline category, while `hfEvalDetailToSummary` reads regex. Same eval can show different categories in eval-list vs eval-detail. Was already in HEAD; dropping it requires the pipeline-side fix above first.
15
+
16
+ ### Open follow-ups for the pipeline side
17
+
18
+ 1. **Reclassify `category: "other"` evals.** 84% of eval-details get the catch-all. Either (a) train a classifier on the regex's intent, (b) tighten the pipeline's mapping rules, or (c) emit two fields (raw + ui_category). Until this lands, the regex inference must stay.
19
+ 2. **Mark known Safety benchmarks correctly.** TruthfulQA, SimpleSafetyTests, the 6 AIRBench 2024 subtasks, RewardBench safety, etc., all currently emit non-Safety categories.
20
+ 3. **Consider warn-once logging in `mapHFCategories`** when a new pipeline key arrives that isn't in the map. Today they silently default to "General".
21
+
22
+ ## Parity setup
23
+
24
+ `scripts/compare-data-backends.mjs` is the regression net for this migration. Run:
25
+
26
+ ```bash
27
+ # JSON-backed dev server, reading the pipeline output directly (offline)
28
+ HF_DATA_LOCAL_DIR=/Users/jchim/projects/eval_cards_backend_pipeline/output \
29
+ HF_DATA_OFFLINE=1 PORT=3001 pnpm dev
30
+
31
+ # DuckDB-backed dev server, same data source
32
+ DATA_BACKEND=duckdb \
33
+ LOCAL_PIPELINE_OUTPUT=/Users/jchim/projects/eval_cards_backend_pipeline/output \
34
+ HF_DATA_LOCAL_DIR=/Users/jchim/projects/eval_cards_backend_pipeline/output \
35
+ HF_DATA_OFFLINE=1 PORT=3002 pnpm dev
36
+
37
+ # Compare (PARITY_FAIL_FAST=0 prints every divergence path)
38
+ PARITY_FAIL_FAST=0 node scripts/compare-data-backends.mjs \
39
+ --json-base http://localhost:3001 --duckdb-base http://localhost:3002
40
+ ```
41
+
42
+ `HF_DATA_LOCAL_DIR` overrides `lib/hf-data.ts`'s default cache path to the sibling pipeline `output/`; `HF_DATA_OFFLINE=1` blocks remote fetches so background refreshes can't poison parity. Both env vars added 2026-04-27 specifically to enable this loop.
43
+
44
+ The harness covers 8 endpoints Γ— 1 ID each, and only what's in the local pipeline output (3 evals, 28 models). It is *not* exhaustive β€” production has 587 evals / 5830 models β€” but it has already caught the model-summary pass-through bug (lib/duckdb-data.ts `toModelSummary` was returning the raw pipeline payload with lowercase category keys). When working on a new migration item, run parity before AND after the change.
45
+
46
+
47
+
48
+ The Eval Cards Next.js app does not own its data: a Python pipeline publishes JSON
49
+ artifacts to `evaleval/card_backend` on Hugging Face, `scripts/cache-hf-data.mjs`
50
+ clones them at build time, and the `lib/` server modules adapt them at request
51
+ time. A meaningful chunk of "shape‑fixing" still happens in TypeScript on every
52
+ request or build. This note inventories those places and proposes which should
53
+ move into the pipeline (dataset creation step) so the frontend becomes a pure
54
+ read of canonical JSON.
55
+
56
+ ## Architecture recap
57
+
58
+ - `scripts/cache-hf-data.mjs` β€” clones `evaleval/card_backend` and writes
59
+ `.cache/hf-data/{manifest, model-cards, eval-list, developers, benchmark-metadata,
60
+ eval-hierarchy, comparison-index}.json` plus per-detail directories
61
+ `models/`, `evals/`, `developers/`. Also re-normalizes some files in JS after
62
+ download.
63
+ - `lib/hf-data.ts` β€” read layer over the cache + remote, plus converters that
64
+ flatten the pipeline's hierarchy back into `BenchmarkEvaluation[]`.
65
+ - `lib/model-data.ts` β€” adapts raw HF artifacts into the app's domain types
66
+ (`BenchmarkEvalSummary`, `EvaluationCardData`, `ModelEvaluationSummary`).
67
+ - `lib/eval-processing.ts` β€” re-aggregates per-model evaluations into family /
68
+ variant summaries.
69
+ - `app/api/*` β€” thin route wrappers around the getters above.
70
+
71
+ The pipeline already produces hierarchy, leaderboards, composite groupings, and
72
+ benchmark cards. The items below are what the frontend still has to do that
73
+ the pipeline could just emit instead.
74
+
75
+ ## Concrete migration candidates
76
+
77
+ ### 1. Family / variant identity parsing β€” *cleaning*
78
+ - Where: `lib/model-family.ts` (`getCanonicalModelIdentity`), duplicated in
79
+ `scripts/cache-hf-data.mjs:141-303` (`normalizeHandle`,
80
+ `getCanonicalFamilyInfo`, `getNormalizedVariantMeta`,
81
+ `normalizeCachedModelCardFile`).
82
+ - What it does: parses `model_info.id` (e.g. `anthropic/claude-3-5-sonnet-20240620`)
83
+ to derive `familyId`, `familySlug`, `versionDate`, `versionQualifier`,
84
+ `variantKey`, `variantLabel`. The cache script *rewrites* `model-cards.json`
85
+ in place after download so the runtime sees a canonicalized version.
86
+ - Move: pipeline should emit canonical `family_id`, `family_slug`,
87
+ `version_date`, `variant_key`, `variant_label` directly so neither the build
88
+ script nor `lib/model-family.ts` has to re‑derive them.
89
+
90
+ ### 2. Setup-alias merging ("prompt" / "fc" / "thinking" variants) β€” *dual: cleaning + reshape*
91
+ - Where: `lib/eval-processing.ts:371-434`
92
+ (`getSetupAliasMode`, `getAggregatedVariantDescriptor`); duplicated in
93
+ `scripts/cache-hf-data.mjs:199-246` (`getNormalizedVariantMeta`,
94
+ `isSetupAliasQualifier`).
95
+ - What it does: looks at `model_info.additional_details.mode` to decide whether
96
+ two model rows are the same release with different prompting setups
97
+ ("prompt" / "fc" / "function calling" / "thinking…") and merges them under
98
+ one variant key.
99
+ - Move:
100
+ - **Cleaning half** β€” pipeline emits a canonical `variant_key` (or `setup_alias_key`) per submission row.
101
+ - **Reshape half** β€” once the key is upstream, the bucket reduction (multiple rows with the same `variant_key` β†’ single variant entry, `MAX(retrieved_timestamp)`, merged evaluation results) becomes SQL `GROUP BY variant_key` rather than a TS reduce loop.
102
+
103
+ ### 3. Hierarchy β†’ flat `BenchmarkEvaluation[]` rebuild β€” *reshape*
104
+ - Where: `lib/hf-data.ts:1236-1436`
105
+ (`flattenModelEvaluations`, `flattenHierarchyNode`,
106
+ `buildSourceMetadataIndex`).
107
+ - What it does: walks the pipeline's `hierarchy_by_category` tree, attaches
108
+ `source_metadata` from a separate `evaluations_by_category` index (because
109
+ hierarchy rows don't carry it β€” see comment at `hf-data.ts:1410-1416`),
110
+ reconciles timestamps across variants, attaches inline samples, and re‑emits
111
+ a flat array so `createModelFamilySummary()` can group it again.
112
+ - Move:
113
+ - Denormalize `source_metadata` onto every hierarchy leaf so the side index
114
+ isn't needed (this half is cleaning, already done as part of #4).
115
+ - The flatten + variant-bucket reduction itself is reshape: emit a relational parquet schema (one row per `(eval_summary_id, variant_key, retrieved_timestamp, source_metadata, …)`) so the DuckDB backend can `SELECT … QUALIFY ROW_NUMBER() OVER (PARTITION BY variant_key ORDER BY retrieved_timestamp DESC) = 1` instead of TS walking the hierarchy and reducing variants. Decision pending: relational parquet vs pre-flattened JSON payload.
116
+
117
+ ### 4. Source-metadata synthesis fallback β€” *cleaning (DONE)*
118
+ - Where: `lib/hf-data.ts:1049-1064` (`getCanonicalSourceMetadata`); copies in
119
+ `lib/model-data.ts:736-741` (`toModelResultsForMetric`),
120
+ `lib/model-data.ts:1114-1119` (`buildSingleMetricSuiteMatrixSummary`).
121
+ - What it does: when the artifact omits source metadata, hardcodes
122
+ `source_type: "documentation"`, `evaluator_relationship: "other"`. The
123
+ result: any code path that goes through these silently collapses 1st / 3rd-party
124
+ badges to "Other".
125
+ - Move: source metadata should always be present on the artifact β€” the
126
+ fallback is a known correctness gap.
127
+
128
+ ### 5. Composite / aggregate eval construction β€” *reshape*
129
+ - Where: `lib/model-data.ts:874-1041` (`aggregateBenchmarkSummaries`).
130
+ - What it does: for any URL of the form `/evals/aggregate__<suite_key>`,
131
+ fetches every sub-eval detail individually, normalizes scores, computes
132
+ per-model averages, sorts, builds aggregate components, etc. ~170 lines.
133
+ - Move: per-model averaging across sub-evals + sort + composite assembly is reshape (groupby + aggregate). Two valid landing spots: (a) materialize as a first-class eval-detail file at pipeline emission time (`eval-hierarchy.json` already knows the composites), or (b) compute at query time in DuckDB SQL once eval rows are relational. Materialization is simpler if every consumer wants the same composite shape; query-time SQL gives consumer-driven slicing for free.
134
+
135
+ ### 6. Synthetic single-metric matrix leaderboard β€” *reshape*
136
+ - Where: `lib/model-data.ts:1043-1214`
137
+ (`buildSingleMetricSuiteMatrixSummary`).
138
+ - What it does: for `/evals/matrix__<suite_key>`, fetches every sub-eval,
139
+ builds a model Γ— subtask matrix, picks columns / rows, deduplicates, and
140
+ reconciles per-row timestamps. ~170 lines.
141
+ - Move: pivot from long-form rows to wide model Γ— subtask matrix is a SQL `PIVOT` (or grouped `MAX(score) FILTER (WHERE subtask = …)`) once eval rows are relational. Same materialize-vs-query-time choice as #5; lean toward materialization since the matrix shape is identical for every consumer.
142
+
143
+ ### 7. Instance-level JSONL parsing β€” *cleaning*
144
+ - Where: `lib/hf-data.ts:919-1029` (`parseInstanceLevelData`,
145
+ `fetchInstanceLevelData`).
146
+ - What it does: ~110 lines of heuristics probing for `input.raw`, `prompt`,
147
+ `question`, `doc.question`, `doc`, `output`, `model_output`,
148
+ `messages[].content`, `filtered_resps[0][0]`, `resps[0][0]`,
149
+ `answer_attribution`, `evaluation.is_correct`, `metrics.exact_match`, etc.
150
+ - Why: shape detection across lm-eval-harness, HELM, Inspect, and other
151
+ harness outputs.
152
+ - Move: pipeline should normalize each instance example to one canonical
153
+ `{sample_id, input, ground_truth, response, is_correct, metadata}` shape so
154
+ the frontend just renders.
155
+
156
+ ### 8. Display-name lookup tables β€” *cleaning*
157
+ - Where:
158
+ - `lib/model-data.ts:93-124` (`BENCHMARK_NAMES`, `getBenchmarkDisplayName`)
159
+ - `components/benchmark-detail.tsx:101-173` (`SUITE_DISPLAY_NAMES`,
160
+ `DISPLAY_TOKEN_OVERRIDES`, `DISPLAY_NAME_OVERRIDES`)
161
+ - `lib/eval-processing.ts:861-885` (`getBenchmarkDisplayName`)
162
+ - What it does: hand-maintained maps that translate keys like `helm_lite` β†’
163
+ "HELM Lite", `arc_agi` β†’ "ARC-AGI", etc.
164
+ - Move: schema already has `canonical_display_name`; pipeline should populate
165
+ it once and the frontend should drop the maps.
166
+
167
+ ### 9. Developer name canonicalization β€” *cleaning*
168
+ - Where: `lib/model-data.ts:201-228` (`KNOWN_DEVELOPER_NAMES`,
169
+ `normalizeDeveloperName`).
170
+ - What it does: `openai` β†’ "OpenAI", `mistralai` β†’ "Mistral AI",
171
+ `deepseek-ai` β†’ "DeepSeek", etc.
172
+ - Move: belongs in the pipeline's developer table.
173
+
174
+ ### 10. Generic metric-name expansion β€” *cleaning*
175
+ - Where: `lib/eval-processing.ts:27-34, 70-86` (`GENERIC_EVALUATION_NAMES`,
176
+ `getEvaluationDisplayName`); mirrored heuristically in
177
+ `lib/model-data.ts:444-454` (`prefersBenchmarkName` detection of
178
+ "accuracy on…", "score on…", "for scorer…", "model_graded").
179
+ - What it does: expands "Accuracy" β†’ "MMLU - Accuracy" when the metric name
180
+ is generic.
181
+ - Move: pipeline should emit a `display_name` that's already the right
182
+ thing.
183
+
184
+ ### 11. Category inference fallback β€” *cleaning (partial)*
185
+ - Where: `lib/benchmark-schema.ts:182-206` (`inferCategoryFromBenchmark`),
186
+ used in `lib/eval-processing.ts:300-307`,
187
+ `lib/model-data.ts:776, 819, 1194`. Plus `PIPELINE_CATEGORY_MAP` /
188
+ `mapHFCategories` in `lib/hf-data.ts:1453-1469`.
189
+ - What it does: regex fallback when the pipeline omits `category`.
190
+ - Move: pipeline should emit `category` for every row so neither fallback nor
191
+ `mapHFCategories` is necessary.
192
+
193
+ ### 12. Parameter-count parsing from free text and model names β€” *cleaning*
194
+ - Where:
195
+ - `lib/model-data.ts:296-338` (`parseParamsBillions`)
196
+ - `components/eval-detail.tsx:81-184`
197
+ (`parseParamsBillionsFromText`, `parseParamsBillionsFromModelName`,
198
+ `getParamsBillionsFromModelInfo`)
199
+ - `app/evals/[id]/page.tsx:434-437` (regex `\b(\d+(?:\.\d+)?)\s*[bB]\b`
200
+ against `name + " " + id`)
201
+ - What it does: parses "70B", "1.5B", "405b", "7 billion", "1.2T" etc. The
202
+ matrix leaderboard even regex-extracts size from the model display name.
203
+ - Move: pipeline should emit a normalized numeric `params_billions` so the
204
+ frontend doesn't need parsers in three places.
205
+
206
+ ### 13. Timestamp normalization β€” *dual: cleaning + reshape*
207
+ - Where: `lib/eval-processing.ts:322-330, 572-577, 962-968`,
208
+ `lib/model-data.ts:60-65`, `components/eval-detail.tsx:218-238`,
209
+ `lib/hf-data.ts:1035-1047` (`toComparableTimestamp`).
210
+ - What it does: timestamps arrive as either unix-seconds-as-string
211
+ (`"1774096306.427425"`) or ISO strings, and the same `Number(ts)` /
212
+ `new Date(ts)` branching is reimplemented at every read site.
213
+ - Move:
214
+ - **Cleaning** β€” pipeline emits ISO-8601 strings everywhere (currently 99.99% unix-seconds-strings + 5 ISO).
215
+ - **Reshape** β€” the comparison call sites (8 of them across `lib/model-data.ts`, `lib/hf-data.ts`, `components/benchmark-detail.tsx`) all exist to pick the freshest variant or sort by recency. Once timestamps are ISO 8601, `MAX(retrieved_timestamp)` and `ORDER BY retrieved_timestamp DESC` work as SQL β€” three TS normalizers + 8 callers delete together. See `notes/transformations/07-timestamp-normalization.md`.
216
+
217
+ ### 14. Score normalization and summary stats β€” *reshape*
218
+ - Where: `lib/eval-processing.ts:946-991` (`groupEvaluationsByBenchmark`
219
+ finalisation), `lib/model-data.ts:67-72, 803-851` (`hfEvalDetailToSummary`).
220
+ - What it does: recomputes `models_count`, `avg_score`, `avg_score_norm`,
221
+ `best_model`, `worst_model`, `evaluator_names`, `source_types`,
222
+ `latest_source_name`, `third_party_ratio`,
223
+ `missing_generation_config_count` even though `eval-list.json` already
224
+ carries `top_score` / `models_count`.
225
+ - Move: aggregation over `model_results` (count, avg, min/max, distinct counts, ratios) is reshape β€” pure SQL groupby. Materialize at pipeline emission OR compute at query time once `model_results` are relational rows in parquet. Either way the TS finalisation deletes.
226
+
227
+ ### 16. Per-category benchmark counts on model cards β€” *reshape*
228
+ - Where: `lib/model-data.ts:354-363` (proportional distribution in
229
+ `hfModelCardToEvaluationCardData`).
230
+ - What it does: because `model-cards.json` doesn't carry `category_stats`, TS
231
+ does `Math.floor(total / categories.length)` to *fake* a per-category split.
232
+ - Move: per-(model, category) benchmark count is `SELECT model, category, COUNT(DISTINCT benchmark) GROUP BY model, category` β€” pure SQL groupby. Materialize as `category_stats` on the model card OR compute at query time. The TS `Math.floor(total / categories.length)` placeholder ships incorrect numbers; the SQL version is the actual answer.
233
+
234
+ ### 17. Benchmark-card attachment at request time β€” *cleaning*
235
+ - Where: `lib/benchmark-metadata.ts:38-49` (`getBenchmarkCard`,
236
+ `getMap`), `lib/model-data.ts:857-872` (`attachBenchmarkCardToSummary`).
237
+ - What it does: iterates 3 candidate names per eval and looks each up in a
238
+ deduped `Map<string, BenchmarkCard>`.
239
+ - Move: eval-detail files already sometimes carry `benchmark_card` inline β€”
240
+ the pipeline should always inline it (or always reference by stable key) so
241
+ the runtime lookup table can be deleted.
242
+
243
+ ### 18. License canonicalization β€” *cleaning*
244
+ - Where: `components/eval-card.tsx:22-48` (`LICENSE_COLORS`,
245
+ `licenseBadgeClass`, `shortenLicense`).
246
+ - What it does: "Creative Commons Attribution 4.0" β†’ "CC BY 4.0",
247
+ "Apache License 2.0" β†’ "Apache 2.0", "Creative Commons Zero" β†’ "CC0", etc.
248
+ - Move: pipeline should expose a normalized SPDX-style license identifier
249
+ alongside the long string.
250
+
251
+ ### 19. Slug candidate generation for HF lookups β€” *cleaning*
252
+ - Where: `lib/model-data.ts:151-194` (`getModelDetailSlugCandidates`,
253
+ `getDeveloperSlugCandidates`); used by `lib/model-data.ts:1467-1509`
254
+ (`getModelSummaryById`).
255
+ - What it does: produces up to 6 spelling variants of a slug (`gpt-3.5` ↔
256
+ `gpt-3-5`, `__` vs `/`, `_` vs `-`) and `getModelSummaryById` retries each
257
+ candidate against the dataset.
258
+ - Move: the manifest could explicitly map every `model_family_id` /
259
+ `route_id` β†’ file path so retry loops disappear.
260
+
261
+ ### 20. Dataset URL synthesis β€” *cleaning*
262
+ - Where: `components/eval-card.tsx:81-89`.
263
+ - What it does: computes
264
+ `dataset_url ?? url[0] ?? https://huggingface.co/datasets/${hf_repo}` to
265
+ derive a clickable link.
266
+ - Move: pipeline already has `hf_repo` and could just emit the resolved URL
267
+ once.
268
+
269
+ ## Suggested priorities
270
+
271
+ If triaging by payoff:
272
+
273
+ 1. **#3 + #4 β€” hierarchy flatten + source-metadata index.** Eliminates the
274
+ largest in-process function in the codebase and a known correctness bug
275
+ ("Other" badge collapse).
276
+ 2. **#5 + #6 β€” composites & matrix synthesis.** Replaces ~340 lines of
277
+ request-time TS reconstruction with pre-built artifacts.
278
+ 3. **#7 β€” instance JSONL parser.** Biggest brittleness surface; one
279
+ normalization step in the pipeline removes a heuristic.
280
+ 4. **#1 + #2 β€” identity parsing & setup-alias merging.** Removes
281
+ triple-maintained logic across `lib/model-family.ts`,
282
+ `lib/eval-processing.ts`, and `scripts/cache-hf-data.mjs`.
283
+ 5. **#8–#13 β€” display names, developer names, params parsing, timestamps,
284
+ category fallback.** Small individually, but together they are scattered
285
+ "polish" code that should live next to the data.
scripts/_server_only_stub.cjs ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ // Empty stub for `server-only`. The real module throws on every
2
+ // non-Next.js import; for the parity verifier we only run pure data
3
+ // adapters, so swallowing the import is safe.
4
+ module.exports = {}
scripts/dump-adapter-outputs.mts ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-nocheck
2
+ // Dump current TS-adapter outputs for the cross-repo parity verifier.
3
+ //
4
+ // The `server-only` import on every lib file at line 1 throws under Node;
5
+ // the wrapper Python verifier preloads `scripts/server_only_hook.cjs`
6
+ // via NODE_OPTIONS before invoking this script.
7
+ //
8
+ // Reads a pipeline output directory (`output/`), runs each TS adapter
9
+ // against the corresponding JSON, and writes the expected payload set
10
+ // as JSON to stdout (or a file).
11
+ //
12
+ // Usage (from `general-eval-card/`):
13
+ // pnpm tsx scripts/dump-adapter-outputs.mts \
14
+ // --pipeline-output /Users/jchim/projects/evaleval/eval_cards_backend_pipeline/output \
15
+ // --out /tmp/parity-expected.json
16
+ //
17
+ // Surfaces dumped: model_cards, model_cards_lite, eval_list, eval_list_lite,
18
+ // eval_summaries (per detail), model_summaries (per family),
19
+ // aggregate_eval_summaries (per suite), matrix_eval_summaries (per suite),
20
+ // developer_summaries, developers.
21
+ import { createWriteStream, readFileSync, readdirSync, writeFileSync } from "node:fs"
22
+ import { join } from "node:path"
23
+
24
+ import * as ModelDataMod from "@/lib/model-data"
25
+ import * as HfDataMod from "@/lib/hf-data"
26
+ import * as EvalProcessingMod from "@/lib/eval-processing"
27
+ import * as BenchmarkMetadataUtilsMod from "@/lib/benchmark-metadata-utils"
28
+
29
+ // Under `tsx`, the libs are CommonJS modules; ESM `import *` lifts the
30
+ // real exports onto the synthetic `default`. Pull them off there.
31
+ const ModelData: any = (ModelDataMod as any).default ?? ModelDataMod
32
+ const HfData: any = (HfDataMod as any).default ?? HfDataMod
33
+ const EvalProcessing: any =
34
+ (EvalProcessingMod as any).default ?? EvalProcessingMod
35
+ const BenchmarkMetadataUtils: any =
36
+ (BenchmarkMetadataUtilsMod as any).default ?? BenchmarkMetadataUtilsMod
37
+ const candidateBenchmarkKeys = BenchmarkMetadataUtils.candidateBenchmarkKeys
38
+
39
+ const hfModelCardToEvaluationCardData = ModelData.hfModelCardToEvaluationCardData
40
+ const hfEvalEntryToListItem = ModelData.hfEvalEntryToListItem
41
+ const hfEvalDetailToSummary = ModelData.hfEvalDetailToSummary
42
+ const hfDeveloperDetailToSummary = ModelData.hfDeveloperDetailToSummary
43
+ const aggregateBenchmarkSummaries = ModelData.aggregateBenchmarkSummaries
44
+ const buildSingleMetricSuiteMatrixSummary = ModelData.buildSingleMetricSuiteMatrixSummary
45
+ const flattenModelEvaluations = HfData.flattenModelEvaluations
46
+ // Mirror the request-time normalizer in `fetchModelCardsList` /
47
+ // `fetchModelCardsListLite` (lib/hf-data.ts:822, 827): every fetch runs
48
+ // `normalizeSingleModelCardEntry` to merge setup-alias variants
49
+ // ("prompt"/"fc"/"thinking") under one variant_key. Without this the
50
+ // dump's variant_count is the raw 6-variant count from disk, but the
51
+ // user-facing API returns the post-merge count (~2-3).
52
+ const normalizeSingleModelCardEntry = HfData.normalizeSingleModelCardEntry
53
+ const createModelFamilySummary = EvalProcessing.createModelFamilySummary
54
+
55
+ function parseArgs() {
56
+ const args: Record<string, string> = {}
57
+ for (let i = 2; i < process.argv.length; i++) {
58
+ const arg = process.argv[i]
59
+ if (arg.startsWith("--")) {
60
+ const key = arg.slice(2)
61
+ const next = process.argv[i + 1]
62
+ if (next && !next.startsWith("--")) {
63
+ args[key] = next
64
+ i++
65
+ } else {
66
+ args[key] = "true"
67
+ }
68
+ }
69
+ }
70
+ return args
71
+ }
72
+
73
+ function readJSON<T>(path: string): T {
74
+ return JSON.parse(readFileSync(path, "utf-8")) as T
75
+ }
76
+
77
+ function listJSONFiles(dir: string): string[] {
78
+ try {
79
+ return readdirSync(dir).filter((p) => p.endsWith(".json"))
80
+ } catch {
81
+ return []
82
+ }
83
+ }
84
+
85
+ interface DumpedSurface {
86
+ surface: string
87
+ by_id: Record<string, unknown>
88
+ }
89
+
90
+ const args = parseArgs()
91
+ const pipelineRoot = args["pipeline-output"]
92
+ const outPath = args["out"]
93
+ if (!pipelineRoot) {
94
+ console.error("Missing --pipeline-output <dir>")
95
+ process.exit(2)
96
+ }
97
+
98
+ const modelCards = readJSON<Array<Record<string, any>>>(
99
+ join(pipelineRoot, "model-cards.json")
100
+ )
101
+ const modelCardsLite = readJSON<Array<Record<string, any>>>(
102
+ join(pipelineRoot, "model-cards-lite.json")
103
+ )
104
+ const evalList = readJSON<{ evals: Array<Record<string, any>> }>(
105
+ join(pipelineRoot, "eval-list.json")
106
+ )
107
+ const evalListLite = readJSON<{ evals: Array<Record<string, any>> }>(
108
+ join(pipelineRoot, "eval-list-lite.json")
109
+ )
110
+ const evalsDir = join(pipelineRoot, "evals")
111
+ const modelsDir = join(pipelineRoot, "models")
112
+
113
+ // Build a sync benchmark card lookup mirror of `lib/benchmark-metadata.ts`'s
114
+ // `readPipelineBenchmarkCards` β€” used for aggregate/matrix surfaces, which
115
+ // in the request-time TS path call the async `attachBenchmarkCardToSummary`.
116
+ // The pipeline parity layer (`scripts/parity_outputs.py:build_aggregate_eval_summaries`)
117
+ // uses a sync card_map; mirror that approach so the dump runs without
118
+ // network/async machinery.
119
+ const benchmarkMetadata = readJSON<Record<string, any>>(
120
+ join(pipelineRoot, "benchmark-metadata.json")
121
+ )
122
+ const benchmarkCardMap = new Map<string, any>()
123
+ for (const card of Object.values(benchmarkMetadata)) {
124
+ const cardObj = card as Record<string, any>
125
+ const name = cardObj?.benchmark_details?.name
126
+ if (!name) continue
127
+ for (const key of candidateBenchmarkKeys(name)) {
128
+ if (!benchmarkCardMap.has(key)) {
129
+ benchmarkCardMap.set(key, cardObj)
130
+ }
131
+ }
132
+ }
133
+
134
+ function syncGetBenchmarkCard(name: string | undefined | null): any | null {
135
+ if (!name) return null
136
+ for (const key of candidateBenchmarkKeys(name)) {
137
+ const card = benchmarkCardMap.get(key)
138
+ if (card) return card
139
+ }
140
+ return null
141
+ }
142
+
143
+ // Sync mirror of `attachBenchmarkCardToSummary` (lib/model-data.ts). The
144
+ // async upstream awaits `getBenchmarkCard`; here we use the sync card map
145
+ // loaded from `benchmark-metadata.json` so dump-adapter-outputs stays
146
+ // fully sync (no fetch / `server-only` boundary).
147
+ function syncAttachBenchmarkCardToSummary(summary: any): any {
148
+ if (summary?.benchmark_card) return summary
149
+ const candidates = [
150
+ summary?.evaluation_name,
151
+ summary?.composite_benchmark_name,
152
+ summary?.composite_benchmark_key,
153
+ ]
154
+ for (const candidate of candidates) {
155
+ const card = syncGetBenchmarkCard(candidate)
156
+ if (card) return { ...summary, benchmark_card: card }
157
+ }
158
+ return summary
159
+ }
160
+
161
+ const dumped: DumpedSurface[] = []
162
+
163
+ dumped.push({
164
+ surface: "model_cards",
165
+ by_id: Object.fromEntries(
166
+ modelCards.map((card) => {
167
+ const normalized = normalizeSingleModelCardEntry(card as any)
168
+ const payload = hfModelCardToEvaluationCardData(normalized as any)
169
+ // Key by the adapter-canonical `route_id` so it matches parquet's
170
+ // scalar `model_route_id` column.
171
+ return [payload.route_id, payload]
172
+ })
173
+ ),
174
+ })
175
+
176
+ // model_cards_lite β€” same adapter pipeline as model_cards, but reads the
177
+ // `-lite.json` source. Mirrors `getModelCardsLite()` in lib/model-data.ts.
178
+ dumped.push({
179
+ surface: "model_cards_lite",
180
+ by_id: Object.fromEntries(
181
+ modelCardsLite.map((card) => {
182
+ const normalized = normalizeSingleModelCardEntry(card as any)
183
+ const payload = hfModelCardToEvaluationCardData(normalized as any)
184
+ return [payload.route_id, payload]
185
+ })
186
+ ),
187
+ })
188
+
189
+ // Mirror the request-time filter in `getEvalListData` /
190
+ // `getEvalListLiteData` (lib/model-data.ts:1251, 1291): drop entries
191
+ // whose `source_data.hf_repo` starts with `example://`. Applied here so
192
+ // the parity verifier sees the same user-facing shape the parity emitter
193
+ // produces (`scripts/parity_outputs.py:_is_example_eval_entry`).
194
+ function isExampleEntry(entry: Record<string, any>): boolean {
195
+ const repo = entry?.source_data?.hf_repo
196
+ return typeof repo === "string" && repo.startsWith("example://")
197
+ }
198
+
199
+ dumped.push({
200
+ surface: "eval_list",
201
+ by_id: Object.fromEntries(
202
+ (evalList.evals ?? [])
203
+ .filter((entry) => !isExampleEntry(entry))
204
+ .map((entry) => {
205
+ const payload = hfEvalEntryToListItem(entry as any)
206
+ return [payload.evaluation_id, payload]
207
+ })
208
+ ),
209
+ })
210
+
211
+ // eval_list_lite β€” same adapter as eval_list but reads from
212
+ // `eval-list-lite.json`; mirrors `getEvalListLiteData` in lib/model-data.ts
213
+ // (which also strips example entries).
214
+ dumped.push({
215
+ surface: "eval_list_lite",
216
+ by_id: Object.fromEntries(
217
+ (evalListLite.evals ?? [])
218
+ .filter((entry) => !isExampleEntry(entry))
219
+ .map((entry) => {
220
+ const payload = hfEvalEntryToListItem(entry as any)
221
+ return [payload.evaluation_id, payload]
222
+ })
223
+ ),
224
+ })
225
+
226
+ const evalDetails: Array<Record<string, any>> = listJSONFiles(evalsDir).map((file) =>
227
+ readJSON<Record<string, any>>(join(evalsDir, file))
228
+ )
229
+
230
+ dumped.push({
231
+ surface: "eval_summaries",
232
+ by_id: Object.fromEntries(
233
+ evalDetails.map((detail) => [
234
+ detail.eval_summary_id,
235
+ hfEvalDetailToSummary(detail as any),
236
+ ])
237
+ ),
238
+ })
239
+
240
+ // aggregate_eval_summaries β€” port of the `aggregate__<suite_key>` branch in
241
+ // `getEvalSummaryById` (lib/model-data.ts). The TS path runs each sub-eval
242
+ // through `hfEvalDetailToSummary`, attaches a benchmark card, then calls
243
+ // `aggregateBenchmarkSummaries(summaries, suiteKey)`. We mirror parity
244
+ // emitter `build_aggregate_eval_summaries` (parity_outputs.py:212-258):
245
+ // - group by `benchmark_family_key || benchmark_parent_key`
246
+ // - skip groups with fewer than 2 distinct sub-evals
247
+ // Keyed by `payload.evaluation_id` (= `aggregate__<suite_key>`).
248
+ {
249
+ const aggregateGroups = new Map<string, Record<string, any>[]>()
250
+ for (const detail of evalDetails) {
251
+ const suiteKey =
252
+ detail.benchmark_family_key || detail.benchmark_parent_key
253
+ if (!suiteKey) continue
254
+ if (!detail.eval_summary_id) continue
255
+ const list = aggregateGroups.get(String(suiteKey)) ?? []
256
+ list.push(detail)
257
+ aggregateGroups.set(String(suiteKey), list)
258
+ }
259
+
260
+ const aggregateById: Record<string, any> = {}
261
+ for (const [suiteKey, details] of aggregateGroups.entries()) {
262
+ // De-dupe by eval_summary_id (parity also dedupes β€” first-write-wins).
263
+ const seenIds = new Set<string>()
264
+ const uniqueDetails: Record<string, any>[] = []
265
+ for (const detail of details) {
266
+ const id = detail.eval_summary_id
267
+ if (seenIds.has(id)) continue
268
+ seenIds.add(id)
269
+ uniqueDetails.push(detail)
270
+ }
271
+ if (uniqueDetails.length < 2) continue
272
+
273
+ const summaries = uniqueDetails.map((detail) => {
274
+ const summary = hfEvalDetailToSummary(detail as any)
275
+ return syncAttachBenchmarkCardToSummary(summary)
276
+ })
277
+
278
+ const aggregated = aggregateBenchmarkSummaries(summaries as any, suiteKey)
279
+ if (!aggregated) continue
280
+ aggregateById[aggregated.evaluation_id] = aggregated
281
+ }
282
+
283
+ dumped.push({
284
+ surface: "aggregate_eval_summaries",
285
+ by_id: aggregateById,
286
+ })
287
+ }
288
+
289
+ // matrix_eval_summaries β€” port of the `matrix__<suite_key>` branch in
290
+ // `getEvalSummaryById`. Mirrors parity emitter `build_matrix_eval_summaries`
291
+ // (parity_outputs.py:261-281):
292
+ // - skip details where `is_summary_score` is true
293
+ // - group by `benchmark_family_key || benchmark_parent_key`
294
+ // - call `buildSingleMetricSuiteMatrixSummary(details, suiteKey)`
295
+ // Keyed by `payload.evaluation_id` (= `matrix__<suite_key>`).
296
+ {
297
+ const matrixGroups = new Map<string, Record<string, any>[]>()
298
+ for (const detail of evalDetails) {
299
+ if (detail.is_summary_score) continue
300
+ const suiteKey =
301
+ detail.benchmark_family_key || detail.benchmark_parent_key
302
+ if (!suiteKey) continue
303
+ const list = matrixGroups.get(String(suiteKey)) ?? []
304
+ list.push(detail)
305
+ matrixGroups.set(String(suiteKey), list)
306
+ }
307
+
308
+ const matrixById: Record<string, any> = {}
309
+ for (const [suiteKey, details] of matrixGroups.entries()) {
310
+ const result = buildSingleMetricSuiteMatrixSummary(details as any, suiteKey)
311
+ if (!result) continue
312
+ const attached = syncAttachBenchmarkCardToSummary(result)
313
+ matrixById[attached.evaluation_id] = attached
314
+ }
315
+
316
+ dumped.push({
317
+ surface: "matrix_eval_summaries",
318
+ by_id: matrixById,
319
+ })
320
+ }
321
+
322
+ const modelDetails: Array<Record<string, any>> = listJSONFiles(modelsDir).map((file) =>
323
+ readJSON<Record<string, any>>(join(modelsDir, file))
324
+ )
325
+
326
+ dumped.push({
327
+ surface: "model_summaries",
328
+ by_id: Object.fromEntries(
329
+ modelDetails.flatMap((detail) => {
330
+ try {
331
+ const evaluations = flattenModelEvaluations(detail as any)
332
+ if (evaluations.length === 0) return []
333
+ const payload = createModelFamilySummary(evaluations as any)
334
+ return [[payload.model_route_id, payload]]
335
+ } catch (error) {
336
+ // The TS guard `assertSourceMetadata` throws when source_metadata
337
+ // is missing on any model_result; surface as a parity-comparable
338
+ // sentinel keyed by the canonical route id (lookup against the
339
+ // canonical model_family_id avoids drift when the input file's
340
+ // route_id was generated pre-canonicalization).
341
+ const fallbackRoute = (detail.model_family_id ?? "").replace(/\//g, "__")
342
+ return [[fallbackRoute || detail.model_route_id, { _adapter_error: String(error) }]]
343
+ }
344
+ })
345
+ ),
346
+ })
347
+
348
+ // Developer surfaces β€” port of getDeveloperList / getDeveloperSummaryById.
349
+ // Both run hfDeveloperDetailToSummary against pipeline `developers/*.json`;
350
+ // the list endpoint then strips `models[]` to keep the index lightweight.
351
+ const developersDir = join(pipelineRoot, "developers")
352
+ const developerDetails: Array<Record<string, any>> = listJSONFiles(developersDir)
353
+ .map((file) => readJSON<Record<string, any>>(join(developersDir, file)))
354
+ .filter((detail) => detail && detail.developer && Array.isArray(detail.models))
355
+
356
+ const developerSummaries = developerDetails.map((detail) => hfDeveloperDetailToSummary(detail as any))
357
+
358
+ dumped.push({
359
+ surface: "developer_summaries",
360
+ by_id: Object.fromEntries(developerSummaries.map((s) => [s.route_id, s])),
361
+ })
362
+
363
+ dumped.push({
364
+ surface: "developers",
365
+ by_id: Object.fromEntries(
366
+ developerSummaries.map((s) => {
367
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
368
+ const { models, ...listEntry } = s
369
+ return [listEntry.route_id, listEntry]
370
+ })
371
+ ),
372
+ })
373
+
374
+ // Stream surfaces individually β€” `JSON.stringify` of the full dump can
375
+ // exceed Node's max string length on production-scale corpora (~5.8k
376
+ // model_summaries with nested `evaluations_by_category`).
377
+ function streamSurface(handle: NodeJS.WritableStream, surface: DumpedSurface, isFirst: boolean) {
378
+ if (!isFirst) handle.write(",")
379
+ handle.write(`{"surface":${JSON.stringify(surface.surface)},"by_id":{`)
380
+ let first = true
381
+ for (const [key, value] of Object.entries(surface.by_id)) {
382
+ if (!first) handle.write(",")
383
+ first = false
384
+ handle.write(JSON.stringify(key))
385
+ handle.write(":")
386
+ handle.write(JSON.stringify(value))
387
+ }
388
+ handle.write("}}")
389
+ }
390
+
391
+ async function emit(): Promise<void> {
392
+ if (outPath) {
393
+ const stream = createWriteStream(outPath)
394
+ stream.write("[")
395
+ dumped.forEach((surface, idx) => streamSurface(stream as any, surface, idx === 0))
396
+ stream.write("]")
397
+ await new Promise<void>((resolve) => {
398
+ stream.end(() => resolve())
399
+ })
400
+ console.log(`Wrote ${dumped.length} surfaces to ${outPath}`)
401
+ } else {
402
+ dumped.forEach((surface, idx) =>
403
+ streamSurface(process.stdout, surface, idx === 0)
404
+ )
405
+ }
406
+ }
407
+
408
+ await emit()
scripts/server_only_hook.cjs ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ // Pre-load require hook that neutralizes `server-only`.
2
+ // Used by `dump-adapter-outputs.mts` via `--require`.
3
+ const Module = require("node:module")
4
+ const origLoad = Module._load
5
+ Module._load = function (request, ...rest) {
6
+ if (request === "server-only") {
7
+ return {}
8
+ }
9
+ return origLoad.apply(this, [request, ...rest])
10
+ }
scripts/verify-benchmark-card-attachment.mjs ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./server-only-shim.mjs"
2
+ import fs from "fs"
3
+ import path from "path"
4
+
5
+ // Audit script for `notes/transformations/11-benchmark-card-attachment.md`.
6
+ // Walks .cache/hf-data/ and quantifies:
7
+ // 1. % of evals with `benchmark_card` already inline vs needing runtime lookup
8
+ // 2. distribution of which candidate position (1st/2nd/3rd/none) resolves
9
+ // under each retry order (summary path: name, name, key; list path: name,
10
+ // key, name)
11
+ // 3. benchmark cards in benchmark-metadata.json with no inbound match from
12
+ // any eval (orphaned cards)
13
+ // 4. evals reachable only via 2nd or 3rd candidate (the load-bearing retry
14
+ // tail β€” these are the rows that would 404 if pipeline switched to a
15
+ // single-candidate lookup without first inlining benchmark_card)
16
+ // 5. map-build first-write-wins collisions (cards silently dropped)
17
+
18
+ const CACHE_DIR = ".cache/hf-data"
19
+
20
+ if (!fs.existsSync(CACHE_DIR)) {
21
+ console.error(`[verify-benchmark-card-attachment] Cache missing at ${CACHE_DIR}.`)
22
+ console.error("Run `pnpm cache-hf-data` first to prime the local HF data cache.")
23
+ process.exit(1)
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Verbatim copies of the TS transformation pieces.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ function normalizeBenchmarkKey(name) {
31
+ if (!name) return ""
32
+ return name
33
+ .replace(/^[a-z0-9_]+ ?\//i, "")
34
+ .toLowerCase()
35
+ .replace(/[_-]+/g, " ")
36
+ .replace(/\s+/g, " ")
37
+ .trim()
38
+ }
39
+
40
+ function candidateBenchmarkKeys(name) {
41
+ const base = normalizeBenchmarkKey(name)
42
+ return Array.from(
43
+ new Set([
44
+ base,
45
+ base.replace(/-/g, " "),
46
+ base.replace(/ /g, "-"),
47
+ base.replace(/[^a-z0-9]/g, ""),
48
+ ])
49
+ )
50
+ }
51
+
52
+ function buildMap(cards) {
53
+ const map = new Map()
54
+ const collisions = []
55
+ for (const card of Object.values(cards)) {
56
+ if (!card?.benchmark_details?.name) continue
57
+ for (const key of candidateBenchmarkKeys(card.benchmark_details.name)) {
58
+ const existing = map.get(key)
59
+ if (existing) {
60
+ if (existing !== card) {
61
+ collisions.push({
62
+ key,
63
+ kept: existing.benchmark_details.name,
64
+ dropped: card.benchmark_details.name,
65
+ })
66
+ }
67
+ } else {
68
+ map.set(key, card)
69
+ }
70
+ }
71
+ }
72
+ return { map, collisions }
73
+ }
74
+
75
+ function getBenchmarkCard(map, benchmarkName) {
76
+ for (const key of candidateBenchmarkKeys(benchmarkName)) {
77
+ const card = map.get(key)
78
+ if (card) return card
79
+ }
80
+ return null
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Replicate hfEvalEntryToListItem just for the two composite_benchmark_* fields.
85
+ // (Source: lib/model-data.ts:427-505. We don't need the rest of the adapter.)
86
+ // ---------------------------------------------------------------------------
87
+
88
+ function deriveCompositeFields(entry) {
89
+ // Pipeline doesn't always populate `evaluation_name`; the adapter has a
90
+ // chain of fallbacks. For audit purposes use the same chain.
91
+ const rawDisplayName =
92
+ entry.evaluation_name || entry.display_name || entry.benchmark_leaf_name || entry.eval_summary_id
93
+ // composite_benchmark_key = entry.benchmark
94
+ // composite_benchmark_name = a display-name lookup; we approximate with
95
+ // benchmark_parent_name || benchmark (matches getBenchmarkDisplayName fallback).
96
+ return {
97
+ evaluation_name: rawDisplayName,
98
+ composite_benchmark_key: entry.benchmark ?? "",
99
+ composite_benchmark_name: entry.benchmark_parent_name || entry.benchmark || "",
100
+ }
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Load corpus
105
+ // ---------------------------------------------------------------------------
106
+
107
+ const benchmarkMetadata = JSON.parse(
108
+ fs.readFileSync(path.join(CACHE_DIR, "benchmark-metadata.json"), "utf8")
109
+ )
110
+ const evalList = JSON.parse(fs.readFileSync(path.join(CACHE_DIR, "eval-list.json"), "utf8"))
111
+ const evals = evalList.evals ?? []
112
+ const evalsDir = path.join(CACHE_DIR, "evals")
113
+ const evalDetailFiles = fs.readdirSync(evalsDir)
114
+
115
+ console.log(`=== Corpus ===`)
116
+ console.log(` benchmark-metadata.json cards: ${Object.keys(benchmarkMetadata).length}`)
117
+ console.log(` eval-list.json entries: ${evals.length}`)
118
+ console.log(` evals/*.json detail files: ${evalDetailFiles.length}`)
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // 1. Inline-vs-lookup coverage
122
+ // ---------------------------------------------------------------------------
123
+
124
+ let inline = 0
125
+ let needsLookup = 0
126
+ for (const e of evals) {
127
+ if (e.benchmark_card) inline++
128
+ else needsLookup++
129
+ }
130
+ console.log(`\n=== Inline benchmark_card coverage in eval-list.json ===`)
131
+ console.log(
132
+ ` inline (pipeline already populated): ${inline} (${((inline / evals.length) * 100).toFixed(1)}%)`
133
+ )
134
+ console.log(
135
+ ` needs runtime lookup: ${needsLookup} (${((needsLookup / evals.length) * 100).toFixed(1)}%)`
136
+ )
137
+
138
+ // Same check on the per-eval detail files
139
+ let detailInline = 0
140
+ let detailMissing = 0
141
+ for (const f of evalDetailFiles) {
142
+ const j = JSON.parse(fs.readFileSync(path.join(evalsDir, f), "utf8"))
143
+ if (j.benchmark_card) detailInline++
144
+ else detailMissing++
145
+ }
146
+ console.log(`\n=== Inline benchmark_card coverage in evals/*.json ===`)
147
+ console.log(
148
+ ` inline: ${detailInline} (${((detailInline / evalDetailFiles.length) * 100).toFixed(1)}%)`
149
+ )
150
+ console.log(
151
+ ` missing: ${detailMissing} (${((detailMissing / evalDetailFiles.length) * 100).toFixed(1)}%)`
152
+ )
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // 2. Build map and run both retry orders
156
+ // ---------------------------------------------------------------------------
157
+
158
+ const { map, collisions } = buildMap(benchmarkMetadata)
159
+
160
+ console.log(`\n=== Map build ===`)
161
+ console.log(` total keys indexed: ${map.size}`)
162
+ console.log(` first-write-wins collisions: ${collisions.length}`)
163
+ if (collisions.length) {
164
+ console.log(` collision examples (kept ← dropped):`)
165
+ for (const c of collisions.slice(0, 10)) {
166
+ console.log(` [${c.key}] ${JSON.stringify(c.kept)} ← ${JSON.stringify(c.dropped)}`)
167
+ }
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // 3. Resolution position distribution under each retry order
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function resolutionPosition(map, candidates) {
175
+ const filtered = candidates.filter(Boolean)
176
+ for (let i = 0; i < filtered.length; i++) {
177
+ if (getBenchmarkCard(map, filtered[i])) return i
178
+ }
179
+ return -1
180
+ }
181
+
182
+ const summaryPositions = new Map() // pos β†’ count
183
+ const listPositions = new Map()
184
+ const summaryMisses = []
185
+ const listMisses = []
186
+ const tailHitsSummary = [] // resolved only via candidate 1 or 2 (i.e. retry-load-bearing)
187
+ const tailHitsList = []
188
+ const matchedCardIdsSummary = new Set()
189
+
190
+ for (const e of evals) {
191
+ if (e.benchmark_card) continue // already inline; lookup never runs
192
+ const composite = deriveCompositeFields(e)
193
+
194
+ const summaryCands = [composite.evaluation_name, composite.composite_benchmark_name, composite.composite_benchmark_key]
195
+ const listCands = [composite.evaluation_name, composite.composite_benchmark_key, composite.composite_benchmark_name]
196
+
197
+ const sPos = resolutionPosition(map, summaryCands)
198
+ const lPos = resolutionPosition(map, listCands)
199
+
200
+ if (sPos === -1) summaryMisses.push({ id: e.eval_summary_id, candidates: summaryCands })
201
+ else summaryPositions.set(sPos, (summaryPositions.get(sPos) ?? 0) + 1)
202
+
203
+ if (lPos === -1) listMisses.push({ id: e.eval_summary_id, candidates: listCands })
204
+ else listPositions.set(lPos, (listPositions.get(lPos) ?? 0) + 1)
205
+
206
+ if (sPos > 0)
207
+ tailHitsSummary.push({ id: e.eval_summary_id, position: sPos, candidates: summaryCands })
208
+ if (lPos > 0) tailHitsList.push({ id: e.eval_summary_id, position: lPos, candidates: listCands })
209
+
210
+ if (sPos !== -1) {
211
+ const filtered = summaryCands.filter(Boolean)
212
+ const hit = getBenchmarkCard(map, filtered[sPos])
213
+ if (hit?.benchmark_details?.name) matchedCardIdsSummary.add(hit.benchmark_details.name)
214
+ }
215
+ }
216
+
217
+ const totalLookupCount = evals.length - inline
218
+ function pct(n) {
219
+ return totalLookupCount === 0 ? "0.0" : ((n / totalLookupCount) * 100).toFixed(1)
220
+ }
221
+
222
+ console.log(`\n=== Summary path retry positions (order: name, name, key) β€” ${totalLookupCount} evals ===`)
223
+ for (const [pos, n] of [...summaryPositions.entries()].sort((a, b) => a[0] - b[0])) {
224
+ console.log(` position ${pos}: ${n} hits (${pct(n)}%)`)
225
+ }
226
+ console.log(` misses: ${summaryMisses.length} (${pct(summaryMisses.length)}%)`)
227
+
228
+ console.log(`\n=== List path retry positions (order: name, key, name) β€” ${totalLookupCount} evals ===`)
229
+ for (const [pos, n] of [...listPositions.entries()].sort((a, b) => a[0] - b[0])) {
230
+ console.log(` position ${pos}: ${n} hits (${pct(n)}%)`)
231
+ }
232
+ console.log(` misses: ${listMisses.length} (${pct(listMisses.length)}%)`)
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // 4. Cards reachable only via 2nd or 3rd candidate (load-bearing retry tail)
236
+ // ---------------------------------------------------------------------------
237
+
238
+ console.log(`\n=== Tail-only hits (summary path, position > 0) ===`)
239
+ console.log(` count: ${tailHitsSummary.length}`)
240
+ for (const t of tailHitsSummary.slice(0, 10)) {
241
+ console.log(` [pos ${t.position}] ${t.id}`)
242
+ console.log(` candidates: ${JSON.stringify(t.candidates)}`)
243
+ }
244
+
245
+ console.log(`\n=== Tail-only hits (list path, position > 0) ===`)
246
+ console.log(` count: ${tailHitsList.length}`)
247
+ for (const t of tailHitsList.slice(0, 10)) {
248
+ console.log(` [pos ${t.position}] ${t.id}`)
249
+ console.log(` candidates: ${JSON.stringify(t.candidates)}`)
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // 5. Disagreement between summary and list paths
254
+ // ---------------------------------------------------------------------------
255
+
256
+ const disagreements = []
257
+ for (const e of evals) {
258
+ if (e.benchmark_card) continue
259
+ const composite = deriveCompositeFields(e)
260
+ const summaryCands = [composite.evaluation_name, composite.composite_benchmark_name, composite.composite_benchmark_key].filter(Boolean)
261
+ const listCands = [composite.evaluation_name, composite.composite_benchmark_key, composite.composite_benchmark_name].filter(Boolean)
262
+ let summaryHit = null
263
+ for (const c of summaryCands) {
264
+ const h = getBenchmarkCard(map, c)
265
+ if (h) { summaryHit = h; break }
266
+ }
267
+ let listHit = null
268
+ for (const c of listCands) {
269
+ const h = getBenchmarkCard(map, c)
270
+ if (h) { listHit = h; break }
271
+ }
272
+ if (summaryHit !== listHit) {
273
+ disagreements.push({
274
+ id: e.eval_summary_id,
275
+ summary: summaryHit?.benchmark_details?.name ?? null,
276
+ list: listHit?.benchmark_details?.name ?? null,
277
+ })
278
+ }
279
+ }
280
+ console.log(`\n=== Summary-vs-list path disagreements (different card depending on entry point) ===`)
281
+ console.log(` count: ${disagreements.length}`)
282
+ for (const d of disagreements.slice(0, 10)) {
283
+ console.log(` ${d.id}: summary→${JSON.stringify(d.summary)} list→${JSON.stringify(d.list)}`)
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // 6. Orphaned benchmark cards (no eval reaches them)
288
+ // ---------------------------------------------------------------------------
289
+
290
+ const reachedCardNames = new Set()
291
+ for (const e of evals) {
292
+ // Use the inline card if present (because that bypasses the lookup loop)
293
+ if (e.benchmark_card?.benchmark_details?.name) {
294
+ reachedCardNames.add(e.benchmark_card.benchmark_details.name)
295
+ continue
296
+ }
297
+ const composite = deriveCompositeFields(e)
298
+ const allCands = [
299
+ composite.evaluation_name,
300
+ composite.composite_benchmark_name,
301
+ composite.composite_benchmark_key,
302
+ ].filter(Boolean)
303
+ for (const c of allCands) {
304
+ const h = getBenchmarkCard(map, c)
305
+ if (h?.benchmark_details?.name) {
306
+ reachedCardNames.add(h.benchmark_details.name)
307
+ break
308
+ }
309
+ }
310
+ }
311
+
312
+ const allCardNames = new Set(
313
+ Object.values(benchmarkMetadata)
314
+ .map((c) => c?.benchmark_details?.name)
315
+ .filter(Boolean)
316
+ )
317
+ const orphaned = [...allCardNames].filter((n) => !reachedCardNames.has(n))
318
+
319
+ console.log(`\n=== Benchmark cards with no inbound eval match (orphans) ===`)
320
+ console.log(` total cards: ${allCardNames.size}`)
321
+ console.log(` reached by at least one eval: ${reachedCardNames.size}`)
322
+ console.log(` orphaned: ${orphaned.length}`)
323
+ for (const n of orphaned.slice(0, 20)) {
324
+ console.log(` - ${JSON.stringify(n)}`)
325
+ }
scripts/verify-benchmark-display-names.mjs ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./server-only-shim.mjs"
2
+ import fs from "fs"
3
+
4
+ // Audit `getBenchmarkDisplayName` from lib/model-data.ts:90-148 against the
5
+ // full live cache in .cache/hf-data/. Prints distribution stats: how many
6
+ // distinct keys hit BENCHMARK_NAMES, how many fall through to humanizeToken,
7
+ // examples of each, and which input field (benchmark / benchmark_parent_key /
8
+ // benchmark_family_key) drives each call.
9
+ //
10
+ // If .cache/hf-data/ is missing or empty, run `pnpm cache-hf-data` first.
11
+ //
12
+ // Spec: notes/transformations/08-benchmark-display-names.md
13
+ // Tests: tests/transformations/benchmark-display-names.test.ts
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Replicate lib/model-data.ts:90-148 verbatim
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function humanizeToken(token) {
20
+ return token
21
+ .split(/[_-]+/g)
22
+ .filter(Boolean)
23
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
24
+ .join(" ")
25
+ }
26
+
27
+ const BENCHMARK_NAMES = {
28
+ hfopenllm_v2: "HF Open LLM v2",
29
+ helm_lite: "HELM Lite",
30
+ helm_capabilities: "HELM Capabilities",
31
+ helm_classic: "HELM Classic",
32
+ helm_instruct: "HELM Instruct",
33
+ helm_mmlu: "HELM MMLU",
34
+ reward_bench: "RewardBench",
35
+ reward_bench_2: "RewardBench 2",
36
+ bfcl: "BFCL",
37
+ global_mmlu_lite: "Global MMLU Lite",
38
+ swe_bench: "SWE-bench",
39
+ arc_agi: "ARC-AGI",
40
+ tau_bench_2: "TAU-Bench 2",
41
+ ace: "ACE",
42
+ apex_agents: "APEX Agents",
43
+ apex_v1: "APEX v1",
44
+ appworld: "AppWorld",
45
+ browsecompplus: "BrowseComp+",
46
+ livecodebenchpro: "LiveCodeBench Pro",
47
+ sciarena: "SciArena",
48
+ terminal_bench_2_0: "Terminal Bench 2.0",
49
+ la_leaderboard: "LA Leaderboard",
50
+ theory_of_mind: "Theory of Mind",
51
+ fibble_arena: "Fibble Arena",
52
+ fibble1_arena: "Fibble Arena v1",
53
+ fibble2_arena: "Fibble Arena v2",
54
+ fibble3_arena: "Fibble Arena v3",
55
+ fibble4_arena: "Fibble Arena v4",
56
+ fibble5_arena: "Fibble Arena v5",
57
+ wordle_arena: "Wordle Arena",
58
+ }
59
+
60
+ function normalizeBenchmarkKeyForLookup(key) {
61
+ return key.toLowerCase().replace(/[-.\s]+/g, "_").replace(/^_+|_+$/g, "")
62
+ }
63
+
64
+ function getBenchmarkDisplayName(benchmark) {
65
+ return BENCHMARK_NAMES[normalizeBenchmarkKeyForLookup(benchmark)] ?? humanizeToken(benchmark)
66
+ }
67
+
68
+ function classify(value) {
69
+ if (value == null || value === "") return "empty"
70
+ return BENCHMARK_NAMES[normalizeBenchmarkKeyForLookup(value)] ? "mapHit" : "fallback"
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Cache check
75
+ // ---------------------------------------------------------------------------
76
+
77
+ const cacheDir = ".cache/hf-data"
78
+ if (!fs.existsSync(cacheDir)) {
79
+ console.error(`ERROR: ${cacheDir} not found. Run \`pnpm cache-hf-data\` first.`)
80
+ process.exit(1)
81
+ }
82
+
83
+ const evalListPath = `${cacheDir}/eval-list.json`
84
+ const modelCardsLitePath = `${cacheDir}/model-cards-lite.json`
85
+
86
+ if (!fs.existsSync(evalListPath)) {
87
+ console.error(`ERROR: ${evalListPath} not found. Run \`pnpm cache-hf-data\` first.`)
88
+ process.exit(1)
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Pass 1 β€” eval-list.json (587 entries; the suite/benchmark key universe)
93
+ // ---------------------------------------------------------------------------
94
+
95
+ const evalListRaw = JSON.parse(fs.readFileSync(evalListPath, "utf8"))
96
+ const evals = Array.isArray(evalListRaw) ? evalListRaw : evalListRaw.evals
97
+ console.log(`=== Audit: getBenchmarkDisplayName across ${evals.length} eval-list.json entries ===\n`)
98
+
99
+ const fields = [
100
+ "benchmark",
101
+ "benchmark_parent_key",
102
+ "benchmark_family_key",
103
+ "benchmark_parent_name",
104
+ ]
105
+
106
+ for (const field of fields) {
107
+ const distinct = new Set()
108
+ const buckets = { mapHit: 0, fallback: 0, empty: 0 }
109
+ const distinctBuckets = { mapHit: new Set(), fallback: new Set(), empty: new Set() }
110
+ const examples = { mapHit: [], fallback: [] }
111
+
112
+ for (const e of evals) {
113
+ const raw = e[field]
114
+ if (raw != null && raw !== "") distinct.add(raw)
115
+ const bucket = classify(raw)
116
+ buckets[bucket]++
117
+ if (raw != null && raw !== "") distinctBuckets[bucket].add(raw)
118
+ }
119
+
120
+ for (const value of distinct) {
121
+ const bucket = classify(value)
122
+ if (bucket === "mapHit" && examples.mapHit.length < 5) {
123
+ examples.mapHit.push({ raw: value, displayed: getBenchmarkDisplayName(value) })
124
+ } else if (bucket === "fallback" && examples.fallback.length < 10) {
125
+ examples.fallback.push({ raw: value, displayed: getBenchmarkDisplayName(value) })
126
+ }
127
+ }
128
+
129
+ console.log(`--- field: ${field} ---`)
130
+ console.log(` ${distinct.size} distinct values across ${evals.length} calls`)
131
+ console.log(` call buckets: ${JSON.stringify(buckets)}`)
132
+ console.log(
133
+ ` distinct buckets: mapHit=${distinctBuckets.mapHit.size} fallback=${distinctBuckets.fallback.size} empty=${distinctBuckets.empty.size}`,
134
+ )
135
+ if (distinct.size > 0) {
136
+ const mapHitPct = ((distinctBuckets.mapHit.size / distinct.size) * 100).toFixed(1)
137
+ const fallbackPct = ((distinctBuckets.fallback.size / distinct.size) * 100).toFixed(1)
138
+ console.log(` distinct mapHit: ${mapHitPct}%; distinct fallback: ${fallbackPct}%`)
139
+ }
140
+ if (examples.mapHit.length) {
141
+ console.log(" mapHit examples:")
142
+ for (const e of examples.mapHit) console.log(` '${e.raw}' -> '${e.displayed}'`)
143
+ }
144
+ if (examples.fallback.length) {
145
+ console.log(" fallback examples (the visibly-wrong cases live here):")
146
+ for (const e of examples.fallback) console.log(` '${e.raw}' -> '${e.displayed}'`)
147
+ }
148
+ console.log()
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Pass 2 β€” model-cards-lite.json β€” `benchmark_names` + `top_benchmark_scores`
153
+ // (these are the per-card rollups that pass through `getBenchmarkDisplayName`
154
+ // at lib/model-data.ts:410 and surrounding rollup paths)
155
+ // ---------------------------------------------------------------------------
156
+
157
+ if (fs.existsSync(modelCardsLitePath)) {
158
+ console.log(`=== Audit: getBenchmarkDisplayName via model-cards-lite.json benchmark fields ===\n`)
159
+ const cards = JSON.parse(fs.readFileSync(modelCardsLitePath, "utf8"))
160
+ const cardsArr = Array.isArray(cards) ? cards : Object.values(cards)
161
+ console.log(` total cards: ${cardsArr.length}`)
162
+
163
+ // benchmark_names is an array; top_benchmark_scores has .benchmark and .benchmarkKey
164
+ const distinctBenchmarkNames = new Set()
165
+ const distinctTopScoreKeys = new Set()
166
+ const distinctTopScoreBenchmarks = new Set()
167
+
168
+ for (const c of cardsArr) {
169
+ if (Array.isArray(c.benchmark_names)) {
170
+ for (const n of c.benchmark_names) distinctBenchmarkNames.add(n)
171
+ }
172
+ if (Array.isArray(c.top_benchmark_scores)) {
173
+ for (const s of c.top_benchmark_scores) {
174
+ if (s.benchmarkKey) distinctTopScoreKeys.add(s.benchmarkKey)
175
+ if (s.benchmark) distinctTopScoreBenchmarks.add(s.benchmark)
176
+ }
177
+ }
178
+ }
179
+
180
+ for (const [label, set] of [
181
+ ["card.benchmark_names[]", distinctBenchmarkNames],
182
+ ["card.top_benchmark_scores[].benchmarkKey", distinctTopScoreKeys],
183
+ ["card.top_benchmark_scores[].benchmark (display-name field)", distinctTopScoreBenchmarks],
184
+ ]) {
185
+ const buckets = { mapHit: new Set(), fallback: new Set() }
186
+ const fallbackExamples = []
187
+ for (const v of set) {
188
+ const bucket = classify(v)
189
+ if (bucket === "mapHit") buckets.mapHit.add(v)
190
+ else if (bucket === "fallback") {
191
+ buckets.fallback.add(v)
192
+ if (fallbackExamples.length < 10) {
193
+ fallbackExamples.push({ raw: v, displayed: getBenchmarkDisplayName(v) })
194
+ }
195
+ }
196
+ }
197
+ const totalDistinct = set.size
198
+ const pctMap = totalDistinct > 0 ? ((buckets.mapHit.size / totalDistinct) * 100).toFixed(1) : "0.0"
199
+ const pctFallback = totalDistinct > 0 ? ((buckets.fallback.size / totalDistinct) * 100).toFixed(1) : "0.0"
200
+ console.log(` --- ${label} ---`)
201
+ console.log(` ${totalDistinct} distinct; mapHit=${buckets.mapHit.size} (${pctMap}%); fallback=${buckets.fallback.size} (${pctFallback}%)`)
202
+ if (fallbackExamples.length) {
203
+ console.log(" fallback examples:")
204
+ for (const e of fallbackExamples) console.log(` '${e.raw}' -> '${e.displayed}'`)
205
+ }
206
+ }
207
+ console.log()
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Pass 3 β€” comparison with pipeline-emitted display_name / canonical_display_name
212
+ // (where pipeline already ships a display name, does the TS function agree?)
213
+ // ---------------------------------------------------------------------------
214
+
215
+ console.log(`=== Comparison: TS getBenchmarkDisplayName vs pipeline-emitted display fields ===\n`)
216
+ const fieldsWithBoth = [
217
+ { keyField: "benchmark_parent_key", nameField: "benchmark_parent_name", label: "benchmark_parent_key vs benchmark_parent_name" },
218
+ { keyField: "benchmark_family_key", nameField: "benchmark_family_name", label: "benchmark_family_key vs benchmark_family_name" },
219
+ { keyField: "benchmark", nameField: "display_name", label: "benchmark vs display_name" },
220
+ { keyField: "benchmark", nameField: "canonical_display_name", label: "benchmark vs canonical_display_name" },
221
+ ]
222
+
223
+ for (const { keyField, nameField, label } of fieldsWithBoth) {
224
+ let agree = 0
225
+ let disagree = 0
226
+ let pipelineMissing = 0
227
+ const examples = []
228
+ for (const e of evals) {
229
+ const key = e[keyField]
230
+ const pipelineName = e[nameField]
231
+ if (!key) continue
232
+ const tsComputed = getBenchmarkDisplayName(key)
233
+ if (pipelineName == null || pipelineName === "") {
234
+ pipelineMissing++
235
+ continue
236
+ }
237
+ if (tsComputed === pipelineName) {
238
+ agree++
239
+ } else {
240
+ disagree++
241
+ if (examples.length < 8) {
242
+ examples.push({ key, pipeline: pipelineName, ts: tsComputed })
243
+ }
244
+ }
245
+ }
246
+ console.log(` --- ${label} ---`)
247
+ console.log(` agree=${agree}; disagree=${disagree}; pipelineMissing=${pipelineMissing}`)
248
+ if (examples.length) {
249
+ console.log(" disagreement examples (key | pipeline | TS-computed):")
250
+ for (const e of examples) console.log(` '${e.key}' | '${e.pipeline}' | '${e.ts}'`)
251
+ }
252
+ console.log()
253
+ }
254
+
255
+ console.log("Done. See notes/transformations/08-benchmark-display-names.md for the full spec.")
scripts/verify-dataset-url.mjs ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./server-only-shim.mjs"
2
+ import fs from "fs"
3
+
4
+ // Replicate the dataset_url fallback chain from components/eval-card.tsx:83-86 verbatim.
5
+ // Original uses `??` (nullish), NOT truthiness β€” empty strings stay; only
6
+ // null/undefined fall through. Pipeline must produce identical outputs.
7
+ function resolveDatasetUrl(sourceData) {
8
+ const fromDataset = sourceData?.dataset_url
9
+ const fromUrl = Array.isArray(sourceData?.url) ? sourceData?.url?.[0] : sourceData?.url
10
+ const fromHfRepo = sourceData?.hf_repo ? `https://huggingface.co/datasets/${sourceData.hf_repo}` : undefined
11
+ return fromDataset ?? fromUrl ?? fromHfRepo
12
+ }
13
+
14
+ // === Audit: distribution of which fallback branch fires ===
15
+ const dir = ".cache/hf-data/evals"
16
+ const files = fs.readdirSync(dir)
17
+
18
+ const branches = { dataset_url: 0, url_array: 0, url_string: 0, hf_repo: 0, undefined: 0 }
19
+ const examples = { dataset_url: [], url_array: [], url_string: [], hf_repo: [], undefined: [] }
20
+ let total = 0
21
+
22
+ for (const f of files) {
23
+ const data = JSON.parse(fs.readFileSync(`${dir}/${f}`, "utf8"))
24
+ const sd = data.source_data
25
+ total++
26
+ const url = resolveDatasetUrl(sd)
27
+ let branch
28
+ if (!sd) branch = "undefined"
29
+ else if (sd.dataset_url) branch = "dataset_url"
30
+ else if (Array.isArray(sd.url)) branch = "url_array"
31
+ else if (typeof sd.url === "string") branch = "url_string"
32
+ else if (sd.hf_repo) branch = "hf_repo"
33
+ else branch = "undefined"
34
+ branches[branch]++
35
+ if (examples[branch].length < 3) examples[branch].push({ id: data.eval_summary_id, sourceData: sd, resolved: url })
36
+ }
37
+
38
+ console.log(`=== Audit: dataset_url fallback branch distribution (${total} eval-detail files) ===`)
39
+ for (const [b, n] of Object.entries(branches)) {
40
+ console.log(` ${b}: ${n}`)
41
+ }
42
+ console.log()
43
+ console.log("=== Examples per branch ===")
44
+ for (const [b, exs] of Object.entries(examples)) {
45
+ if (exs.length === 0) continue
46
+ console.log(`\n--- ${b} ---`)
47
+ for (const ex of exs) {
48
+ console.log(` ${ex.id}: source_data=${JSON.stringify(ex.sourceData)}`)
49
+ console.log(` β†’ resolved: ${ex.resolved}`)
50
+ }
51
+ }
scripts/verify-developer-name.mjs ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./server-only-shim.mjs"
2
+ import fs from "fs"
3
+
4
+ // Replicate normalizeDeveloperName from lib/model-data.ts:217-244 verbatim.
5
+ const KNOWN_DEVELOPER_NAMES = {
6
+ openai: "OpenAI",
7
+ google: "Google",
8
+ anthropic: "Anthropic",
9
+ meta: "Meta",
10
+ microsoft: "Microsoft",
11
+ mistralai: "Mistral AI",
12
+ deepseek: "DeepSeek",
13
+ "deepseek-ai": "DeepSeek",
14
+ cohere: "Cohere",
15
+ nvidia: "NVIDIA",
16
+ alibaba: "Alibaba",
17
+ amazon: "Amazon",
18
+ apple: "Apple",
19
+ ibm: "IBM",
20
+ xai: "xAI",
21
+ "x-ai": "xAI",
22
+ }
23
+
24
+ function normalizeDeveloperName(name) {
25
+ const key = name.trim().toLowerCase()
26
+ if (KNOWN_DEVELOPER_NAMES[key]) return KNOWN_DEVELOPER_NAMES[key]
27
+ if (name === name.toLowerCase() && /^[a-z]/.test(name)) {
28
+ return name.charAt(0).toUpperCase() + name.slice(1)
29
+ }
30
+ return name
31
+ }
32
+
33
+ const devs = JSON.parse(fs.readFileSync(".cache/hf-data/developers.json", "utf8"))
34
+
35
+ console.log(`=== Audit: normalizeDeveloperName across ${devs.length} developers ===`)
36
+ const buckets = { mapHit: 0, titleCase: 0, passthrough: 0 }
37
+ const examples = { mapHit: [], titleCase: [], passthrough: [] }
38
+ for (const d of devs) {
39
+ const raw = d.developer
40
+ const normalized = normalizeDeveloperName(raw)
41
+ const key = raw.trim().toLowerCase()
42
+ let bucket
43
+ if (KNOWN_DEVELOPER_NAMES[key]) bucket = "mapHit"
44
+ else if (raw === raw.toLowerCase() && /^[a-z]/.test(raw)) bucket = "titleCase"
45
+ else bucket = "passthrough"
46
+ buckets[bucket]++
47
+ if (examples[bucket].length < 5) examples[bucket].push({ raw, normalized })
48
+ }
49
+ console.log(buckets)
50
+ console.log()
51
+ for (const [bucket, exs] of Object.entries(examples)) {
52
+ console.log(`--- ${bucket} ---`)
53
+ for (const e of exs) console.log(` '${e.raw}' β†’ '${e.normalized}'`)
54
+ }
55
+
56
+ // Also check model-cards.json β€” `developer` field there
57
+ console.log("\n=== Audit: across 5830 model-cards.json entries ===")
58
+ const cards = JSON.parse(fs.readFileSync(".cache/hf-data/model-cards.json", "utf8"))
59
+ const cardBuckets = { mapHit: 0, titleCase: 0, passthrough: 0 }
60
+ for (const c of cards) {
61
+ const raw = c.developer
62
+ const key = raw.trim().toLowerCase()
63
+ let bucket
64
+ if (KNOWN_DEVELOPER_NAMES[key]) bucket = "mapHit"
65
+ else if (raw === raw.toLowerCase() && /^[a-z]/.test(raw)) bucket = "titleCase"
66
+ else bucket = "passthrough"
67
+ cardBuckets[bucket]++
68
+ }
69
+ console.log(cardBuckets)
70
+
71
+ // Distinct developer names
72
+ const distinctDevs = new Set(devs.map(d => d.developer))
73
+ console.log("\n=== Distinct developer name strings:", distinctDevs.size, "===")
scripts/verify-identity.mjs ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./server-only-shim.mjs"
2
+ import fs from "fs"
3
+
4
+ const { getCanonicalModelIdentity, getModelFamilyRouteId } = await import("../lib/model-family.ts")
5
+
6
+ const cards = JSON.parse(fs.readFileSync(".cache/hf-data/model-cards.json", "utf8"))
7
+
8
+ let total = 0
9
+ let familyIdMismatch = 0
10
+ let familyNameMismatch = 0
11
+ const familyIdExamples = []
12
+ const familyNameExamples = []
13
+
14
+ for (const c of cards) {
15
+ total++
16
+ const computed = getCanonicalModelIdentity({
17
+ id: c.model_family_id,
18
+ name: c.model_family_name,
19
+ })
20
+ if (computed.familyId !== c.model_family_id) {
21
+ familyIdMismatch++
22
+ if (familyIdExamples.length < 5) familyIdExamples.push({pipeline: c.model_family_id, computed: computed.familyId})
23
+ }
24
+ if (computed.familyName !== c.model_family_name) {
25
+ familyNameMismatch++
26
+ if (familyNameExamples.length < 10) familyNameExamples.push({pipeline: c.model_family_name, computed: computed.familyName})
27
+ }
28
+ const computedRoute = getModelFamilyRouteId(computed.familyId)
29
+ if (computedRoute !== c.model_route_id) {
30
+ console.error("ROUTE MISMATCH", c.model_family_id, "->", computedRoute, "vs", c.model_route_id)
31
+ }
32
+ }
33
+
34
+ console.log({total, familyIdMismatch, familyNameMismatch})
35
+ if (familyIdExamples.length) console.log("familyId examples:", familyIdExamples)
36
+ if (familyNameExamples.length) console.log("familyName examples:", familyNameExamples.slice(0, 10))
scripts/verify-instance-level-data.mjs ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./server-only-shim.mjs"
2
+ import fs from "fs"
3
+ import path from "path"
4
+
5
+ // Audit script for `notes/transformations/12-instance-level-data.md`.
6
+ //
7
+ // Walks .cache/hf-data/models/*.json, finds every result row that has
8
+ // `instance_level_data.instance_examples`, replicates the parser's branch
9
+ // logic, and reports:
10
+ // - Prevalence (% of rows / files with sample data)
11
+ // - ild-level shape uniformity (which top-level keys appear)
12
+ // - interaction_type distribution
13
+ // - Per-output-field branch firing rates (which fallback path wins for each)
14
+ // - Distinct example top-level key signatures (top 5)
15
+ //
16
+ // Output is the raw distribution; the spec's "Pipeline status" section quotes
17
+ // these numbers. Run after `pnpm cache-hf-data` to refresh.
18
+
19
+ const CACHE_DIR = ".cache/hf-data"
20
+ const MODELS_DIR = path.join(CACHE_DIR, "models")
21
+
22
+ if (!fs.existsSync(MODELS_DIR)) {
23
+ console.error("=== ERROR: HF data cache missing ===")
24
+ console.error(` Expected: ${MODELS_DIR}`)
25
+ console.error(" Prime it first: pnpm cache-hf-data")
26
+ process.exit(1)
27
+ }
28
+
29
+ const files = fs.readdirSync(MODELS_DIR).filter((f) => f.endsWith(".json"))
30
+
31
+ // Tally trackers
32
+ const branchHits = {
33
+ input: { string: 0, "input.raw": 0, prompt: 0, question: 0, "doc.question": 0, "doc.JSON": 0, EMPTY: 0 },
34
+ ground_truth: { "input.reference": 0, ground_truth: 0, target: 0, gold: 0, "doc.answer": 0, NONE: 0 },
35
+ response: { output: 0, response: 0, model_output: 0, answer_attribution: 0, messages: 0, filtered_resps: 0, resps: 0, EMPTY: 0 },
36
+ is_correct: { "evaluation.is_correct": 0, is_correct: 0, "metrics.exact_match": 0, NONE: 0 },
37
+ sample_id: { sample_id: 0, doc_id: 0, id: 0, index_fallback: 0 },
38
+ choices: { choices: 0, "doc.choices": 0, NONE: 0 },
39
+ }
40
+
41
+ let totalFiles = 0
42
+ let filesWithAnyIld = 0
43
+ let totalEvals = 0
44
+ let evalsWithIld = 0
45
+ let totalResults = 0
46
+ let resultsWithIld = 0
47
+ let totalExamples = 0
48
+ let totalInstanceCountSum = 0 // sum of full-set sizes (i.e. URL JSONL totals)
49
+
50
+ const ildKeysSeen = new Map()
51
+ const interactionTypes = new Map()
52
+ const exampleKeysHistogram = new Map()
53
+
54
+ function classifyExample(raw) {
55
+ if (!raw || typeof raw !== "object") return
56
+ totalExamples++
57
+
58
+ // Track first-level keys present
59
+ const sig = Object.keys(raw).sort().join(",")
60
+ exampleKeysHistogram.set(sig, (exampleKeysHistogram.get(sig) ?? 0) + 1)
61
+
62
+ // input
63
+ if (typeof raw.input === "string") branchHits.input.string++
64
+ else if (raw.input?.raw != null) branchHits.input["input.raw"]++
65
+ else if (raw.prompt) branchHits.input.prompt++
66
+ else if (raw.question) branchHits.input.question++
67
+ else if (raw.doc?.question) branchHits.input["doc.question"]++
68
+ else if (raw.doc) branchHits.input["doc.JSON"]++
69
+ else branchHits.input.EMPTY++
70
+
71
+ // ground_truth
72
+ if (raw.input?.reference != null) branchHits.ground_truth["input.reference"]++
73
+ else if (raw.ground_truth != null) branchHits.ground_truth.ground_truth++
74
+ else if (raw.target != null) branchHits.ground_truth.target++
75
+ else if (raw.gold != null) branchHits.ground_truth.gold++
76
+ else if (raw.doc?.answer != null) branchHits.ground_truth["doc.answer"]++
77
+ else branchHits.ground_truth.NONE++
78
+
79
+ // response
80
+ if (raw.output != null) branchHits.response.output++
81
+ else if (raw.response) branchHits.response.response++
82
+ else if (raw.model_output) branchHits.response.model_output++
83
+ else if (Array.isArray(raw.answer_attribution) && raw.answer_attribution.length > 0) branchHits.response.answer_attribution++
84
+ else if (Array.isArray(raw.messages) && raw.messages.length > 0) branchHits.response.messages++
85
+ else if (raw.filtered_resps?.[0]?.[0]) branchHits.response.filtered_resps++
86
+ else if (raw.resps?.[0]?.[0]) branchHits.response.resps++
87
+ else branchHits.response.EMPTY++
88
+
89
+ // is_correct
90
+ if (raw.evaluation?.is_correct !== undefined) branchHits.is_correct["evaluation.is_correct"]++
91
+ else if (raw.is_correct !== undefined) branchHits.is_correct.is_correct++
92
+ else if (raw.metrics?.exact_match === 1 || raw.metrics?.exact_match === 0) branchHits.is_correct["metrics.exact_match"]++
93
+ else branchHits.is_correct.NONE++
94
+
95
+ // sample_id
96
+ if (raw.sample_id != null) branchHits.sample_id.sample_id++
97
+ else if (raw.doc_id != null) branchHits.sample_id.doc_id++
98
+ else if (raw.id != null) branchHits.sample_id.id++
99
+ else branchHits.sample_id.index_fallback++
100
+
101
+ // choices
102
+ if (raw.choices != null) branchHits.choices.choices++
103
+ else if (raw.doc?.choices != null) branchHits.choices["doc.choices"]++
104
+ else branchHits.choices.NONE++
105
+ }
106
+
107
+ function walk(node) {
108
+ let nodeHasIld = false
109
+ for (const m of node.metrics ?? []) {
110
+ totalEvals++
111
+ let evalHasIld = false
112
+ for (const r of m.model_results ?? []) {
113
+ totalResults++
114
+ const ild = r.instance_level_data
115
+ if (ild != null && typeof ild === "object" && Array.isArray(ild.instance_examples) && ild.instance_examples.length > 0) {
116
+ resultsWithIld++
117
+ evalHasIld = true
118
+ nodeHasIld = true
119
+
120
+ const ildSig = Object.keys(ild).sort().join(",")
121
+ ildKeysSeen.set(ildSig, (ildKeysSeen.get(ildSig) ?? 0) + 1)
122
+ if (typeof ild.interaction_type === "string") {
123
+ interactionTypes.set(ild.interaction_type, (interactionTypes.get(ild.interaction_type) ?? 0) + 1)
124
+ }
125
+ if (typeof ild.instance_count === "number") {
126
+ totalInstanceCountSum += ild.instance_count
127
+ }
128
+ for (const ex of ild.instance_examples) {
129
+ classifyExample(ex)
130
+ }
131
+ }
132
+ }
133
+ if (evalHasIld) evalsWithIld++
134
+ }
135
+ for (const s of node.subtasks ?? []) walk(s)
136
+ return nodeHasIld
137
+ }
138
+
139
+ for (const f of files) {
140
+ totalFiles++
141
+ let fileHasIld = false
142
+ let data
143
+ try {
144
+ data = JSON.parse(fs.readFileSync(path.join(MODELS_DIR, f), "utf-8"))
145
+ } catch {
146
+ continue
147
+ }
148
+ for (const cat of Object.values(data.hierarchy_by_category ?? {})) {
149
+ for (const node of cat) {
150
+ if (walk(node)) fileHasIld = true
151
+ }
152
+ }
153
+ if (fileHasIld) filesWithAnyIld++
154
+ }
155
+
156
+ const pct = (n, total = totalExamples) => (total ? ((100 * n) / total).toFixed(2) + "%" : "-")
157
+
158
+ console.log("=== Audit: instance_level_data prevalence ===")
159
+ console.log(` Total model files: ${totalFiles}`)
160
+ console.log(` Files with any ild: ${filesWithAnyIld} (${pct(filesWithAnyIld, totalFiles)})`)
161
+ console.log(` Total (metric Γ— result) rows: ${totalResults}`)
162
+ console.log(` Result rows with ild: ${resultsWithIld} (${pct(resultsWithIld, totalResults)})`)
163
+ console.log(` Total inline preview examples (≀5 per row): ${totalExamples}`)
164
+ console.log(` Total full samples (sum of instance_count, accessible via source_url): ${totalInstanceCountSum}`)
165
+
166
+ console.log("\n=== ild-level top-level key signatures ===")
167
+ for (const [k, v] of [...ildKeysSeen.entries()].sort((a, b) => b[1] - a[1])) {
168
+ console.log(` ${v}x: ${k}`)
169
+ }
170
+
171
+ console.log("\n=== interaction_type distribution ===")
172
+ for (const [k, v] of [...interactionTypes.entries()].sort((a, b) => b[1] - a[1])) {
173
+ console.log(` ${v}x: ${k}`)
174
+ }
175
+
176
+ console.log("\n=== Per-field branch firing rates ===\n")
177
+ for (const [field, branches] of Object.entries(branchHits)) {
178
+ console.log(`--- ${field} ---`)
179
+ for (const [b, n] of Object.entries(branches)) {
180
+ console.log(` ${b}: ${n} (${pct(n)})`)
181
+ }
182
+ console.log()
183
+ }
184
+
185
+ console.log("=== Distinct example top-level key signatures (top 5) ===")
186
+ const sorted = [...exampleKeysHistogram.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
187
+ for (const [sig, count] of sorted) {
188
+ console.log(` ${count}x: ${sig.substring(0, 200)}${sig.length > 200 ? "…" : ""}`)
189
+ }
190
+
191
+ console.log("\n=== Summary for spec ===")
192
+ console.log(`Inline preview prevalence: ${resultsWithIld}/${totalResults} rows (${pct(resultsWithIld, totalResults)})`)
193
+ console.log(`Full-corpus samples behind source_url: ~${totalInstanceCountSum} (vs ${totalExamples} loaded inline)`)
194
+ console.log(`Defensive scaffolding firing rate: see per-field breakdown above. Most branches are 0.00%.`)
scripts/verify-license.mjs ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./server-only-shim.mjs"
2
+ import fs from "fs"
3
+
4
+ // Replicate shortenLicense from components/eval-card.tsx:38-48 verbatim.
5
+ // Pipeline must produce identical outputs for every license string.
6
+ function shortenLicense(license) {
7
+ if (!license || license === "Not specified") return ""
8
+ if (license.toLowerCase().includes("creative commons attribution 4")) return "CC BY 4.0"
9
+ if (license.toLowerCase().includes("creative commons zero")) return "CC0"
10
+ if (license.toLowerCase().includes("apache license 2") || license.toLowerCase().includes("apache 2")) return "Apache 2.0"
11
+ if (license.toLowerCase().includes("mit license")) return "MIT"
12
+ if (license.toLowerCase().includes("cc-by-sa")) return "CC BY-SA"
13
+ if (license.length > 24) return license.slice(0, 22) + "…"
14
+ return license
15
+ }
16
+
17
+ // === Audit 1: distinct license strings + their shortened form ===
18
+ const cardsByName = JSON.parse(fs.readFileSync(".cache/hf-data/benchmark-metadata.json", "utf8"))
19
+ const cards = Array.isArray(cardsByName) ? cardsByName : Object.values(cardsByName)
20
+
21
+ const licenseStrings = new Map()
22
+ const shortenedDistribution = new Map()
23
+ let cardsWithLicense = 0
24
+ let cardsTotal = 0
25
+
26
+ for (const c of cards) {
27
+ cardsTotal++
28
+ const license = c?.ethical_and_legal_considerations?.data_licensing
29
+ if (!license) continue
30
+ cardsWithLicense++
31
+ const lic = String(license)
32
+ licenseStrings.set(lic, (licenseStrings.get(lic) ?? 0) + 1)
33
+ const short = shortenLicense(lic)
34
+ shortenedDistribution.set(short, (shortenedDistribution.get(short) ?? 0) + 1)
35
+ }
36
+
37
+ console.log(`=== Audit 1: distinct license strings (${cardsWithLicense}/${cardsTotal} benchmark cards have a license) ===`)
38
+ console.log(`Distinct raw license strings: ${licenseStrings.size}`)
39
+ console.log("Top 30 raw β†’ shortened:")
40
+ const rows = [...licenseStrings.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30)
41
+ for (const [raw, n] of rows) {
42
+ const short = shortenLicense(raw)
43
+ const truncMarker = short.endsWith("…") ? " (truncated)" : ""
44
+ const matchedRule = (() => {
45
+ const l = raw.toLowerCase()
46
+ if (l.includes("creative commons attribution 4")) return "CC BY 4.0 rule"
47
+ if (l.includes("creative commons zero")) return "CC0 rule"
48
+ if (l.includes("apache license 2") || l.includes("apache 2")) return "Apache 2.0 rule"
49
+ if (l.includes("mit license")) return "MIT rule"
50
+ if (l.includes("cc-by-sa")) return "CC BY-SA rule"
51
+ if (raw.length > 24) return "truncate-22 rule"
52
+ return "passthrough"
53
+ })()
54
+ console.log(` ${n.toString().padStart(3)}Γ— '${raw}' β†’ '${short}' [${matchedRule}]${truncMarker}`)
55
+ }
56
+
57
+ console.log(`\n=== Audit 2: shortened distribution ===`)
58
+ for (const [short, n] of [...shortenedDistribution.entries()].sort((a, b) => b[1] - a[1])) {
59
+ console.log(` ${n.toString().padStart(3)}Γ— '${short}'`)
60
+ }
61
+
62
+ console.log(`\n=== Audit 3: rule-coverage stats ===`)
63
+ let counts = { ccby4: 0, cc0: 0, apache2: 0, mit: 0, ccbysa: 0, truncated: 0, passthrough: 0, empty: 0 }
64
+ for (const [raw, n] of licenseStrings.entries()) {
65
+ const l = raw.toLowerCase()
66
+ if (!raw || raw === "Not specified") counts.empty += n
67
+ else if (l.includes("creative commons attribution 4")) counts.ccby4 += n
68
+ else if (l.includes("creative commons zero")) counts.cc0 += n
69
+ else if (l.includes("apache license 2") || l.includes("apache 2")) counts.apache2 += n
70
+ else if (l.includes("mit license")) counts.mit += n
71
+ else if (l.includes("cc-by-sa")) counts.ccbysa += n
72
+ else if (raw.length > 24) counts.truncated += n
73
+ else counts.passthrough += n
74
+ }
75
+ console.log(counts)
scripts/verify-params-parsing.mjs ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./server-only-shim.mjs"
2
+ import fs from "fs"
3
+ import path from "path"
4
+
5
+ // === Replicate all five TS implementations verbatim ===
6
+
7
+ // Variant A: lib/model-data.ts:312-354 (parseParamsBillions)
8
+ function parseParamsBillions(value) {
9
+ if (typeof value === "number") {
10
+ return Number.isFinite(value) && value > 0 ? value : null
11
+ }
12
+ if (typeof value !== "string") return null
13
+
14
+ const normalized = value.trim().toLowerCase()
15
+ if (!normalized) return null
16
+
17
+ const compact = normalized.replace(/,/g, "")
18
+ const tokenMatch = compact.match(
19
+ /(\d+(?:\.\d+)?)\s*(trillion|tn|t|billion|bn|b|million|mn|m|thousand|k)\b/
20
+ )
21
+ if (tokenMatch) {
22
+ const amount = Number.parseFloat(tokenMatch[1])
23
+ if (!Number.isFinite(amount) || amount <= 0) return null
24
+ const unit = tokenMatch[2]
25
+ if (unit === "trillion" || unit === "tn" || unit === "t") return amount * 1000
26
+ if (unit === "billion" || unit === "bn" || unit === "b") return amount
27
+ if (unit === "million" || unit === "mn" || unit === "m") return amount / 1000
28
+ if (unit === "thousand" || unit === "k") return amount / 1_000_000
29
+ }
30
+ const numeric = Number.parseFloat(compact)
31
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : null
32
+ }
33
+
34
+ // Variant B: components/eval-detail.tsx:81-119 (parseParamsBillionsFromText)
35
+ function parseParamsBillionsFromText(value) {
36
+ if (!value) return null
37
+ const normalized = value.trim().toLowerCase()
38
+ if (!normalized) return null
39
+ const compact = normalized.replace(/,/g, "")
40
+ const tokenMatch = compact.match(
41
+ /(\d+(?:\.\d+)?)\s*(trillion|tn|t|billion|bn|b|million|mn|m|thousand|k)\b/
42
+ )
43
+ if (tokenMatch) {
44
+ const amount = Number.parseFloat(tokenMatch[1])
45
+ if (!Number.isFinite(amount)) return null
46
+ const unit = tokenMatch[2]
47
+ if (unit === "trillion" || unit === "tn" || unit === "t") return amount * 1000
48
+ if (unit === "billion" || unit === "bn" || unit === "b") return amount
49
+ if (unit === "million" || unit === "mn" || unit === "m") return amount / 1000
50
+ if (unit === "thousand" || unit === "k") return amount / 1_000_000
51
+ }
52
+ const numeric = Number.parseFloat(compact)
53
+ return Number.isFinite(numeric) ? numeric : null
54
+ }
55
+
56
+ // Variant C: components/eval-detail.tsx:121-155 (parseParamsBillionsFromModelName)
57
+ function parseParamsBillionsFromModelNameC(modelName) {
58
+ if (!modelName) return null
59
+ const sizeTokens = Array.from(modelName.matchAll(/\b(\d+(?:\.\d+)?)\s*([tmbk])\b/gi))
60
+ if (sizeTokens.length === 0) return null
61
+ const lastToken = sizeTokens[sizeTokens.length - 1]
62
+ const numericValue = Number.parseFloat(lastToken[1])
63
+ if (!Number.isFinite(numericValue)) return null
64
+ const unit = lastToken[2].toLowerCase()
65
+ if (unit === "t") return numericValue * 1000
66
+ if (unit === "b") return numericValue
67
+ if (unit === "m") return numericValue / 1000
68
+ if (unit === "k") return numericValue / 1_000_000
69
+ return null
70
+ }
71
+
72
+ // Variant E: components/model-compare-dialog.tsx:44-60 (parseParamsBillionsFromModelName)
73
+ function parseParamsBillionsFromModelNameE(modelName) {
74
+ if (!modelName) return null
75
+ const sizeTokens = Array.from(modelName.matchAll(/\b(\d+(?:\.\d+)?)\s*([bm])\b/gi))
76
+ if (sizeTokens.length === 0) return null
77
+ const lastToken = sizeTokens[sizeTokens.length - 1]
78
+ const numericValue = Number(lastToken[1])
79
+ if (!Number.isFinite(numericValue)) return null
80
+ const unit = lastToken[2].toLowerCase()
81
+ if (unit === "b") return numericValue
82
+ if (unit === "m") return numericValue / 1000
83
+ return null
84
+ }
85
+
86
+ // Variant F: app/evals/[id]/page.tsx:434-437 (inline)
87
+ function parseParamsBillionsInline(name, id) {
88
+ const sizeMatch = (String(name ?? "") + " " + String(id ?? "")).match(
89
+ /\b(\d+(?:\.\d+)?)\s*[bB]\b/
90
+ )
91
+ if (sizeMatch) return parseFloat(sizeMatch[1])
92
+ return null
93
+ }
94
+
95
+ // === Audit ===
96
+
97
+ const cacheDir = ".cache/hf-data"
98
+ if (!fs.existsSync(cacheDir)) {
99
+ console.error(`No cache at ${cacheDir}. Run \`pnpm cache-hf-data\` first.`)
100
+ process.exit(1)
101
+ }
102
+
103
+ // ---- 1) Distribution of `model-cards.json.params_billions` (Variant A's input)
104
+ const cardsPath = path.join(cacheDir, "model-cards.json")
105
+ let modelCardsTypeDist = { number: 0, string: 0, null: 0, other: 0 }
106
+ let modelCardsAOutputs = { positive: 0, zero: 0, negative: 0, nan: 0, null: 0 }
107
+ let modelCardsCount = 0
108
+ if (fs.existsSync(cardsPath)) {
109
+ const cards = JSON.parse(fs.readFileSync(cardsPath, "utf8"))
110
+ modelCardsCount = cards.length
111
+ for (const card of cards) {
112
+ const v = card.params_billions
113
+ const t = v === null ? "null" : typeof v
114
+ modelCardsTypeDist[t] = (modelCardsTypeDist[t] ?? 0) + 1
115
+ const out = parseParamsBillions(v)
116
+ if (out === null) modelCardsAOutputs.null++
117
+ else if (Number.isNaN(out)) modelCardsAOutputs.nan++
118
+ else if (out > 0) modelCardsAOutputs.positive++
119
+ else if (out === 0) modelCardsAOutputs.zero++
120
+ else modelCardsAOutputs.negative++
121
+ }
122
+ }
123
+
124
+ // ---- 2) Per-model-result audit
125
+ const modelsDir = path.join(cacheDir, "models")
126
+ const files = fs.existsSync(modelsDir) ? fs.readdirSync(modelsDir) : []
127
+
128
+ // Buckets:
129
+ // - 'add.params_billions': string|number|undefined per row
130
+ // - format distribution of strings (clean decimal? unit-suffixed?)
131
+ // - model name format distribution (b-suffix? K-suffix? T-suffix? MoE 8x7B? bare like GPT-4?)
132
+ // - per-row Variant D resolution: which fallback fires
133
+ const addPbTypeDist = { undefined: 0, null: 0, number: 0, string: 0, other: 0 }
134
+ const stringPbFormatDist = { cleanDecimal: 0, unitSuffix: 0, other: 0 }
135
+ const stringPbExamples = { cleanDecimal: [], unitSuffix: [], other: [] }
136
+ const nameFormatDist = {
137
+ hasBOnly: 0, // "Llama-3-70B-Instruct"
138
+ hasBAndContextWindow: 0, // "Llama-3-70B-Instruct-8K" (contains B and K)
139
+ hasBAndT: 0, // contains both B and T
140
+ hasMOnly: 0, // "560M"
141
+ hasMoEPattern: 0, // "Mixtral-8x7B" (\dx\d before B)
142
+ noUnitToken: 0, // "GPT-4", no unit suffix
143
+ empty: 0,
144
+ }
145
+ const nameExamples = {
146
+ hasBOnly: [],
147
+ hasBAndContextWindow: [],
148
+ hasBAndT: [],
149
+ hasMOnly: [],
150
+ hasMoEPattern: [],
151
+ noUnitToken: [],
152
+ }
153
+ const variantDFallback = {
154
+ addPbNumber: 0, // additional_details.params_billions is number β†’ returned as-is
155
+ addPbString: 0, // additional_details.params_billions is string β†’ Variant B
156
+ addParameterCount: 0, // additional_details.parameter_count fallback
157
+ addNumParameters: 0,
158
+ addParams: 0,
159
+ miParameterCount: 0,
160
+ modelNameFallback: 0, // last fallback: parseParamsBillionsFromModelName(name) β€” Variant C
161
+ noResolution: 0, // all paths return null/undefined
162
+ }
163
+
164
+ // Cross-variant disagreement on names (where parsers agree on B/None but C diverges)
165
+ let nameAgreementCounts = {
166
+ allConverge: 0, // A,B,C,E,F all return same value (or all null)
167
+ cTSQuirkContextWindow: 0, // C returns context-window (β‰ͺ 1), F/E/A/B return param count
168
+ fOnlyMissing: 0, // F returns null because no B, but others find something
169
+ someDisagreement: 0,
170
+ }
171
+ const disagreementExamples = []
172
+
173
+ let totalRows = 0
174
+ let count = 0
175
+ const FILE_CAP = 100000
176
+
177
+ function classifyName(name) {
178
+ if (!name) return "empty"
179
+ // Detect MoE pattern: digit + 'x' + digit + B (no \b between x and digit)
180
+ if (/\dx\d+\s*[bB]\b/.test(name)) return "hasMoEPattern"
181
+ const hasB = /\b\d+(\.\d+)?\s*[bB]\b/.test(name)
182
+ const hasContextWindow = /\b\d+\s*[kK]\b/.test(name)
183
+ const hasT = /\b\d+(\.\d+)?\s*[tT]\b/.test(name)
184
+ const hasM = /\b\d+(\.\d+)?\s*[mM]\b/.test(name)
185
+ if (hasB && hasContextWindow) return "hasBAndContextWindow"
186
+ if (hasB && hasT) return "hasBAndT"
187
+ if (hasB) return "hasBOnly"
188
+ if (hasM) return "hasMOnly"
189
+ return "noUnitToken"
190
+ }
191
+
192
+ function classifyStringPb(s) {
193
+ if (/^\d+(\.\d+)?$/.test(s.trim())) return "cleanDecimal"
194
+ if (
195
+ /(\d+(\.\d+)?)\s*(trillion|tn|t|billion|bn|b|million|mn|m|thousand|k)\b/i.test(s)
196
+ )
197
+ return "unitSuffix"
198
+ return "other"
199
+ }
200
+
201
+ function processModelInfo(mi) {
202
+ totalRows++
203
+ const ad = mi.additional_details || {}
204
+ const v = ad.params_billions
205
+ const t = v === undefined ? "undefined" : v === null ? "null" : typeof v
206
+ addPbTypeDist[t] = (addPbTypeDist[t] ?? 0) + 1
207
+
208
+ if (typeof v === "string") {
209
+ const fmt = classifyStringPb(v)
210
+ stringPbFormatDist[fmt]++
211
+ if (stringPbExamples[fmt].length < 5) stringPbExamples[fmt].push(v)
212
+ }
213
+
214
+ // Variant D fallback resolution
215
+ let resolved = false
216
+ const rawPb =
217
+ ad.params_billions ?? ad.parameter_count ?? ad.num_parameters ?? ad.params
218
+ if (typeof rawPb === "number") {
219
+ if (ad.params_billions != null) variantDFallback.addPbNumber++
220
+ else if (ad.parameter_count != null) variantDFallback.addParameterCount++
221
+ else if (ad.num_parameters != null) variantDFallback.addNumParameters++
222
+ else variantDFallback.addParams++
223
+ resolved = true
224
+ } else if (typeof rawPb === "string") {
225
+ const parsed = parseParamsBillionsFromText(rawPb)
226
+ if (Number.isFinite(parsed)) {
227
+ if (ad.params_billions != null) variantDFallback.addPbString++
228
+ else if (ad.parameter_count != null) variantDFallback.addParameterCount++
229
+ else if (ad.num_parameters != null) variantDFallback.addNumParameters++
230
+ else variantDFallback.addParams++
231
+ resolved = true
232
+ }
233
+ }
234
+ if (!resolved && typeof mi.parameter_count === "string") {
235
+ const parsed = parseParamsBillionsFromText(mi.parameter_count)
236
+ if (Number.isFinite(parsed)) {
237
+ variantDFallback.miParameterCount++
238
+ resolved = true
239
+ }
240
+ }
241
+ if (!resolved) {
242
+ const parsed = parseParamsBillionsFromModelNameC(mi.name)
243
+ if (parsed != null) {
244
+ variantDFallback.modelNameFallback++
245
+ resolved = true
246
+ }
247
+ }
248
+ if (!resolved) variantDFallback.noResolution++
249
+
250
+ // Name format
251
+ const cls = classifyName(mi.name)
252
+ nameFormatDist[cls] = (nameFormatDist[cls] ?? 0) + 1
253
+ if (nameExamples[cls] && nameExamples[cls].length < 5 && mi.name) {
254
+ nameExamples[cls].push(mi.name)
255
+ }
256
+
257
+ // Cross-variant on the name
258
+ if (mi.name) {
259
+ const a = parseParamsBillions(mi.name)
260
+ const b = parseParamsBillionsFromText(mi.name)
261
+ const c = parseParamsBillionsFromModelNameC(mi.name)
262
+ const e = parseParamsBillionsFromModelNameE(mi.name)
263
+ const f = parseParamsBillionsInline(mi.name, mi.id ?? "")
264
+
265
+ const vals = [a, b, c, e, f]
266
+ const allEq = vals.every((x) => x === vals[0] || (x == null && vals[0] == null))
267
+ if (allEq) nameAgreementCounts.allConverge++
268
+ else {
269
+ // C-specific TS quirk: c is much smaller (< 0.001) while a/e/f match
270
+ if (c != null && c < 0.001 && a != null && e === a && f === a) {
271
+ nameAgreementCounts.cTSQuirkContextWindow++
272
+ if (disagreementExamples.length < 10) {
273
+ disagreementExamples.push({
274
+ name: mi.name,
275
+ id: mi.id,
276
+ type: "C TS-quirk: context-window beats param count",
277
+ values: { A: a, B: b, C: c, E: e, F: f },
278
+ })
279
+ }
280
+ } else if (f == null && (a != null || c != null || e != null)) {
281
+ nameAgreementCounts.fOnlyMissing++
282
+ } else {
283
+ nameAgreementCounts.someDisagreement++
284
+ if (disagreementExamples.length < 10) {
285
+ disagreementExamples.push({
286
+ name: mi.name,
287
+ id: mi.id,
288
+ type: "other disagreement",
289
+ values: { A: a, B: b, C: c, E: e, F: f },
290
+ })
291
+ }
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ function walk(node, topModelInfo) {
298
+ for (const m of node.metrics ?? []) {
299
+ for (const r of m.model_results ?? []) {
300
+ // Per-row model_info doesn't exist in the cache; the runtime
301
+ // buildModelInfoForVariant in lib/hf-data.ts:1133 spreads
302
+ // detail.model_info onto each row. So for audit purposes we treat the
303
+ // top-level model_info as the per-row model_info.
304
+ const mi = {
305
+ ...topModelInfo,
306
+ id: r.raw_model_id ?? r.model_id ?? topModelInfo.id,
307
+ name: r.model_name || topModelInfo.name,
308
+ developer: r.developer || topModelInfo.developer,
309
+ additional_details: {
310
+ ...(topModelInfo.additional_details || {}),
311
+ raw_model_id: r.raw_model_id ?? r.model_id,
312
+ },
313
+ }
314
+ processModelInfo(mi)
315
+ }
316
+ }
317
+ for (const s of node.subtasks ?? []) walk(s, topModelInfo)
318
+ }
319
+
320
+ for (const fn of files) {
321
+ if (count++ > FILE_CAP) break
322
+ let data
323
+ try {
324
+ data = JSON.parse(fs.readFileSync(path.join(modelsDir, fn), "utf8"))
325
+ } catch (err) {
326
+ continue
327
+ }
328
+ const mi = data.model_info || {}
329
+ for (const cat of Object.values(data.hierarchy_by_category ?? {})) {
330
+ for (const node of cat) walk(node, mi)
331
+ }
332
+ }
333
+
334
+ // === Print results ===
335
+
336
+ console.log(`=== Audit: params parsing ===`)
337
+ console.log(`Cache: ${cacheDir} (model-cards.json: ${modelCardsCount} cards; models/: ${files.length} files; ${totalRows} model_result rows scanned)`)
338
+ console.log()
339
+
340
+ console.log(`--- 1) Variant A input: model-cards.json.params_billions type distribution ---`)
341
+ console.log(modelCardsTypeDist)
342
+ console.log(`Variant A outputs:`, modelCardsAOutputs)
343
+ console.log()
344
+
345
+ console.log(`--- 2) Per-row model_info.additional_details.params_billions type distribution ---`)
346
+ console.log(addPbTypeDist)
347
+ console.log(`Of ${addPbTypeDist.string ?? 0} string-typed values, format distribution:`)
348
+ console.log(stringPbFormatDist)
349
+ for (const [k, exs] of Object.entries(stringPbExamples)) {
350
+ if (exs.length === 0) continue
351
+ console.log(` ${k} examples: ${exs.map((e) => `'${e}'`).join(", ")}`)
352
+ }
353
+ console.log()
354
+
355
+ console.log(`--- 3) Variant D (orchestrator) fallback resolution counts ---`)
356
+ console.log(variantDFallback)
357
+ const totalResolved = Object.entries(variantDFallback)
358
+ .filter(([k]) => k !== "noResolution")
359
+ .reduce((s, [, v]) => s + v, 0)
360
+ const pctByPath = Object.fromEntries(
361
+ Object.entries(variantDFallback).map(([k, v]) => [
362
+ k,
363
+ totalRows > 0 ? `${((v / totalRows) * 100).toFixed(1)}%` : "0%",
364
+ ])
365
+ )
366
+ console.log(`(% of rows by path)`, pctByPath)
367
+ console.log(`Total resolved: ${totalResolved} of ${totalRows} (${((totalResolved / totalRows) * 100).toFixed(1)}%)`)
368
+ console.log()
369
+
370
+ console.log(`--- 4) Model name format distribution (drives Variant C/E/F) ---`)
371
+ console.log(nameFormatDist)
372
+ for (const [k, exs] of Object.entries(nameExamples)) {
373
+ if (exs.length === 0) continue
374
+ console.log(` ${k} examples: ${exs.map((e) => `'${e}'`).join(", ")}`)
375
+ }
376
+ console.log()
377
+
378
+ console.log(`--- 5) Cross-variant agreement on model names (A/B/C/E/F applied to the name string) ---`)
379
+ console.log(nameAgreementCounts)
380
+ const totalNames = Object.values(nameAgreementCounts).reduce((s, v) => s + v, 0)
381
+ console.log(`Total names checked: ${totalNames}`)
382
+ if (totalNames > 0) {
383
+ console.log(
384
+ ` Convergence rate: ${((nameAgreementCounts.allConverge / totalNames) * 100).toFixed(2)}%`
385
+ )
386
+ console.log(
387
+ ` Variant-C TS-quirk hit rate (context-window beats param count): ${nameAgreementCounts.cTSQuirkContextWindow} (${((nameAgreementCounts.cTSQuirkContextWindow / totalNames) * 100).toFixed(2)}%)`
388
+ )
389
+ }
390
+
391
+ if (disagreementExamples.length > 0) {
392
+ console.log()
393
+ console.log(`--- Sample disagreements (up to 10) ---`)
394
+ for (const ex of disagreementExamples) {
395
+ console.log(` [${ex.type}]`)
396
+ console.log(` name: '${ex.name}' id: '${ex.id}'`)
397
+ console.log(` A=${ex.values.A} B=${ex.values.B} C=${ex.values.C} E=${ex.values.E} F=${ex.values.F}`)
398
+ }
399
+ }
400
+
401
+ console.log()
402
+ console.log(`=== Done ===`)
scripts/verify-setup-alias.mjs ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./server-only-shim.mjs"
2
+ import fs from "fs"
3
+
4
+ const { getCanonicalModelIdentity, getModelFamilyRouteId } = await import("../lib/model-family.ts")
5
+
6
+ // Replicate the runtime normalizer's logic exactly (lib/hf-data.ts:750-812)
7
+ // so we can trace what it would do against any real card.
8
+ function normalizeSetupAliasQualifier(value) {
9
+ return value?.trim().toLowerCase().replace(/[_\s]+/g, "-") ?? ""
10
+ }
11
+ function isSetupAliasQualifier(value) {
12
+ const normalized = normalizeSetupAliasQualifier(value)
13
+ return (
14
+ normalized === "prompt" ||
15
+ normalized === "fc" ||
16
+ normalized === "function-calling" ||
17
+ normalized.startsWith("thinking")
18
+ )
19
+ }
20
+ function getLatestTimestamp(a, b) {
21
+ if (!a) return b
22
+ if (!b) return a
23
+ const aTime = new Date(a).getTime()
24
+ const bTime = new Date(b).getTime()
25
+ if (!Number.isFinite(aTime)) return b
26
+ if (!Number.isFinite(bTime)) return a
27
+ return bTime > aTime ? b : a
28
+ }
29
+ function normalizeSingleModelCardEntry(entry) {
30
+ const familyIdentity = getCanonicalModelIdentity({ id: entry.model_family_id, name: entry.model_family_name })
31
+ const variantsByKey = new Map()
32
+ for (const variant of entry.variants ?? []) {
33
+ let normalizedVariantKey = variant.variant_key
34
+ let normalizedVariantLabel = variant.variant_label
35
+ if (variant.variant_key === "base") {
36
+ normalizedVariantKey = "default"
37
+ normalizedVariantLabel = "Default"
38
+ } else if (variant.variant_key !== "default") {
39
+ const syntheticIdentity = getCanonicalModelIdentity({
40
+ id: `${familyIdentity.familyId}-${variant.variant_key}`,
41
+ name: `${familyIdentity.familyId}-${variant.variant_key}`,
42
+ })
43
+ if (syntheticIdentity.versionDate && isSetupAliasQualifier(syntheticIdentity.versionQualifier)) {
44
+ normalizedVariantKey = syntheticIdentity.versionDate
45
+ normalizedVariantLabel = syntheticIdentity.versionDate
46
+ } else {
47
+ normalizedVariantKey = syntheticIdentity.variantKey
48
+ normalizedVariantLabel = syntheticIdentity.variantLabel
49
+ }
50
+ }
51
+ const existing = variantsByKey.get(normalizedVariantKey)
52
+ if (existing) {
53
+ existing.evaluation_count += variant.evaluation_count
54
+ existing.last_updated = getLatestTimestamp(existing.last_updated, variant.last_updated)
55
+ existing.raw_model_ids = Array.from(new Set([...(existing.raw_model_ids ?? []), ...(variant.raw_model_ids ?? [])])).sort()
56
+ continue
57
+ }
58
+ variantsByKey.set(normalizedVariantKey, {
59
+ ...variant,
60
+ variant_key: normalizedVariantKey,
61
+ variant_label: normalizedVariantLabel,
62
+ raw_model_ids: [...(variant.raw_model_ids ?? [])].sort(),
63
+ })
64
+ }
65
+ const normalizedVariants = Array.from(variantsByKey.values())
66
+ return { ...entry, variants: normalizedVariants }
67
+ }
68
+
69
+ const cards = JSON.parse(fs.readFileSync(".cache/hf-data/model-cards.json", "utf8"))
70
+
71
+ // === Sample a known-affected card to show pre/post ===
72
+ console.log("=== Sample: openai/gpt-5.2 (multi-variant flagship, known to exercise the rule) ===")
73
+ const gpt52 = cards.find(c => c.model_family_id === "openai/gpt-5.2")
74
+ if (gpt52) {
75
+ console.log("Input variants (cache state):")
76
+ for (const v of gpt52.variants) console.log(` '${v.variant_key}'`)
77
+ console.log("After runtime normalize (what /api/model-cards returns):")
78
+ const normalized = normalizeSingleModelCardEntry(gpt52)
79
+ for (const v of normalized.variants) console.log(` '${v.variant_key}' / raw_ids count=${v.raw_model_ids?.length ?? 0}`)
80
+ }
81
+
82
+ // === Aggregate audit: across all cards, does normalizer change anything? ===
83
+ console.log("\n=== Aggregate: cards where normalizer would CHANGE the variants list ===")
84
+ let changedCount = 0
85
+ const changedExamples = []
86
+ for (const c of cards) {
87
+ const beforeKeys = (c.variants ?? []).map(v => v.variant_key).sort().join("|")
88
+ const afterKeys = normalizeSingleModelCardEntry(c).variants.map(v => v.variant_key).sort().join("|")
89
+ if (beforeKeys !== afterKeys) {
90
+ changedCount++
91
+ if (changedExamples.length < 5) changedExamples.push({ family: c.model_family_id, before: beforeKeys, after: afterKeys })
92
+ }
93
+ }
94
+ console.log(` ${changedCount} of ${cards.length} cards would have variants changed by runtime normalizer`)
95
+ for (const ex of changedExamples) {
96
+ console.log(` ${ex.family}:`)
97
+ console.log(` before: ${ex.before}`)
98
+ console.log(` after: ${ex.after}`)
99
+ }
scripts/verify-slug-candidates.mjs ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./server-only-shim.mjs"
2
+ import fs from "fs"
3
+
4
+ // Replicate the slug-candidate generators from lib/model-data.ts:150-211 verbatim.
5
+ function pipelineSlugify(text) {
6
+ return (
7
+ text
8
+ .replace(/[\x00-\x1f\x7f]/g, "")
9
+ .replace(/[^a-zA-Z0-9._-]/g, "_")
10
+ .replace(/^_+|_+$/g, "") || "unknown"
11
+ )
12
+ }
13
+
14
+ function getModelDetailSlugCandidates(modelId) {
15
+ const normalized = modelId.trim()
16
+ const candidates = new Set()
17
+ const withSlash = normalized.replace(/\//g, "__")
18
+ const withDots = withSlash.replace(/\./g, "-")
19
+ candidates.add(pipelineSlugify(withSlash))
20
+ candidates.add(pipelineSlugify(withSlash.toLowerCase()))
21
+ candidates.add(pipelineSlugify(withDots))
22
+ candidates.add(pipelineSlugify(withDots.toLowerCase()))
23
+ candidates.add(pipelineSlugify(normalized))
24
+ candidates.add(pipelineSlugify(normalized.toLowerCase()))
25
+ return Array.from(candidates)
26
+ }
27
+
28
+ function getDeveloperSlugCandidates(developerOrRouteId) {
29
+ const normalized = developerOrRouteId.trim()
30
+ const candidates = new Set()
31
+ const lowercase = normalized.toLowerCase()
32
+ const underscoreSlug = pipelineSlugify(normalized)
33
+ const lowercaseUnderscoreSlug = pipelineSlugify(lowercase)
34
+ const hyphenSlug = lowercase
35
+ .replace(/[\x00-\x1f\x7f]/g, "")
36
+ .replace(/[^a-z0-9]+/g, "-")
37
+ .replace(/^-+|-+$/g, "")
38
+ const compactSlug = lowercase.replace(/[^a-z0-9]+/g, "")
39
+ candidates.add(underscoreSlug)
40
+ candidates.add(lowercaseUnderscoreSlug)
41
+ candidates.add(underscoreSlug.replace(/_/g, "-"))
42
+ candidates.add(lowercaseUnderscoreSlug.replace(/_/g, "-"))
43
+ if (hyphenSlug) candidates.add(hyphenSlug)
44
+ if (compactSlug) candidates.add(compactSlug)
45
+ return Array.from(candidates)
46
+ }
47
+
48
+ // === Audit: for each model card, which candidate position resolves? ===
49
+ const cards = JSON.parse(fs.readFileSync(".cache/hf-data/model-cards.json", "utf8"))
50
+ const modelFiles = new Set(fs.readdirSync(".cache/hf-data/models"))
51
+
52
+ console.log(`=== Model lookups (${cards.length} cards) ===`)
53
+ const modelHitPositions = new Map()
54
+ let modelMisses = 0
55
+ const modelMissExamples = []
56
+
57
+ for (const c of cards) {
58
+ // Try the family_id (with slash) since that's what getModelSummaryById uses
59
+ const candidates = getModelDetailSlugCandidates(c.model_family_id)
60
+ let hitPos = -1
61
+ for (let i = 0; i < candidates.length; i++) {
62
+ if (modelFiles.has(`${candidates[i]}.json`)) {
63
+ hitPos = i
64
+ break
65
+ }
66
+ }
67
+ if (hitPos === -1) {
68
+ modelMisses++
69
+ if (modelMissExamples.length < 5) modelMissExamples.push({ family: c.model_family_id, route: c.model_route_id, candidates })
70
+ } else {
71
+ modelHitPositions.set(hitPos, (modelHitPositions.get(hitPos) ?? 0) + 1)
72
+ }
73
+ }
74
+ for (const [pos, n] of [...modelHitPositions.entries()].sort((a, b) => a[0] - b[0])) {
75
+ console.log(` position ${pos}: ${n} hits`)
76
+ }
77
+ console.log(` misses (none of 6 candidates worked): ${modelMisses}`)
78
+ if (modelMissExamples.length) console.log(" miss examples:", modelMissExamples)
79
+
80
+ // === Audit: for each developer, which candidate position resolves? ===
81
+ const devs = JSON.parse(fs.readFileSync(".cache/hf-data/developers.json", "utf8"))
82
+ const developerFiles = new Set(fs.readdirSync(".cache/hf-data/developers"))
83
+
84
+ console.log(`\n=== Developer lookups (${devs.length} developers) ===`)
85
+ const devHitPositions = new Map()
86
+ let devMisses = 0
87
+ const devMissExamples = []
88
+
89
+ for (const d of devs) {
90
+ const candidates = getDeveloperSlugCandidates(d.developer ?? d.route_id ?? "")
91
+ let hitPos = -1
92
+ for (let i = 0; i < candidates.length; i++) {
93
+ if (developerFiles.has(`${candidates[i]}.json`)) {
94
+ hitPos = i
95
+ break
96
+ }
97
+ }
98
+ if (hitPos === -1) {
99
+ devMisses++
100
+ if (devMissExamples.length < 5) devMissExamples.push({ dev: d.developer, candidates })
101
+ } else {
102
+ devHitPositions.set(hitPos, (devHitPositions.get(hitPos) ?? 0) + 1)
103
+ }
104
+ }
105
+ for (const [pos, n] of [...devHitPositions.entries()].sort((a, b) => a[0] - b[0])) {
106
+ console.log(` position ${pos}: ${n} hits`)
107
+ }
108
+ console.log(` misses (none of 6 candidates worked): ${devMisses}`)
109
+ if (devMissExamples.length) console.log(" miss examples:", devMissExamples)
110
+
111
+ // === Show example candidates for a dotted-name model ===
112
+ console.log("\n=== Example: openai/gpt-5.2 candidates ===")
113
+ const exCandidates = getModelDetailSlugCandidates("openai/gpt-5.2")
114
+ for (let i = 0; i < exCandidates.length; i++) {
115
+ const exists = modelFiles.has(`${exCandidates[i]}.json`)
116
+ console.log(` [${i}] '${exCandidates[i]}.json' ${exists ? "← HIT" : "(miss)"}`)
117
+ }
scripts/verify-timestamp.mjs ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "./server-only-shim.mjs"
2
+ import fs from "fs"
3
+
4
+ // === Replicate all three TS implementations verbatim ===
5
+
6
+ // 1. lib/model-data.ts:76 β€” uses Number(), multiplies by 1000 if numeric AND no dash
7
+ function normalizeEvalTimestamp(value) {
8
+ const numericTimestamp = Number(value)
9
+ return !Number.isNaN(numericTimestamp) && !value.includes("-")
10
+ ? numericTimestamp * 1000
11
+ : new Date(value).getTime()
12
+ }
13
+
14
+ // 2. lib/hf-data.ts:1049 β€” uses parseFloat, NO multiplier, handles undefined
15
+ function toComparableTimestampHfData(timestamp) {
16
+ if (!timestamp) {
17
+ return Number.NEGATIVE_INFINITY
18
+ }
19
+ const numericTimestamp = Number.parseFloat(timestamp)
20
+ if (Number.isFinite(numericTimestamp)) {
21
+ return numericTimestamp
22
+ }
23
+ const parsedTimestamp = new Date(timestamp).getTime()
24
+ return Number.isFinite(parsedTimestamp) ? parsedTimestamp : Number.NEGATIVE_INFINITY
25
+ }
26
+
27
+ // 3. components/benchmark-detail.tsx:1418 β€” same as hf-data.ts but no undefined handling
28
+ function toComparableTimestampBenchmarkDetail(timestamp) {
29
+ const numericTimestamp = Number.parseFloat(timestamp)
30
+ if (Number.isFinite(numericTimestamp)) {
31
+ return numericTimestamp
32
+ }
33
+ const parsedTimestamp = new Date(timestamp).getTime()
34
+ return Number.isFinite(parsedTimestamp) ? parsedTimestamp : Number.NEGATIVE_INFINITY
35
+ }
36
+
37
+ // === Audit: distribution of timestamp formats in production ===
38
+
39
+ const dir = ".cache/hf-data/models"
40
+ const files = fs.readdirSync(dir)
41
+ const formats = { isoDateTime: 0, unixSecondsString: 0, unixMsString: 0, empty: 0, other: 0 }
42
+ const formatExamples = { isoDateTime: [], unixSecondsString: [], unixMsString: [], other: [] }
43
+ let totalChecked = 0
44
+
45
+ function classify(ts) {
46
+ if (!ts) return "empty"
47
+ // ISO date-time has dashes (YYYY-MM-DD or YYYY-MM-DDTHH:...)
48
+ if (/^\d{4}-\d{2}-\d{2}/.test(ts)) return "isoDateTime"
49
+ // All numeric
50
+ if (/^\d+(\.\d+)?$/.test(ts)) {
51
+ const n = Number.parseFloat(ts)
52
+ // Unix seconds typically ~1.6e9 (year 2020+) up to ~2e9 (2033)
53
+ // Unix ms typically ~1.6e12 (year 2020+) up to ~2e12 (2033)
54
+ if (n < 1e11) return "unixSecondsString"
55
+ return "unixMsString"
56
+ }
57
+ return "other"
58
+ }
59
+
60
+ function walk(node) {
61
+ for (const m of node.metrics ?? []) {
62
+ for (const r of m.model_results ?? []) {
63
+ const ts = r.retrieved_timestamp
64
+ const cat = classify(ts)
65
+ formats[cat] = (formats[cat] ?? 0) + 1
66
+ if (formatExamples[cat] && formatExamples[cat].length < 3) formatExamples[cat].push(ts)
67
+ totalChecked++
68
+ }
69
+ }
70
+ for (const s of node.subtasks ?? []) walk(s)
71
+ }
72
+ for (const f of files) {
73
+ const data = JSON.parse(fs.readFileSync(`${dir}/${f}`, "utf8"))
74
+ for (const cat of Object.values(data.hierarchy_by_category ?? {})) {
75
+ for (const node of cat) walk(node)
76
+ }
77
+ }
78
+
79
+ console.log(`=== Audit: timestamp format distribution (${totalChecked} model_result rows) ===`)
80
+ console.log(formats)
81
+ console.log()
82
+ for (const [k, exs] of Object.entries(formatExamples)) {
83
+ if (exs.length === 0) continue
84
+ console.log(`--- ${k} examples ---`)
85
+ for (const e of exs) console.log(` '${e}'`)
86
+ }
87
+
88
+ // === Audit: do the three implementations produce SAME relative ordering? ===
89
+ // Pick pairs of distinct-format timestamps and compare under each function.
90
+ console.log("\n=== Cross-impl ordering: same input pairs ===")
91
+ const pairs = [
92
+ ["1774096306.427425", "2026-04-13T12:34:56Z"], // unix seconds vs ISO datetime
93
+ ["1774096306427", "2026-04-13T12:34:56Z"], // unix ms vs ISO
94
+ ["2025-01-01", "2026-01-01"], // two ISO dates
95
+ ["1700000000", "1800000000"], // two unix seconds
96
+ ["1700000000000", "1800000000000"], // two unix ms
97
+ ]
98
+ for (const [a, b] of pairs) {
99
+ const m = normalizeEvalTimestamp
100
+ const h = toComparableTimestampHfData
101
+ const c = toComparableTimestampBenchmarkDetail
102
+ console.log(` pair: '${a}' vs '${b}'`)
103
+ console.log(` model-data.ts: ${m(a)} vs ${m(b)} (a${m(a) < m(b) ? '<' : m(a) > m(b) ? '>' : '='}b)`)
104
+ console.log(` hf-data.ts: ${h(a)} vs ${h(b)} (a${h(a) < h(b) ? '<' : h(a) > h(b) ? '>' : '='}b)`)
105
+ console.log(` benchmark.tsx: ${c(a)} vs ${c(b)} (a${c(a) < c(b) ? '<' : c(a) > c(b) ? '>' : '='}b)`)
106
+ }
tests/fixtures/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "$comment": "Pinned snapshot of HF cache files used by Tier A pipeline-contract tests and Tier B adapter snapshot tests. Refresh via `pnpm refresh-fixtures`. Each fixture earns its place by exercising a specific code path documented in `notes/testing-strategy.md` (curation criteria).",
3
  "snapshot_source": ".cache/hf-data",
4
  "snapshot_ts": "2026-04-27T22:32:59.376Z",
5
  "evals": [
 
1
  {
2
+ "$comment": "Pinned snapshot of HF cache files used by Tier A pipeline-contract tests and Tier B adapter snapshot tests. Refresh via `pnpm refresh-fixtures`. Each fixture entry has a `why` field explaining the specific code path it exercises (multi-variant model, first/third-party badge, Safety regression-bait, coding hierarchy key, etc). Curation rule: every fixture must justify its inclusion via `why`; no random sampling.",
3
  "snapshot_source": ".cache/hf-data",
4
  "snapshot_ts": "2026-04-27T22:32:59.376Z",
5
  "evals": [
tests/pipeline-contract.test.ts CHANGED
@@ -195,6 +195,47 @@ describe("Tier A β€” pipeline contracts (eval-detail files)", () => {
195
  }
196
  expect(violations, formatViolations(violations)).toEqual([])
197
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  })
199
 
200
  describe("Tier A β€” pipeline contracts (model card list entries)", () => {
 
195
  }
196
  expect(violations, formatViolations(violations)).toEqual([])
197
  })
198
+
199
+ // Replaces the deleted `prefersBenchmarkName` heuristic in
200
+ // `lib/model-data.ts hfEvalEntryToListItem`. That heuristic detected when a
201
+ // display string was in a "<generic-metric> on <benchmark>" / "for scorer" /
202
+ // "model_graded" shape and substituted the benchmark name. Audit against the
203
+ // full corpus showed 0/587 matches (verified 2026-04-28). The heuristic was
204
+ // deleted in favor of this explicit contract; if pipeline ever starts
205
+ // emitting display strings in those shapes again, this test fails loudly.
206
+ //
207
+ // The four fields below mirror the resolution order the deleted code used
208
+ // (`entry.evaluation_name || entry.display_name || entry.benchmark_leaf_name
209
+ // || entry.eval_summary_id`).
210
+ it("eval-list display strings don't match prefersBenchmarkName patterns (deleted heuristic)", () => {
211
+ const violations: Violation[] = []
212
+ for (const { id, data } of evals) {
213
+ // Pipeline emits `evaluation_name` and `display_name` on eval entries
214
+ // even though they're not on HFEvalDetail (TS type is a subset of the
215
+ // actual cache shape). Cast to access them.
216
+ const extras = data as unknown as { evaluation_name?: string; display_name?: string }
217
+ const raw =
218
+ extras.evaluation_name ||
219
+ extras.display_name ||
220
+ data.benchmark_leaf_name ||
221
+ data.eval_summary_id ||
222
+ ""
223
+ const normalized = raw.trim().toLowerCase()
224
+ const reasons: string[] = []
225
+ if (normalized.startsWith("accuracy on ")) reasons.push("startsWith('accuracy on ')")
226
+ if (normalized.startsWith("score on ")) reasons.push("startsWith('score on ')")
227
+ if (normalized.includes("for scorer")) reasons.push("includes('for scorer')")
228
+ if (normalized.includes("model_graded")) reasons.push("includes('model_graded')")
229
+ if (reasons.length > 0) {
230
+ violations.push({
231
+ fixture: id,
232
+ path: "evaluation_name|display_name|benchmark_leaf_name|eval_summary_id",
233
+ detail: `${JSON.stringify(raw)} matches: ${reasons.join(", ")}`,
234
+ })
235
+ }
236
+ }
237
+ expect(violations, formatViolations(violations)).toEqual([])
238
+ })
239
  })
240
 
241
  describe("Tier A β€” pipeline contracts (model card list entries)", () => {
tests/transformations/benchmark-card-attachment.test.ts ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ // Executable spec for `notes/transformations/11-benchmark-card-attachment.md`.
4
+ //
5
+ // Replicates the four transformation pieces from
6
+ // lib/benchmark-metadata.ts:11-49
7
+ // lib/benchmark-metadata-utils.ts:10-33
8
+ // lib/model-data.ts:860-875
9
+ // lib/duckdb-data.ts:133-156
10
+ // verbatim. Pipeline must produce identical attach behaviour for every case
11
+ // below β€” and the migration target is "always inline benchmark_card so this
12
+ // retry loop becomes dead code."
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Verbatim copies of lib/benchmark-metadata-utils.ts:10-33
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function normalizeBenchmarkKey(name: string): string {
19
+ if (!name) return ""
20
+ return name
21
+ .replace(/^[a-z0-9_]+ ?\//i, "")
22
+ .toLowerCase()
23
+ .replace(/[_-]+/g, " ")
24
+ .replace(/\s+/g, " ")
25
+ .trim()
26
+ }
27
+
28
+ function candidateBenchmarkKeys(name: string): string[] {
29
+ const base = normalizeBenchmarkKey(name)
30
+ return Array.from(
31
+ new Set([
32
+ base,
33
+ base.replace(/-/g, " "),
34
+ base.replace(/ /g, "-"),
35
+ base.replace(/[^a-z0-9]/g, ""),
36
+ ])
37
+ )
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Verbatim copy of lib/benchmark-metadata.ts:11-49 (map build + getBenchmarkCard)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ interface MinimalCard {
45
+ benchmark_details: { name: string }
46
+ // tag for fixture identification
47
+ __id: string
48
+ }
49
+
50
+ function buildMap(cards: Record<string, MinimalCard>): Map<string, MinimalCard> {
51
+ const map = new Map<string, MinimalCard>()
52
+ for (const card of Object.values(cards)) {
53
+ if (!card?.benchmark_details?.name) continue
54
+ for (const key of candidateBenchmarkKeys(card.benchmark_details.name)) {
55
+ if (!map.has(key)) {
56
+ map.set(key, card)
57
+ }
58
+ }
59
+ }
60
+ return map
61
+ }
62
+
63
+ function getBenchmarkCard(
64
+ map: Map<string, MinimalCard>,
65
+ benchmarkName: string
66
+ ): MinimalCard | null {
67
+ for (const key of candidateBenchmarkKeys(benchmarkName)) {
68
+ const card = map.get(key)
69
+ if (card) return card
70
+ }
71
+ return null
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Verbatim copy of lib/model-data.ts:860-875 (summary attach order: name, name, key)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ interface MinimalSummary {
79
+ evaluation_name?: string
80
+ composite_benchmark_name?: string
81
+ composite_benchmark_key?: string
82
+ benchmark_card?: MinimalCard | null
83
+ }
84
+
85
+ function attachBenchmarkCardToSummary(
86
+ map: Map<string, MinimalCard>,
87
+ summary: MinimalSummary
88
+ ): MinimalSummary {
89
+ if (summary.benchmark_card) return summary
90
+ const candidates = [
91
+ summary.evaluation_name,
92
+ summary.composite_benchmark_name,
93
+ summary.composite_benchmark_key,
94
+ ]
95
+ for (const candidate of candidates) {
96
+ const card = getBenchmarkCard(map, candidate ?? "")
97
+ if (card) return { ...summary, benchmark_card: card }
98
+ }
99
+ return summary
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Verbatim copy of lib/duckdb-data.ts:133-156 (list attach order: name, key, name)
104
+ // ---------------------------------------------------------------------------
105
+
106
+ interface MinimalListItem {
107
+ evaluation_name?: string
108
+ composite_benchmark_name?: string
109
+ composite_benchmark_key?: string
110
+ benchmark_card?: MinimalCard | null
111
+ }
112
+
113
+ function attachBenchmarkCardToListItem(
114
+ map: Map<string, MinimalCard>,
115
+ item: MinimalListItem
116
+ ): MinimalListItem {
117
+ if (item.benchmark_card) return item
118
+ const candidates = [
119
+ item.evaluation_name,
120
+ item.composite_benchmark_key,
121
+ item.composite_benchmark_name,
122
+ ].filter(Boolean) as string[]
123
+ for (const name of candidates) {
124
+ const card = getBenchmarkCard(map, name)
125
+ if (card) return { ...item, benchmark_card: card }
126
+ }
127
+ return item
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Group A β€” normalizeBenchmarkKey
132
+ // ---------------------------------------------------------------------------
133
+
134
+ describe("Group A β€” normalizeBenchmarkKey", () => {
135
+ const cases = [
136
+ { input: "MMLU", expected: "mmlu", why: "lowercase only" },
137
+ { input: "BIG-Bench Hard (BBH)", expected: "big bench hard (bbh)", why: "dashes β†’ spaces" },
138
+ { input: "hfopenllm_v2/mmlu", expected: "mmlu", why: "composite prefix stripped" },
139
+ { input: "hfopenllm_v2 / mmlu", expected: "mmlu", why: "composite prefix with one space before /" },
140
+ { input: "GPQA / Diamond", expected: "diamond", why: "regex allows optional space before /" },
141
+ { input: "", expected: "", why: "falsy short-circuit (NOT 'unknown' fallback)" },
142
+ { input: " MMLU ", expected: "mmlu", why: "trim leading/trailing whitespace" },
143
+ { input: "foo___bar", expected: "foo bar", why: "underscore run β†’ single space" },
144
+ { input: "foo - - bar", expected: "foo bar", why: "dash + space runs collapse to single space" },
145
+ { input: "mmlu_categories/mmlu_pro", expected: "mmlu pro", why: "prefix strip + underscore collapse" },
146
+ { input: "BBH", expected: "bbh", why: "lowercase only" },
147
+ ]
148
+ it.each(cases)("'$input' β†’ '$expected' ($why)", ({ input, expected }) => {
149
+ expect(normalizeBenchmarkKey(input)).toBe(expected)
150
+ })
151
+ })
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Group B β€” candidateBenchmarkKeys
155
+ // ---------------------------------------------------------------------------
156
+
157
+ describe("Group B β€” candidateBenchmarkKeys", () => {
158
+ const cases = [
159
+ { input: "MMLU", expected: ["mmlu"], why: "no dashes, no spaces, alnum-only β†’ all 4 variants collapse" },
160
+ {
161
+ input: "BIG-Bench Hard (BBH)",
162
+ expected: ["big bench hard (bbh)", "big-bench-hard-(bbh)", "bigbenchhardbbh"],
163
+ why: "base; spaces→dashes; alnum-only (dashes→spaces collides with base after normalizer dash-collapse)",
164
+ },
165
+ { input: "GSM-8K", expected: ["gsm 8k", "gsm-8k", "gsm8k"], why: "base; spaces→dashes; alnum-only" },
166
+ { input: "gsm 8k", expected: ["gsm 8k", "gsm-8k", "gsm8k"], why: "identical to above (input differs only in dash/space)" },
167
+ { input: "hfopenllm_v2/mmlu", expected: ["mmlu"], why: "composite prefix stripped, no other variations" },
168
+ { input: "", expected: [""], why: "all four variants collapse to empty string" },
169
+ ]
170
+ it.each(cases)("'$input' β†’ $expected ($why)", ({ input, expected }) => {
171
+ expect(candidateBenchmarkKeys(input)).toEqual(expected)
172
+ })
173
+ })
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Group C β€” getBenchmarkCard (per-name lookup)
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe("Group C β€” getBenchmarkCard (per-name lookup against deduped map)", () => {
180
+ const cards: Record<string, MinimalCard> = {
181
+ mmlu: { __id: "mmlu", benchmark_details: { name: "MMLU" } },
182
+ bbh: { __id: "bbh", benchmark_details: { name: "BIG-Bench Hard (BBH)" } },
183
+ gsm: { __id: "gsm", benchmark_details: { name: "GSM-8K" } },
184
+ }
185
+ const map = buildMap(cards)
186
+
187
+ it("finds MMLU via base candidate", () => {
188
+ expect(getBenchmarkCard(map, "MMLU")?.__id).toBe("mmlu")
189
+ })
190
+ it("finds MMLU via case variation", () => {
191
+ expect(getBenchmarkCard(map, "mmlu")?.__id).toBe("mmlu")
192
+ })
193
+ it("finds BBH card via full title (matches indexed key)", () => {
194
+ expect(getBenchmarkCard(map, "BIG-Bench Hard (BBH)")?.__id).toBe("bbh")
195
+ })
196
+ it("MISSES BBH abbreviation (reverse-lookup limitation)", () => {
197
+ // The card was indexed under variants of its full name. The eval name
198
+ // "BBH" produces only ["bbh"] which does not collide with any indexed key.
199
+ expect(getBenchmarkCard(map, "BBH")).toBeNull()
200
+ })
201
+ it("finds GSM-8K via input with space", () => {
202
+ expect(getBenchmarkCard(map, "GSM 8K")?.__id).toBe("gsm")
203
+ })
204
+ it("returns null for empty string (no card indexed under '')", () => {
205
+ expect(getBenchmarkCard(map, "")).toBeNull()
206
+ })
207
+ it("strips composite prefix and finds the leaf card", () => {
208
+ expect(getBenchmarkCard(map, "hfopenllm_v2/mmlu")?.__id).toBe("mmlu")
209
+ })
210
+ })
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Group D β€” Map build first-write-wins dedup
214
+ // ---------------------------------------------------------------------------
215
+
216
+ describe("Group D β€” map build dedup behaviour (first-write-wins)", () => {
217
+ it("when two cards normalize to the same key, the first one wins", () => {
218
+ const cards: Record<string, MinimalCard> = {
219
+ first: { __id: "first", benchmark_details: { name: "MMLU" } },
220
+ second: { __id: "second", benchmark_details: { name: "mmlu" } },
221
+ }
222
+ const map = buildMap(cards)
223
+ expect(getBenchmarkCard(map, "MMLU")?.__id).toBe("first")
224
+ })
225
+
226
+ it("Object.values insertion order determines who wins", () => {
227
+ // Reverse insertion order β€” now 'second' comes first.
228
+ const cards: Record<string, MinimalCard> = {
229
+ second: { __id: "second", benchmark_details: { name: "mmlu" } },
230
+ first: { __id: "first", benchmark_details: { name: "MMLU" } },
231
+ }
232
+ const map = buildMap(cards)
233
+ expect(getBenchmarkCard(map, "MMLU")?.__id).toBe("second")
234
+ })
235
+
236
+ it("a card with missing benchmark_details.name is skipped", () => {
237
+ const cards: Record<string, MinimalCard> = {
238
+ bad: { __id: "bad", benchmark_details: { name: "" } },
239
+ good: { __id: "good", benchmark_details: { name: "MMLU" } },
240
+ }
241
+ const map = buildMap(cards)
242
+ expect(getBenchmarkCard(map, "MMLU")?.__id).toBe("good")
243
+ })
244
+
245
+ it("a card with multiple distinct candidate keys is reachable via each", () => {
246
+ const cards: Record<string, MinimalCard> = {
247
+ bbh: { __id: "bbh", benchmark_details: { name: "BIG-Bench Hard (BBH)" } },
248
+ }
249
+ const map = buildMap(cards)
250
+ expect(getBenchmarkCard(map, "BIG-Bench Hard (BBH)")?.__id).toBe("bbh")
251
+ expect(getBenchmarkCard(map, "big-bench-hard-(bbh)")?.__id).toBe("bbh")
252
+ expect(getBenchmarkCard(map, "bigbenchhardbbh")?.__id).toBe("bbh")
253
+ })
254
+ })
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Group E β€” attachBenchmarkCardToSummary (3-candidate retry, summary order)
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe("Group E β€” attachBenchmarkCardToSummary (summary path, order: name, name, key)", () => {
261
+ const cards: Record<string, MinimalCard> = {
262
+ mmlu: { __id: "mmlu", benchmark_details: { name: "MMLU" } },
263
+ bbh: { __id: "bbh", benchmark_details: { name: "BIG-Bench Hard (BBH)" } },
264
+ }
265
+ const map = buildMap(cards)
266
+
267
+ it("hits on 1st candidate (evaluation_name)", () => {
268
+ const result = attachBenchmarkCardToSummary(map, {
269
+ evaluation_name: "MMLU",
270
+ composite_benchmark_name: "Some other thing",
271
+ composite_benchmark_key: "another_thing",
272
+ })
273
+ expect(result.benchmark_card?.__id).toBe("mmlu")
274
+ })
275
+
276
+ it("hits on 2nd candidate (composite_benchmark_name) when 1st misses", () => {
277
+ const result = attachBenchmarkCardToSummary(map, {
278
+ evaluation_name: "Accuracy on multi-choice questions",
279
+ composite_benchmark_name: "MMLU",
280
+ composite_benchmark_key: "mmlu",
281
+ })
282
+ expect(result.benchmark_card?.__id).toBe("mmlu")
283
+ })
284
+
285
+ it("hits on 3rd candidate (composite_benchmark_key) when 1st + 2nd miss", () => {
286
+ const result = attachBenchmarkCardToSummary(map, {
287
+ evaluation_name: "Accuracy on multi-choice questions",
288
+ composite_benchmark_name: "Some unindexed display name",
289
+ composite_benchmark_key: "MMLU",
290
+ })
291
+ expect(result.benchmark_card?.__id).toBe("mmlu")
292
+ })
293
+
294
+ it("no match β†’ returns summary unchanged (passthrough)", () => {
295
+ const summary = {
296
+ evaluation_name: "nothing matches",
297
+ composite_benchmark_name: "still nothing",
298
+ composite_benchmark_key: "zilch",
299
+ }
300
+ const result = attachBenchmarkCardToSummary(map, summary)
301
+ expect(result).toBe(summary) // identity β€” same object
302
+ expect(result.benchmark_card).toBeUndefined()
303
+ })
304
+
305
+ it("default-only: pre-attached card is preserved (does NOT overwrite)", () => {
306
+ const preExisting: MinimalCard = { __id: "preExisting", benchmark_details: { name: "preExisting" } }
307
+ const summary = {
308
+ evaluation_name: "MMLU",
309
+ composite_benchmark_name: "MMLU",
310
+ composite_benchmark_key: "mmlu",
311
+ benchmark_card: preExisting,
312
+ }
313
+ const result = attachBenchmarkCardToSummary(map, summary)
314
+ expect(result).toBe(summary)
315
+ expect(result.benchmark_card?.__id).toBe("preExisting")
316
+ })
317
+
318
+ it("falsy benchmark_card (null) falls through to retry", () => {
319
+ const result = attachBenchmarkCardToSummary(map, {
320
+ evaluation_name: "MMLU",
321
+ benchmark_card: null,
322
+ })
323
+ expect(result.benchmark_card?.__id).toBe("mmlu")
324
+ })
325
+
326
+ it("undefined candidate strings are passed through to getBenchmarkCard (which returns null on '')", () => {
327
+ const result = attachBenchmarkCardToSummary(map, {
328
+ // all three undefined
329
+ })
330
+ expect(result.benchmark_card).toBeUndefined()
331
+ })
332
+ })
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Group F β€” attachBenchmarkCardToListItem (3-candidate retry, list path order)
336
+ // ---------------------------------------------------------------------------
337
+
338
+ describe("Group F β€” attachBenchmarkCardToListItem (list path, order: name, key, name)", () => {
339
+ const cards: Record<string, MinimalCard> = {
340
+ mmlu: { __id: "mmlu", benchmark_details: { name: "MMLU" } },
341
+ bbh: { __id: "bbh", benchmark_details: { name: "BIG-Bench Hard (BBH)" } },
342
+ }
343
+ const map = buildMap(cards)
344
+
345
+ it("hits on 1st candidate (evaluation_name)", () => {
346
+ const result = attachBenchmarkCardToListItem(map, {
347
+ evaluation_name: "MMLU",
348
+ composite_benchmark_key: "other_key",
349
+ composite_benchmark_name: "Other display name",
350
+ })
351
+ expect(result.benchmark_card?.__id).toBe("mmlu")
352
+ })
353
+
354
+ it("hits on 2nd candidate (composite_benchmark_KEY in this path, not name)", () => {
355
+ const result = attachBenchmarkCardToListItem(map, {
356
+ evaluation_name: "no match",
357
+ composite_benchmark_key: "MMLU",
358
+ composite_benchmark_name: "Other display",
359
+ })
360
+ expect(result.benchmark_card?.__id).toBe("mmlu")
361
+ })
362
+
363
+ it("hits on 3rd candidate (composite_benchmark_NAME in this path) when 1st + 2nd miss", () => {
364
+ const result = attachBenchmarkCardToListItem(map, {
365
+ evaluation_name: "no match",
366
+ composite_benchmark_key: "no_match_either",
367
+ composite_benchmark_name: "MMLU",
368
+ })
369
+ expect(result.benchmark_card?.__id).toBe("mmlu")
370
+ })
371
+
372
+ it(".filter(Boolean) drops empty/undefined candidates before iteration", () => {
373
+ const item = {
374
+ evaluation_name: "",
375
+ composite_benchmark_key: undefined,
376
+ composite_benchmark_name: "MMLU",
377
+ }
378
+ const result = attachBenchmarkCardToListItem(map, item)
379
+ expect(result.benchmark_card?.__id).toBe("mmlu")
380
+ })
381
+
382
+ it("no match β†’ returns item unchanged", () => {
383
+ const item = {
384
+ evaluation_name: "nothing",
385
+ composite_benchmark_key: "nothing",
386
+ composite_benchmark_name: "nothing",
387
+ }
388
+ const result = attachBenchmarkCardToListItem(map, item)
389
+ expect(result).toBe(item)
390
+ expect(result.benchmark_card).toBeUndefined()
391
+ })
392
+
393
+ it("default-only: pre-attached card preserved", () => {
394
+ const preExisting: MinimalCard = { __id: "preExisting", benchmark_details: { name: "preExisting" } }
395
+ const item = {
396
+ evaluation_name: "MMLU",
397
+ benchmark_card: preExisting,
398
+ }
399
+ const result = attachBenchmarkCardToListItem(map, item)
400
+ expect(result).toBe(item)
401
+ expect(result.benchmark_card?.__id).toBe("preExisting")
402
+ })
403
+ })
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Group G β€” Asymmetric retry order between summary and list paths
407
+ // ---------------------------------------------------------------------------
408
+
409
+ describe("Group G β€” summary vs list path can disagree (TS-as-spec, not a bug)", () => {
410
+ // Construct a map where the 2nd-position candidate in the SUMMARY path
411
+ // (composite_benchmark_name) and the 2nd-position candidate in the LIST
412
+ // path (composite_benchmark_key) point at DIFFERENT cards. The two paths
413
+ // would attach different cards to the same underlying record.
414
+ const cards: Record<string, MinimalCard> = {
415
+ cardForName: { __id: "cardForName", benchmark_details: { name: "Display Name" } },
416
+ cardForKey: { __id: "cardForKey", benchmark_details: { name: "raw_key" } },
417
+ }
418
+ const map = buildMap(cards)
419
+
420
+ const record = {
421
+ evaluation_name: "no first-position match",
422
+ composite_benchmark_name: "Display Name",
423
+ composite_benchmark_key: "raw_key",
424
+ }
425
+
426
+ it("summary path (2nd candidate is composite_benchmark_name) β†’ cardForName", () => {
427
+ const result = attachBenchmarkCardToSummary(map, { ...record })
428
+ expect(result.benchmark_card?.__id).toBe("cardForName")
429
+ })
430
+
431
+ it("list path (2nd candidate is composite_benchmark_key) β†’ cardForKey", () => {
432
+ const result = attachBenchmarkCardToListItem(map, { ...record })
433
+ expect(result.benchmark_card?.__id).toBe("cardForKey")
434
+ })
435
+
436
+ // Migration target: pipeline inlines benchmark_card so this asymmetry
437
+ // becomes unobservable. Until then, document which path produced which
438
+ // value if a bug report comes in.
439
+ })
tests/transformations/benchmark-display-names.test.ts ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ // Executable spec for `notes/transformations/08-benchmark-display-names.md`.
4
+ //
5
+ // Replicates BENCHMARK_NAMES + normalizeBenchmarkKeyForLookup + humanizeToken +
6
+ // getBenchmarkDisplayName from lib/model-data.ts:90-148 verbatim.
7
+ //
8
+ // Also replicates the duplicate getBenchmarkDisplayName from
9
+ // lib/eval-processing.ts:861-885 (Group D β€” functionally dead, tested for
10
+ // completeness so a pipeline implementer porting the rules sees the
11
+ // disagreement explicitly).
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Active implementation β€” lib/model-data.ts
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function humanizeToken(token: string): string {
18
+ return token
19
+ .split(/[_-]+/g)
20
+ .filter(Boolean)
21
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
22
+ .join(" ")
23
+ }
24
+
25
+ const BENCHMARK_NAMES: Record<string, string> = {
26
+ hfopenllm_v2: "HF Open LLM v2",
27
+ helm_lite: "HELM Lite",
28
+ helm_capabilities: "HELM Capabilities",
29
+ helm_classic: "HELM Classic",
30
+ helm_instruct: "HELM Instruct",
31
+ helm_mmlu: "HELM MMLU",
32
+ reward_bench: "RewardBench",
33
+ reward_bench_2: "RewardBench 2",
34
+ bfcl: "BFCL",
35
+ global_mmlu_lite: "Global MMLU Lite",
36
+ swe_bench: "SWE-bench",
37
+ arc_agi: "ARC-AGI",
38
+ tau_bench_2: "TAU-Bench 2",
39
+ ace: "ACE",
40
+ apex_agents: "APEX Agents",
41
+ apex_v1: "APEX v1",
42
+ appworld: "AppWorld",
43
+ browsecompplus: "BrowseComp+",
44
+ livecodebenchpro: "LiveCodeBench Pro",
45
+ sciarena: "SciArena",
46
+ terminal_bench_2_0: "Terminal Bench 2.0",
47
+ la_leaderboard: "LA Leaderboard",
48
+ theory_of_mind: "Theory of Mind",
49
+ fibble_arena: "Fibble Arena",
50
+ fibble1_arena: "Fibble Arena v1",
51
+ fibble2_arena: "Fibble Arena v2",
52
+ fibble3_arena: "Fibble Arena v3",
53
+ fibble4_arena: "Fibble Arena v4",
54
+ fibble5_arena: "Fibble Arena v5",
55
+ wordle_arena: "Wordle Arena",
56
+ }
57
+
58
+ function normalizeBenchmarkKeyForLookup(key: string): string {
59
+ return key.toLowerCase().replace(/[-.\s]+/g, "_").replace(/^_+|_+$/g, "")
60
+ }
61
+
62
+ function getBenchmarkDisplayName(benchmark: string): string {
63
+ return BENCHMARK_NAMES[normalizeBenchmarkKeyForLookup(benchmark)] ?? humanizeToken(benchmark)
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Group A β€” Map hits (normalized-key lookup)
68
+ // ---------------------------------------------------------------------------
69
+
70
+ describe("Group A β€” BENCHMARK_NAMES map hits", () => {
71
+ const cases = [
72
+ // Exact normalized matches
73
+ { input: "hfopenllm_v2", expected: "HF Open LLM v2" },
74
+ { input: "helm_lite", expected: "HELM Lite" },
75
+ { input: "helm_capabilities", expected: "HELM Capabilities" },
76
+ { input: "helm_classic", expected: "HELM Classic" },
77
+ { input: "helm_instruct", expected: "HELM Instruct" },
78
+ { input: "helm_mmlu", expected: "HELM MMLU" },
79
+ { input: "reward_bench", expected: "RewardBench" },
80
+ { input: "reward_bench_2", expected: "RewardBench 2" },
81
+ { input: "bfcl", expected: "BFCL" },
82
+ { input: "global_mmlu_lite", expected: "Global MMLU Lite" },
83
+ { input: "swe_bench", expected: "SWE-bench" },
84
+ { input: "arc_agi", expected: "ARC-AGI" },
85
+ { input: "tau_bench_2", expected: "TAU-Bench 2" },
86
+ { input: "ace", expected: "ACE" },
87
+ { input: "apex_agents", expected: "APEX Agents" },
88
+ { input: "apex_v1", expected: "APEX v1" },
89
+ { input: "appworld", expected: "AppWorld" },
90
+ { input: "browsecompplus", expected: "BrowseComp+" },
91
+ { input: "livecodebenchpro", expected: "LiveCodeBench Pro" },
92
+ { input: "sciarena", expected: "SciArena" },
93
+ { input: "terminal_bench_2_0", expected: "Terminal Bench 2.0" },
94
+ { input: "la_leaderboard", expected: "LA Leaderboard" },
95
+ { input: "theory_of_mind", expected: "Theory of Mind" },
96
+ { input: "fibble_arena", expected: "Fibble Arena" },
97
+ { input: "fibble1_arena", expected: "Fibble Arena v1" },
98
+ { input: "fibble2_arena", expected: "Fibble Arena v2" },
99
+ { input: "fibble3_arena", expected: "Fibble Arena v3" },
100
+ { input: "fibble4_arena", expected: "Fibble Arena v4" },
101
+ { input: "fibble5_arena", expected: "Fibble Arena v5" },
102
+ { input: "wordle_arena", expected: "Wordle Arena" },
103
+
104
+ // Case- and separator-insensitive lookups (normalize: lower; -/./space -> _; trim _)
105
+ { input: "HELM Lite", expected: "HELM Lite", why: "space -> _ during normalize" },
106
+ { input: "helm-lite", expected: "HELM Lite", why: "dash -> _" },
107
+ { input: "helm.lite", expected: "HELM Lite", why: "dot -> _" },
108
+ { input: "HELM-LITE", expected: "HELM Lite", why: "lower + dash -> _" },
109
+ { input: " helm lite ", expected: "HELM Lite", why: "whitespace runs collapse, edges trim" },
110
+ { input: "ARC.AGI", expected: "ARC-AGI", why: "dot -> _" },
111
+ { input: "Reward-Bench-2", expected: "RewardBench 2" },
112
+ ]
113
+ it.each(cases)("'$input' -> '$expected'", ({ input, expected }) => {
114
+ expect(getBenchmarkDisplayName(input)).toBe(expected)
115
+ })
116
+ })
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Group B β€” Tokenize fallback (humanizeToken on the *original* input)
120
+ // ---------------------------------------------------------------------------
121
+
122
+ describe("Group B β€” humanizeToken fallback for non-map inputs", () => {
123
+ const cases = [
124
+ // Single-token acronyms β€” only first char gets uppercased (NOT a real acronym map)
125
+ { input: "bbh", expected: "Bbh", why: "humanizeToken only uppercases first char of each token; map doesn't have 'bbh'" },
126
+ { input: "gpqa", expected: "Gpqa", why: "same β€” visibly wrong but TS-as-spec" },
127
+ { input: "mmlu", expected: "Mmlu", why: "same β€” visibly wrong; the suite-name companion table in benchmark-detail.tsx fixes this, but the active getBenchmarkDisplayName does NOT" },
128
+ { input: "gsm8k", expected: "Gsm8k", why: "digits inside don't capitalize differently" },
129
+ { input: "humaneval", expected: "Humaneval" },
130
+ { input: "truthfulqa", expected: "Truthfulqa" },
131
+
132
+ // Already-uppercase passthrough (charAt(0).toUpperCase() is a no-op on already-upper char)
133
+ { input: "MATH", expected: "MATH", why: "M is already upper; ATH preserved by slice(1)" },
134
+ { input: "MMLU", expected: "MMLU", why: "M upper; MLU preserved" },
135
+ { input: "BBQ", expected: "BBQ" },
136
+ { input: "MMLU-PRO", expected: "MMLU PRO", why: "split on - -> ['MMLU','PRO'] -> first-char-upper (no-op) -> join with space" },
137
+
138
+ // Multi-token snake/dash inputs that miss the map
139
+ { input: "swe-bench-verified", expected: "Swe Bench Verified", why: "split on -, each first-cap" },
140
+ { input: "swe_bench_verified_mini", expected: "Swe Bench Verified Mini" },
141
+ { input: "multi_swe_bench", expected: "Multi Swe Bench" },
142
+ { input: "helm_air_bench", expected: "Helm Air Bench", why: "not in map (only the ~30 listed suite keys are)" },
143
+ { input: "helm_safety", expected: "Helm Safety" },
144
+ { input: "swe_bench_verified", expected: "Swe Bench Verified", why: "swe_bench is in map but swe_bench_verified is not" },
145
+ { input: "cocoabench", expected: "Cocoabench" },
146
+ { input: "llm_stats", expected: "Llm Stats" },
147
+ { input: "artificial_analysis_llms", expected: "Artificial Analysis Llms" },
148
+
149
+ // The fallback uses the *original* (unnormalized) input β€” spaces survive!
150
+ { input: "Helm air bench", expected: "Helm air bench", why: "fallback splits on [_-]+ ONLY; the spaces don't trigger split; first char of the lone token already upper" },
151
+ { input: "helm air bench", expected: "Helm air bench", why: "single token (spaces don't split); lowercase 'h' becomes 'H', rest unchanged" },
152
+ ]
153
+ it.each(cases)("'$input' -> '$expected' ($why)", ({ input, expected }) => {
154
+ expect(getBenchmarkDisplayName(input)).toBe(expected)
155
+ })
156
+ })
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Group C β€” Edge cases
160
+ // ---------------------------------------------------------------------------
161
+
162
+ describe("Group C β€” edge cases", () => {
163
+ it("empty string -> empty string", () => {
164
+ // normalize -> "" (no map hit). humanizeToken: "".split(/[_-]+/) -> [""] -> filter(Boolean) -> [] -> [].join(" ") -> ""
165
+ expect(getBenchmarkDisplayName("")).toBe("")
166
+ })
167
+
168
+ it("single underscore -> empty string", () => {
169
+ // normalize: "_" -> "" (edges stripped). no map hit. humanizeToken: "_".split(/[_-]+/) -> ["",""] -> filter -> [] -> ""
170
+ expect(getBenchmarkDisplayName("_")).toBe("")
171
+ })
172
+
173
+ it("triple-underscore-padded map key collapses to map hit during normalize", () => {
174
+ // normalize: "___helm___lite___" -> lower (no-op) -> internal runs of _ stay (but [-.\s]+ doesn't include _!) -> wait
175
+ // Let's check carefully: normalizeBenchmarkKeyForLookup uses /[-.\s]+/g (NOT _).
176
+ // So "___helm___lite___".replace(/[-.\s]+/g, "_") is unchanged.
177
+ // Then .replace(/^_+|_+$/g, "") strips edge _ runs. Internal "___" stays as-is.
178
+ // Result: "helm___lite" β€” NOT "helm_lite". So this misses the map!
179
+ expect(getBenchmarkDisplayName("___helm___lite___")).toBe("Helm Lite")
180
+ // humanizeToken splits on [_-]+ which collapses the runs: "helm___lite".split(/[_-]+/) -> ["helm","lite"] -> ["Helm","Lite"]
181
+ })
182
+
183
+ it("single char -> uppercased single char via fallback", () => {
184
+ expect(getBenchmarkDisplayName("a")).toBe("A")
185
+ })
186
+
187
+ it("a-b -> 'A B' via fallback", () => {
188
+ expect(getBenchmarkDisplayName("a-b")).toBe("A B")
189
+ })
190
+
191
+ it("lookup is case-insensitive even for substantive transforms", () => {
192
+ expect(getBenchmarkDisplayName("APEX_AGENTS")).toBe("APEX Agents")
193
+ expect(getBenchmarkDisplayName("Browsecompplus")).toBe("BrowseComp+")
194
+ expect(getBenchmarkDisplayName("LIVECODEBENCHPRO")).toBe("LiveCodeBench Pro")
195
+ })
196
+
197
+ it("two-space whitespace collapses for normalize (map lookup)", () => {
198
+ expect(getBenchmarkDisplayName("helm lite")).toBe("HELM Lite")
199
+ })
200
+ })
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Group D β€” Duplicate getBenchmarkDisplayName in lib/eval-processing.ts
204
+ // (functionally dead β€” only called by groupEvaluationsByBenchmark which has
205
+ // no importers. Tested for completeness so the divergence in semantics is
206
+ // explicit.)
207
+ // ---------------------------------------------------------------------------
208
+
209
+ function getBenchmarkDisplayNameDuplicate(name: string | undefined | null): string {
210
+ if (!name) return "Unknown Benchmark"
211
+
212
+ const mapping: Record<string, string> = {
213
+ MMLU: "Massive Multitask Language Understanding",
214
+ "MMLU-Pro": "MMLU Professional",
215
+ GSM8K: "Grade School Math 8K",
216
+ HumanEval: "Human Eval (Code)",
217
+ MBPP: "Mostly Basic Python Problems",
218
+ HellaSwag: "HellaSwag (Commonsense)",
219
+ ARC: "AI2 Reasoning Challenge",
220
+ TruthfulQA: "TruthfulQA",
221
+ BBH: "Big-Bench Hard",
222
+ MATH: "MATH Dataset",
223
+ }
224
+
225
+ for (const [key, value] of Object.entries(mapping)) {
226
+ if (name.toUpperCase().includes(key.toUpperCase())) {
227
+ return value
228
+ }
229
+ }
230
+
231
+ return name
232
+ }
233
+
234
+ describe("Group D β€” duplicate getBenchmarkDisplayName (eval-processing.ts) β€” substring-include rule", () => {
235
+ const cases: Array<{ input: string | null | undefined; expected: string; why?: string }> = [
236
+ { input: null, expected: "Unknown Benchmark", why: "guard: !name" },
237
+ { input: undefined, expected: "Unknown Benchmark", why: "guard" },
238
+ { input: "", expected: "Unknown Benchmark", why: "guard (empty string is falsy)" },
239
+ { input: "MMLU", expected: "Massive Multitask Language Understanding", why: "substring match on MMLU" },
240
+ { input: "mmlu", expected: "Massive Multitask Language Understanding", why: "case-insensitive (toUpperCase)" },
241
+ {
242
+ input: "MMLU-Pro",
243
+ expected: "Massive Multitask Language Understanding",
244
+ why: "iteration order: MMLU is matched first (insertion order); MMLU-Pro entry never reached. KNOWN SOFT-BUG, document don't fix.",
245
+ },
246
+ { input: "GSM8K", expected: "Grade School Math 8K" },
247
+ { input: "HumanEval", expected: "Human Eval (Code)" },
248
+ { input: "MBPP", expected: "Mostly Basic Python Problems" },
249
+ { input: "HellaSwag", expected: "HellaSwag (Commonsense)" },
250
+ { input: "ARC", expected: "AI2 Reasoning Challenge" },
251
+ { input: "TruthfulQA", expected: "TruthfulQA", why: "key === value" },
252
+ { input: "BBH", expected: "Big-Bench Hard" },
253
+ { input: "MATH", expected: "MATH Dataset" },
254
+ { input: "helm_lite", expected: "helm_lite", why: "no substring match -> passthrough" },
255
+ {
256
+ input: "MMLU Lite something",
257
+ expected: "Massive Multitask Language Understanding",
258
+ why: "substring match still fires when the key appears anywhere in the input",
259
+ },
260
+ {
261
+ input: "ARC-AGI",
262
+ expected: "AI2 Reasoning Challenge",
263
+ why: "substring 'ARC' matches; this overwrites the more specific intent of 'ARC-AGI' β€” soft-bug",
264
+ },
265
+ ]
266
+ it.each(cases)("'$input' -> '$expected' ($why)", ({ input, expected }) => {
267
+ expect(getBenchmarkDisplayNameDuplicate(input)).toBe(expected)
268
+ })
269
+ })
tests/transformations/dataset-url-synthesis.test.ts ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ // Executable spec for `notes/transformations/04-dataset-url-synthesis.md`.
4
+ //
5
+ // Replicates the 4-step fallback chain from components/eval-card.tsx:83-86
6
+ // verbatim. Pipeline must produce identical outputs for every case below.
7
+ // Verify cross-corpus equivalence with `scripts/verify-dataset-url.mjs`.
8
+
9
+ interface SourceData {
10
+ dataset_url?: string
11
+ url?: string | string[] | null
12
+ hf_repo?: string
13
+ [key: string]: unknown
14
+ }
15
+
16
+ // Replicates the actual TS expression verbatim. The original uses `??`
17
+ // (nullish coalescing), NOT `||` truthiness β€” so empty strings stay; only
18
+ // null/undefined fall through. Don't "improve" by switching to truthiness.
19
+ function resolveDatasetUrl(sourceData: SourceData | null | undefined): string | undefined {
20
+ const fromDataset = sourceData?.dataset_url
21
+ const fromUrl = Array.isArray(sourceData?.url) ? sourceData?.url?.[0] : sourceData?.url
22
+ const fromHfRepo = sourceData?.hf_repo ? `https://huggingface.co/datasets/${sourceData.hf_repo}` : undefined
23
+ return fromDataset ?? fromUrl ?? fromHfRepo
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Group A β€” Branch firing order (first non-nullish wins, NOT truthy)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ describe("Group A β€” branch firing order", () => {
31
+ const cases = [
32
+ { input: { dataset_url: "https://example.com/x" }, expected: "https://example.com/x", why: "branch 1" },
33
+ { input: { dataset_url: "x", url: ["y"] }, expected: "x", why: "branch 1 short-circuits even with url present" },
34
+ { input: { url: ["https://a.com", "https://b.com"] }, expected: "https://a.com", why: "branch 2 β€” first array element" },
35
+ { input: { url: ["only"] }, expected: "only", why: "branch 2 β€” single-element array" },
36
+ { input: { url: "https://a.com" }, expected: "https://a.com", why: "branch 3 β€” string form" },
37
+ { input: { hf_repo: "Mercor/ACE" }, expected: "https://huggingface.co/datasets/Mercor/ACE", why: "branch 4 β€” HF template" },
38
+ {
39
+ input: { hf_repo: "mercor/apex-agents" },
40
+ expected: "https://huggingface.co/datasets/mercor/apex-agents",
41
+ why: "branch 4 β€” preserves case",
42
+ },
43
+ { input: { dataset_name: "x" }, expected: undefined, why: "branch 5 β€” none of the above" },
44
+ { input: {}, expected: undefined, why: "branch 5 β€” empty object" },
45
+ { input: null, expected: undefined, why: "branch 5 β€” null defensive" },
46
+ { input: undefined, expected: undefined, why: "branch 5 β€” undefined defensive" },
47
+ ]
48
+ it.each(cases)("$why β†’ '$expected'", ({ input, expected }) => {
49
+ expect(resolveDatasetUrl(input as SourceData | null | undefined)).toBe(expected)
50
+ })
51
+ })
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Group B β€” Edge cases of the fallback chain
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe("Group B β€” fallback chain edge cases (?? nullish semantics)", () => {
58
+ it("empty dataset_url string is RETURNED (not nullish, ?? does NOT fall through)", () => {
59
+ // "" is not nullish β€” ?? short-circuits to it. TS quirk to preserve.
60
+ expect(resolveDatasetUrl({ dataset_url: "", url: ["fallback"] })).toBe("")
61
+ })
62
+
63
+ it("empty url array β€” url[0] is undefined, ?? falls through to hf_repo", () => {
64
+ expect(resolveDatasetUrl({ url: [], hf_repo: "x/y" })).toBe("https://huggingface.co/datasets/x/y")
65
+ })
66
+
67
+ it("url array containing only empty string β€” returns empty string (NO further fallback because '' is not nullish)", () => {
68
+ expect(resolveDatasetUrl({ url: [""], hf_repo: "x/y" })).toBe("")
69
+ })
70
+
71
+ it("url array containing only null β€” null IS nullish, ?? falls through to hf_repo", () => {
72
+ expect(resolveDatasetUrl({ url: [null as unknown as string], hf_repo: "x/y" })).toBe(
73
+ "https://huggingface.co/datasets/x/y"
74
+ )
75
+ })
76
+
77
+ it("url array short-circuits hf_repo when first element is truthy", () => {
78
+ expect(resolveDatasetUrl({ url: ["a"], hf_repo: "x/y" })).toBe("a")
79
+ })
80
+
81
+ it("empty hf_repo evaluated as falsy by inline ternary, falls through to undefined", () => {
82
+ // The hf_repo branch uses `sourceData.hf_repo ? template : undefined`,
83
+ // a truthiness check (NOT ??), so empty string is treated as falsy.
84
+ expect(resolveDatasetUrl({ hf_repo: "" })).toBe(undefined)
85
+ })
86
+
87
+ it("hf_repo with leading slash produces double-slash URL (no normalization)", () => {
88
+ expect(resolveDatasetUrl({ hf_repo: "/leading-slash" })).toBe(
89
+ "https://huggingface.co/datasets//leading-slash"
90
+ )
91
+ })
92
+ })
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Group C β€” Production fixtures (real source_data shapes from prod cache)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe("Group C β€” production fixtures", () => {
99
+ const cases = [
100
+ {
101
+ input: { dataset_name: "appworld/test_normal", source_type: "url", url: ["https://github.com/Exgentic/exgentic"] },
102
+ expected: "https://github.com/Exgentic/exgentic",
103
+ why: "url-array path (564/587 eval-details use this)",
104
+ },
105
+ {
106
+ input: { dataset_name: "ace", source_type: "hf_dataset", hf_repo: "Mercor/ACE" },
107
+ expected: "https://huggingface.co/datasets/Mercor/ACE",
108
+ why: "hf_repo template (22/587)",
109
+ },
110
+ {
111
+ input: {
112
+ dataset_name: "Artificial Analysis LLM API",
113
+ source_type: "url",
114
+ url: ["https://artificialanalysis.ai/api/v2/data/llms/models"],
115
+ },
116
+ expected: "https://artificialanalysis.ai/api/v2/data/llms/models",
117
+ why: "third-party API URL",
118
+ },
119
+ {
120
+ input: {
121
+ dataset_name: "CocoaBench v1.0",
122
+ source_type: "other",
123
+ additional_details: { samples_number: "153" },
124
+ },
125
+ expected: undefined,
126
+ why: "no url, no hf_repo, no dataset_url β€” branch 5 (1/587)",
127
+ },
128
+ ]
129
+ it.each(cases)("$why β†’ '$expected'", ({ input, expected }) => {
130
+ expect(resolveDatasetUrl(input as SourceData)).toBe(expected)
131
+ })
132
+ })