j-chim Claude Opus 4.8 (1M context) commited on
Commit
aec0823
·
1 Parent(s): 22c2706

chore: make working notes private (untrack notes/ + docs/INTERPRETIVE_SIGNALS.md)

Browse files

These were committed by accident. Untrack via git rm --cached (local copies kept)
and gitignore notes/ + docs/INTERPRETIVE_SIGNALS.md so they stay private going
forward. History retains the old copies; this only stops future tracking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

.gitignore CHANGED
@@ -49,3 +49,7 @@ whisker-render.mjs
49
  output/
50
  public/peer-ranks.json
51
  **/* (1).tsx
 
 
 
 
 
49
  output/
50
  public/peer-ranks.json
51
  **/* (1).tsx
52
+
53
+ # private working notes / design specs / migration plans — not for publishing
54
+ notes/
55
+ docs/INTERPRETIVE_SIGNALS.md
docs/INTERPRETIVE_SIGNALS.md DELETED
@@ -1,622 +0,0 @@
1
- # EvalCards interpretive signals — frontend implementation spec
2
-
3
- **Status:** implemented reference. Backend producer lives in `eval_card_backend`; field shapes below are stable and covered by that backend's test suite.
4
-
5
- **Companion docs:**
6
- - Spec source of truth: *EvalCards Interpretive Signals v1.0* (Anka Reuel, Stanford). Section refs (§3, §4, …) below point at that doc.
7
- - Backend implementation reference: `eval_card_backend`.
8
-
9
- ---
10
-
11
- ## 0. What this PR does at a glance
12
-
13
- The backend now annotates evaluation records with four interpretive signals:
14
-
15
- 1. **Reproducibility gap** — *per row.* Was the evaluation documented well enough to be re-run? Surfaced as a missing-fields list (e.g. "missing `max_tokens`").
16
- 2. **Reporting completeness** — *per benchmark.* What fraction of EvalCards-required documentation fields are populated? Surfaced as a `[0, 1]` score with a missing-field breakdown.
17
- 3. **Provenance** — *per row.* Who reported this score (first-party / third-party / collaborative / unspecified), and is it the only source for this `(model, benchmark, metric)` group?
18
- 4. **Comparability** — *per `(model, benchmark, metric)` group.* Two flavors: **variant divergence** (same model, same benchmark, different setups → diverging scores) and **cross-party divergence** (different orgs reporting → diverging scores).
19
-
20
- Plus a corpus-level rollup file (`corpus-aggregates.json`) for a stratified analytics page.
21
-
22
- The frontend's job: surface these signals **in three places** — row-level badges, per-eval / per-model summary panels, and a corpus dashboard view.
23
-
24
- ---
25
-
26
- ## 1. Where the new data lives
27
-
28
- All fields are new additions to existing artifacts. No artifact is removed or reshaped.
29
-
30
- | Artifact | New fields |
31
- |---|---|
32
- | `evals/{id}.json` (`HFEvalDetail`) | Per-row `evalcards.annotations` block on every `metrics[].model_results[]` and `subtasks[…].metrics[].model_results[]`. Plus eval-root `evalcards.annotations.reporting_completeness`, `evalcards.annotations.benchmark_comparability`, and three top-level summaries: `reproducibility_summary`, `provenance_summary`, `comparability_summary`. |
33
- | `models/{id}.json` (`HFModelDetail`) | Per-row `evalcards.annotations` block on every `hierarchy_by_category[*][*].metrics[].model_results[]`. Plus three top-level summaries scoped to that model. |
34
- | `eval-list.json` / `eval-list-lite.json` (`HFEvalListEntry`) | Three summaries per entry. |
35
- | `model-cards.json` / `model-cards-lite.json` (`HFModelCardEntry`) | Three summaries per entry. |
36
- | `hierarchy.json` (`EvalHierarchy`) | Composite and benchmark entries carry the three summaries; the frontend adapter synthesizes family rows for listing from the backend's top-level `composites[]` plus flat `families[]` lookup. |
37
- | **`corpus-aggregates.json` (NEW FILE)** | Stratified rollups for paper / dashboard use. |
38
- | `manifest.json` | New entry in `summary_artifacts`: `corpus_aggregates: "corpus-aggregates.json"`. |
39
-
40
- `signal_version` (currently `"1.0"`) is present on every annotation. Treat it as opaque; surface only in admin/debug.
41
-
42
- ---
43
-
44
- ## 2. TypeScript types to add
45
-
46
- Add to `lib/backend-artifacts.ts` (preferred — these are pipeline contract types):
47
-
48
- ```ts
49
- // Spec §3
50
- export interface ReproducibilityGap {
51
- has_reproducibility_gap: boolean
52
- missing_fields: string[] // e.g. ["max_tokens"]
53
- required_field_count: number // 2 base + 2 if agentic on current runtime
54
- populated_field_count: number
55
- signal_version: string
56
- }
57
-
58
- // Spec §5
59
- export type ProvenanceSourceType =
60
- | "first_party"
61
- | "third_party"
62
- | "collaborative"
63
- | "unspecified"
64
-
65
- export interface Provenance {
66
- source_type: ProvenanceSourceType
67
- is_multi_source: boolean
68
- first_party_only: boolean // see §6.1 below for caveat
69
- distinct_reporting_organizations: number
70
- signal_version: string
71
- }
72
-
73
- // Spec §6.1
74
- export interface VariantDivergence {
75
- has_variant_divergence: boolean
76
- group_id: string // "{model_route_id}__{metric_summary_id}"
77
- divergence_magnitude: number
78
- threshold_used: number
79
- threshold_basis:
80
- | "proportion_or_continuous_normalized"
81
- | "percent"
82
- | "range_5pct"
83
- | "fallback_default"
84
- differing_setup_fields: Array<{ field: string; values: unknown[] }>
85
- scores_in_group: number[]
86
- this_triple_score: number | null // this row's score within the group
87
- triple_count_in_group: number
88
- score_scale_anomaly: boolean
89
- group_variant_breakdown: Array<{ variant_key: string; row_count: number }>
90
- signal_version: string
91
- }
92
-
93
- // Spec §6.2
94
- export interface CrossPartyDivergence {
95
- has_cross_party_divergence: boolean
96
- group_id: string
97
- divergence_magnitude: number
98
- threshold_used: number
99
- threshold_basis: VariantDivergence["threshold_basis"]
100
- scores_by_organization: Record<string, number> // display org name → score
101
- differing_setup_fields: Array<{ field: string; values: unknown[] }>
102
- organization_count: number
103
- group_variant_breakdown: Array<{ variant_key: string; row_count: number }>
104
- signal_version: string
105
- }
106
-
107
- // Per-row annotation block (carried on every model_result row)
108
- export interface RowAnnotations {
109
- reproducibility_gap: ReproducibilityGap | null
110
- provenance: Provenance | null
111
- variant_divergence: VariantDivergence | null
112
- cross_party_divergence: CrossPartyDivergence | null
113
- }
114
-
115
- // Spec §4
116
- export interface ReportingCompleteness {
117
- completeness_score: number // [0, 1]
118
- total_fields_evaluated: number
119
- missing_required_fields: string[] // dotted paths
120
- partial_fields: Array<{
121
- field_path: string
122
- score: number // (0, 1) — strictly between
123
- populated_subitems: number
124
- total_subitems: number
125
- }>
126
- field_scores: Array<{
127
- field_path: string
128
- coverage_type: "full" | "partial" | "reserved"
129
- score: number // [0, 1]
130
- }>
131
- signal_version: string
132
- }
133
-
134
- export interface BenchmarkComparability {
135
- variant_divergence_groups: Array<{
136
- group_id: string
137
- model_route_id: string
138
- divergence_magnitude: number
139
- threshold_used: number
140
- threshold_basis: VariantDivergence["threshold_basis"]
141
- differing_setup_fields: VariantDivergence["differing_setup_fields"]
142
- }>
143
- cross_party_divergence_groups: Array<{
144
- group_id: string
145
- model_route_id: string
146
- divergence_magnitude: number
147
- threshold_used: number
148
- threshold_basis: VariantDivergence["threshold_basis"]
149
- scores_by_organization: Record<string, number>
150
- differing_setup_fields: VariantDivergence["differing_setup_fields"]
151
- }>
152
- }
153
-
154
- // Eval-root or model-root annotation block
155
- export interface EvalcardsAnnotations {
156
- reporting_completeness?: ReportingCompleteness
157
- benchmark_comparability?: BenchmarkComparability
158
- }
159
-
160
- // Top-level summary blocks (present on eval-list / model-cards / eval / model / hierarchy nodes)
161
- export interface ReproducibilitySummary {
162
- results_total: number
163
- has_reproducibility_gap_count: number
164
- populated_ratio_avg: number | null // null when results_total == 0
165
- }
166
-
167
- export interface ProvenanceSummary {
168
- total_results: number
169
- total_groups: number
170
- multi_source_groups: number
171
- first_party_only_groups: number
172
- source_type_distribution: Record<ProvenanceSourceType, number>
173
- }
174
-
175
- export interface ComparabilitySummary {
176
- total_groups: number
177
- groups_with_variant_check: number // eligible groups (>=2 rows, differing setups, >=2 scored)
178
- groups_with_cross_party_check: number // eligible groups (>=2 named orgs)
179
- variant_divergent_count: number
180
- cross_party_divergent_count: number
181
- }
182
-
183
- export interface SignalSummaries {
184
- reproducibility_summary?: ReproducibilitySummary
185
- provenance_summary?: ProvenanceSummary
186
- comparability_summary?: ComparabilitySummary
187
- }
188
-
189
- // corpus-aggregates.json
190
- export interface CorpusAggregates {
191
- generated_at: string
192
- signal_version: string
193
- stratification_dimensions: ["category"]
194
- reproducibility: Stratified<ReproducibilityCorpusBlock>
195
- completeness: Stratified<CompletenessCorpusBlock>
196
- provenance: Stratified<ProvenanceCorpusBlock>
197
- comparability: Stratified<ComparabilityCorpusBlock>
198
- }
199
-
200
- export interface Stratified<T> {
201
- overall: T
202
- by_category: Record<string, T> // categories: agentic | general | knowledge | reasoning | safety | other
203
- }
204
-
205
- export interface ReproducibilityCorpusBlock {
206
- total_triples: number
207
- triples_with_reproducibility_gap: number
208
- reproducibility_gap_rate: number | null
209
- agentic_triples: number
210
- per_field_missingness: Record<string, {
211
- missing_count: number
212
- missing_rate: number | null
213
- denominator: "all_triples" | "agentic_only"
214
- denominator_count: number
215
- }>
216
- }
217
-
218
- export interface CompletenessCorpusBlock {
219
- total_benchmarks: number
220
- completeness_score_mean: number | null
221
- completeness_score_median: number | null
222
- per_field_population: Record<string, {
223
- mean_score: number
224
- populated_rate: number
225
- fully_populated_rate: number
226
- benchmark_count: number
227
- }>
228
- }
229
-
230
- export interface ProvenanceCorpusBlock {
231
- total_triples: number
232
- total_groups: number
233
- multi_source_groups: number
234
- multi_source_rate: number | null
235
- first_party_only_groups: number
236
- first_party_only_rate: number | null
237
- source_type_distribution: Record<ProvenanceSourceType, number>
238
- }
239
-
240
- export interface ComparabilityCorpusBlock {
241
- total_groups: number
242
- variant_eligible_groups: number
243
- variant_divergent_groups: number
244
- variant_divergence_rate: number | null
245
- cross_party_eligible_groups: number
246
- cross_party_divergent_groups: number
247
- cross_party_divergence_rate: number | null // commonly null on current corpus
248
- }
249
- ```
250
-
251
- Then in `lib/hf-data.ts`:
252
-
253
- - Extend `HFEvalModelResult` (line ~522) with `evalcards?: { annotations?: RowAnnotations }`.
254
- - Extend `HFEvalDetail` (line ~556) with `evalcards?: { annotations?: EvalcardsAnnotations }` plus the three summary fields from `SignalSummaries`.
255
- - Extend `HFEvalListEntry` (line ~475) with `SignalSummaries` fields.
256
- - Extend `HFModelCardEntry` (line ~439) with `SignalSummaries` fields.
257
- - Extend `HFModelDetail` (line ~571) with `SignalSummaries` fields.
258
- - Extend `HFModelHierarchyMetric` (line ~616) — `model_results` already typed as `HFEvalModelResult`, so the per-row annotations propagate automatically.
259
-
260
- In `EvalHierarchy` types (`lib/backend-artifacts.ts` line ~54), add `SignalSummaries` to both `HierarchyFamily` and `HierarchyBenchmark`.
261
-
262
- All fields are **optional** at the type level — older cached snapshots won't have them, and the frontend should render gracefully when they're absent.
263
-
264
- ---
265
-
266
- ## 3. Data plumbing
267
-
268
- ### 3.1 New fetcher + API route for corpus aggregates
269
-
270
- In `lib/hf-data.ts`, add after the existing fetchers (~line 866):
271
-
272
- ```ts
273
- export async function fetchCorpusAggregates(): Promise<CorpusAggregates | null> {
274
- return fetchHFJsonSafe<CorpusAggregates>("corpus-aggregates.json")
275
- }
276
- ```
277
-
278
- Add to `scripts/cache-hf-data.mjs` `CACHE_ROOT_FILES` array: `"corpus-aggregates.json"`. (Mark it optional in `OPTIONAL_CACHE_ROOT_FILES` if shipping while the HF dataset upload is still rolling — once the backend pipeline next runs against the dataset, the file will appear.)
279
-
280
- Create `app/api/corpus-aggregates/route.ts`:
281
-
282
- ```ts
283
- import { NextResponse } from "next/server"
284
- import { fetchCorpusAggregates } from "@/lib/hf-data"
285
-
286
- export async function GET() {
287
- const aggregates = await fetchCorpusAggregates()
288
- if (!aggregates) {
289
- return NextResponse.json({ error: "Corpus aggregates not available" }, { status: 404 })
290
- }
291
- return NextResponse.json(aggregates)
292
- }
293
- ```
294
-
295
- ### 3.2 Rest of plumbing is automatic
296
-
297
- Existing fetchers (`fetchEvalDetail`, `fetchModelDetail`, `fetchEvalList`, `fetchModelCardsList`, `fetchEvalHierarchy`) just pull the raw JSON, so the new fields propagate without code changes once the types above are widened.
298
-
299
- ---
300
-
301
- ## 4. UX components to build
302
-
303
- Build a small set of reusable signal components in `components/signals/`. Each takes one of the typed shapes above and renders a badge / panel. This keeps signal rendering consistent across `eval-detail.tsx`, `benchmark-detail.tsx`, `model-compare-dialog.tsx`, and the new corpus dashboard.
304
-
305
- ```
306
- components/signals/
307
- ├── reproducibility-badge.tsx
308
- ├── provenance-badge.tsx // already partially exists in benchmark-detail.tsx — see §4.2
309
- ├── variant-divergence-badge.tsx
310
- ├── cross-party-divergence-badge.tsx
311
- ├── reproducibility-panel.tsx // detail view — full missing-fields list
312
- ├── completeness-panel.tsx // detail view — score bar + missing-field list
313
- ├── comparability-panel.tsx // detail view — divergent groups list
314
- ├── signals-row-badges.tsx // composite: renders all four row-level badges with proper spacing
315
- └── signal-tooltip.tsx // shared tooltip primitive
316
- ```
317
-
318
- All badges should follow the existing tone conventions used by `getRelationshipBadgeTone` ([components/benchmark-detail.tsx:289](../components/benchmark-detail.tsx#L289)) and the `Badge` primitive in [components/ui/badge.tsx](../components/ui/badge.tsx).
319
-
320
- ### 4.1 Row-level badges — placement
321
-
322
- Insert `<SignalsRowBadges annotations={modelResult.evalcards?.annotations} />` next to the score cell in:
323
-
324
- - **Eval detail leaderboard table** — [components/eval-detail.tsx:869-871](../components/eval-detail.tsx#L869-L871) (the `<TableCell className="text-right">` containing the score). Render badges below the score on a new line for desktop, hidden on mobile.
325
- - **Benchmark detail rows** — `components/benchmark-detail.tsx` renders score rows in several places (search for `formatRawScoreValue`); insert the same component.
326
- - **Model compare dialog** — [components/model-compare-dialog.tsx](../components/model-compare-dialog.tsx) score columns.
327
-
328
- **Display rules — only badge for actionable states.** Silence is meaningful here.
329
-
330
- | Signal | Show badge when | Hide when |
331
- |---|---|---|
332
- | Reproducibility | `has_reproducibility_gap === true` | gap=false, or annotation absent |
333
- | Provenance | `source_type` ∈ {`first_party`, `third_party`, `collaborative`} | `source_type === "unspecified"` |
334
- | Variant divergence | `variant_divergence !== null && has_variant_divergence === true` | null (not applicable) or false (checked, fine) |
335
- | Cross-party divergence | `cross_party_divergence !== null && has_cross_party_divergence === true` | null (almost always on current corpus) or false |
336
-
337
- `has_*: false` means "we checked and it's fine" — silent success. `null` means "not applicable / not enough data" — also silent. **Only divergent / gap-positive states warrant pixels.**
338
-
339
- **Dedup rule.** `variant_divergence` and `cross_party_divergence` are duplicated onto every row in the same group. If you render three rows from the same `group_id`, render the divergence badge on each row but the *expanded panel* (§4.4) only once at the group header.
340
-
341
- ### 4.2 Provenance badge — reuse what's there
342
-
343
- [components/benchmark-detail.tsx:262-302](../components/benchmark-detail.tsx#L262-L302) already has `getRelationshipShortLabel` and `getRelationshipBadgeTone`. Extract these into `components/signals/provenance-badge.tsx` and import back into `benchmark-detail.tsx`. The new badge should **also** consume the new `Provenance` annotation when present (it carries `is_multi_source` and `first_party_only`, which the current implementation derives row-by-row from `source_metadata` alone).
344
-
345
- When `provenance.first_party_only === true`, show a small ⚠ subtle indicator on the first-party badge ("first-party only — no independent replication"). This is the headline use of the signal for policy-mode readers.
346
-
347
- ### 4.3 Reproducibility badge — content rules
348
-
349
- Tooltip content depends on audience mode (`useAudienceMode()` from [components/audience-mode-provider.tsx:40](../components/audience-mode-provider.tsx#L40)):
350
-
351
- - Research mode: "Setup not fully documented. Missing: `max_tokens`, `eval_plan`."
352
- - Policy mode: "This score's setup isn't fully documented, so it can't be re-run as-is."
353
-
354
- Always include the count "{populated_field_count} of {required_field_count} setup fields recorded." Don't hardcode "4 fields" — the active runtime checks 2 base fields (`temperature`, `max_tokens`) plus 2 agentic fields (`eval_plan`, `eval_limits`) when the benchmark is agentic. Read counts off the annotation.
355
-
356
- ### 4.4 Detail panels — placement
357
-
358
- #### Reproducibility panel
359
- The existing "Evaluation Provenance" panel in [components/eval-detail.tsx:952-998](../components/eval-detail.tsx#L952-L998) (rendered when a row is expanded) is the right place for the **per-row** reproducibility breakdown. Add a new `DetailPanel` adjacent to it:
360
-
361
- ```tsx
362
- {rowAnnotations?.reproducibility_gap && (
363
- <DetailPanel
364
- title={isResearchView ? "Reproducibility" : "Re-runnability"}
365
- subtitle={
366
- isResearchView
367
- ? "Whether the setup is documented well enough for someone else to re-run."
368
- : "Whether someone could re-run this evaluation with the information available."
369
- }
370
- >
371
- <MetaRow
372
- label="Setup fields recorded"
373
- value={`${rowAnnotations.reproducibility_gap.populated_field_count} of ${rowAnnotations.reproducibility_gap.required_field_count}`}
374
- />
375
- {rowAnnotations.reproducibility_gap.missing_fields.length > 0 && (
376
- <MetaRow
377
- label="Missing"
378
- value={rowAnnotations.reproducibility_gap.missing_fields.join(", ")}
379
- />
380
- )}
381
- </DetailPanel>
382
- )}
383
- ```
384
-
385
- #### Completeness panel
386
- Render at the **eval-detail header level** (above the leaderboard, below the metric specification card). New `<CompletenessPanel completeness={detail.evalcards?.annotations?.reporting_completeness} />`. UI: progress bar showing `completeness_score`, label "{N} of {M} fields populated" where N = sum of `field_scores[].score` rounded, M = `total_fields_evaluated`. Below: collapsible accordions:
387
-
388
- - **Missing required fields** (count badge) — list of `missing_required_fields` with friendly labels (see §6.4 for label mapping).
389
- - **Partially populated** (count badge) — `partial_fields` rendered as "{field}: {populated_subitems}/{total_subitems}".
390
-
391
- In policy mode, don't show the dotted-path field names — show friendly labels only. In research mode, show both.
392
-
393
- #### Comparability panel
394
- Also at eval-detail header level. Sourced from `detail.evalcards?.annotations?.benchmark_comparability`. Render as two collapsibles — "Variant divergence ({count})" and "Cross-party divergence ({count})". Each item should link to the relevant model row (use `model_route_id` from each group entry as anchor — add `id={"row-" + model_route_id}` on the leaderboard row).
395
-
396
- When both arrays are empty, hide the panel entirely. When `comparability_summary.groups_with_cross_party_check === 0` (the common state), surface a small note: "No third-party reports available for cross-party comparison."
397
-
398
- ### 4.5 Per-eval header chips
399
- On the eval-detail page header (next to existing "Measures" / "Source dataset" chips around [components/eval-detail.tsx:486-525](../components/eval-detail.tsx#L486-L525)), add a fourth chip when `evalcards.annotations.reporting_completeness` is present:
400
-
401
- > **Documentation**
402
- > {round(completeness_score * 100)}%
403
-
404
- Tooltip: "{N} of {M} EvalCards documentation fields populated for this benchmark."
405
-
406
- ### 4.6 Per-model card chips
407
- On `components/eval-card.tsx` and the model card pages, add three chips driven by the model-level summaries. Replace the hand-written hint at [components/eval-card.tsx:250](../components/eval-card.tsx#L250) ("Some results lack generation settings; compare scores with care.") with a data-driven version:
408
-
409
- > {has_reproducibility_gap_count} of {results_total} reported scores aren't fully documented.
410
-
411
- Show only when `has_reproducibility_gap_count > 0`. The hand-written hint was a placeholder for exactly this signal — wire it up.
412
-
413
- ---
414
-
415
- ## 5. New page: corpus dashboard
416
-
417
- Add `app/corpus/page.tsx` (linked from main navigation [components/navigation.tsx](../components/navigation.tsx)). Server component that calls `fetchCorpusAggregates()` and renders four sections:
418
-
419
- ### 5.1 Reproducibility section
420
- - Headline number: `reproducibility_gap_rate` rendered as percentage. Sub-label: "{triples_with_reproducibility_gap} of {total_triples} reported scores."
421
- - Per-field horizontal bar chart from `per_field_missingness`. **Bar denominator depends on `denominator` field**: agentic-only fields use `agentic_triples`, others use `total_triples`. Label each bar with the denominator type so users understand.
422
- - Toggle: `overall` ↔ `by_category` (rendered as a small-multiple grid, one panel per category).
423
-
424
- ### 5.2 Completeness section
425
- - Headline: `completeness_score_mean` (and median) across `total_benchmarks`.
426
- - Histogram of per-benchmark scores (pull individual benchmark scores from `eval-list.json` `reporting_completeness.completeness_score`, since corpus-aggregates only carries mean/median).
427
- - Per-field bar chart from `per_field_population` — three bars per field: `mean_score`, `populated_rate`, `fully_populated_rate`. (See §6.7 for which one to highlight per coverage type.)
428
-
429
- ### 5.3 Provenance section
430
- - Stacked bar of `source_type_distribution` (across all triples).
431
- - Two ratios: `multi_source_rate`, `first_party_only_rate`. Label both: "% of (model, benchmark, metric) groups."
432
-
433
- ### 5.4 Comparability section
434
- - Two side-by-side panels: Variant divergence (eligible-aware rate) and Cross-party divergence (often null).
435
- - **When `cross_party_divergence_rate === null`:** show a "Not enough multi-org coverage to compute" empty state, not "0%". Same for `variant_divergence_rate === null`. This is critical — see §6.5.
436
-
437
- All sections support a category toggle (research mode shows category breakdowns by default; policy mode shows overall by default).
438
-
439
- ---
440
-
441
- ## 6. Caveats and edge cases (read these before implementing)
442
-
443
- ### 6.1 `first_party_only` semantics
444
- A row can be `first_party_only: true` even when `is_multi_source: false`. The spec literal: a group with one *named* org reporting first-party gets the badge. **Don't read it as "exclusive coverage"** — read it as "no independent replication." The label suggestion is "First-party only" rather than "Sole source."
445
-
446
- If `distinct_reporting_organizations === 0` (all rows have null org), `first_party_only` is `false` even when `source_type === "first_party"`. Render the row's source as "First-party (org unspecified)" in research mode; suppress the first-party-only badge.
447
-
448
- ### 6.2 Active reproducibility field set is reduced
449
- The spec describes four base fields (`temperature`, `top_p`, `max_tokens`, `prompt_template`); the active backend currently checks **only `temperature` and `max_tokens`** plus `eval_plan` / `eval_limits` for agentic benchmarks. **Don't hardcode "4 fields" anywhere.** Always read `required_field_count` off the annotation. This is a deliberate spec-author choice and may revert; the field count is the only stable interface.
450
-
451
- ### 6.3 Missing-field path strings
452
- `missing_fields` for reproducibility uses bare names (e.g. `"max_tokens"`). `missing_required_fields` for completeness uses dotted paths (e.g. `"autobenchmarkcard.methodology.baseline_results"`). Different conventions, intentional. Build a small label map for completeness paths — paths come from `eval_card_backend/src/eval_card_backend/registry/completeness_fields.json`. Suggested label rules:
453
-
454
- - Drop the `autobenchmarkcard.` / `eee_eval.` / `evalcards.` prefix.
455
- - Replace dots with " / ", underscore with space, title-case.
456
- - Example: `autobenchmarkcard.methodology.baseline_results` → "Methodology / Baseline results".
457
-
458
- ### 6.4 `differing_setup_fields[].values` may contain null and mixed types
459
- Per spec §6.1.4, `null` is a *distinct* value from any explicit setting (comparing "explicit 2048" to "unspecified" is meaningful). Render `null` as "(unspecified)" rather than the string "null". Numeric, string, boolean, and object values can all appear in the same array; render with `JSON.stringify` for objects, plain text otherwise.
460
-
461
- ### 6.5 `null` rates in comparability are *not* zero
462
- Eligibility-aware denominators mean `variant_divergence_rate` and `cross_party_divergence_rate` are `null` when no groups were eligible. **Render as "N/A — not enough data" or an empty-state card, never as "0%".** On the current corpus, `cross_party_divergence_rate` will commonly be null (third-party reports are sparse). Treat this as a normal state, not a data-loading error.
463
-
464
- ### 6.6 Score-scale anomaly flag
465
- `variant_divergence.score_scale_anomaly === true` indicates the metric was declared `proportion` but scores fell outside [0, 1] — usually a metric-normalization bug upstream. Surface as a small "data quality warning" annotation alongside the divergence number; the divergence is still computed but the threshold may not be apples-to-apples.
466
-
467
- ### 6.7 `mean_score` vs `populated_rate` for completeness
468
- Per-field aggregates expose three numbers. Pick which to display based on `coverage_type`:
469
-
470
- - **`full` and `reserved` fields** — `mean_score` and `populated_rate` are equal. Show one number labeled "% of benchmarks populating this field."
471
- - **`partial` fields** — they diverge. `populated_rate` = % of benchmarks with *any* sub-item; `mean_score` = average sub-item population fraction. Show both: "{populated_rate}% have any data, {mean_score}% on average across sub-items."
472
-
473
- ### 6.8 No `computed_at` on per-record annotations
474
- Only `signal_version` is on each annotation. For "last computed" UI text, use `manifest.json → generated_at` from the existing `BackendManifest`.
475
-
476
- ### 6.9 Stratification categories
477
- `by_category` keys are: `agentic`, `general`, `knowledge`, `reasoning`, `safety`, `other`. Same set as the existing `category` field on evals — reuse whatever color scheme is currently keyed off `inferCategoryFromBenchmark` ([lib/benchmark-schema.ts](../lib/benchmark-schema.ts)).
478
-
479
- ### 6.10 Annotation block can be `null` or absent
480
- `evalcards.annotations.{reproducibility_gap,provenance,variant_divergence,cross_party_divergence}` can each be `null` independently, and the entire `evalcards` block may be absent on older cached snapshots. Use optional chaining everywhere; never assume presence. The `RowAnnotations` type intentionally types each subfield as `T | null` (not `T | undefined`) because the backend writes explicit `null`.
481
-
482
- ---
483
-
484
- ## 7. Suggested implementation order
485
-
486
- 1. **Types + plumbing** (1–2 hours): types in `backend-artifacts.ts` + `hf-data.ts`, the `fetchCorpusAggregates` fetcher, the API route, and adding `corpus-aggregates.json` to the cache script. No UI yet.
487
- 2. **Row-level badges** (½ day): build `signals/` directory with the four badge components, the dedup-aware `signals-row-badges.tsx`, and wire into eval-detail and benchmark-detail. This is the most visible win.
488
- 3. **Per-eval completeness panel + comparability panel** (½ day): single benchmark, easy to design around. New `CompletenessPanel` is the headline new UX in this set.
489
- 4. **Per-row reproducibility detail panel** (1–2 hours): drops into the existing expanded row layout.
490
- 5. **Per-eval / per-model header chips + replace the hand-written gap hint** (1–2 hours): wires the summary fields into existing card surfaces.
491
- 6. **Corpus dashboard page** (1–2 days): new route, new components, biggest scope. Defer until 1–5 are live and reviewed.
492
-
493
- Each step is independently shippable. Steps 1–5 can land before the corpus dashboard is designed.
494
-
495
- ---
496
-
497
- ## 8. Out of scope (don't do these yet)
498
-
499
- - **Filter / sort the eval list by signal state** ("show only benchmarks with completeness > 0.5"). Wait for the dashboard view to land first; users will tell us which filters they actually want.
500
- - **Side-by-side score comparison with divergence overlay.** The data supports it (`scores_in_group`, `scores_by_organization`) but the design space is large. Hold off until we see the row-level badges in use.
501
- - **Recompute / verification UI for missing reproducibility fields.** Backend-side; out of scope here.
502
- - **Per-instance sample-level badges.** Signals operate at row / benchmark level; sample-level instance data is unaffected.
503
-
504
- ---
505
-
506
- ## 9. Reference: minimal real-shape examples
507
-
508
- Per-row `evalcards.annotations` with all four signals populated:
509
-
510
- ```jsonc
511
- {
512
- "reproducibility_gap": {
513
- "has_reproducibility_gap": true,
514
- "missing_fields": ["max_tokens"],
515
- "required_field_count": 2,
516
- "populated_field_count": 1,
517
- "signal_version": "1.0"
518
- },
519
- "provenance": {
520
- "source_type": "first_party",
521
- "is_multi_source": false,
522
- "first_party_only": true,
523
- "distinct_reporting_organizations": 1,
524
- "signal_version": "1.0"
525
- },
526
- "variant_divergence": null,
527
- "cross_party_divergence": null
528
- }
529
- ```
530
-
531
- Per-eval `evalcards.annotations` with completeness + comparability:
532
-
533
- ```jsonc
534
- {
535
- "reporting_completeness": {
536
- "completeness_score": 0.62,
537
- "total_fields_evaluated": 28,
538
- "missing_required_fields": [
539
- "autobenchmarkcard.methodology.baseline_results",
540
- "autobenchmarkcard.methodology.validation",
541
- "evalcards.preregistration_url"
542
- ],
543
- "partial_fields": [
544
- { "field_path": "autobenchmarkcard.data", "score": 0.5, "populated_subitems": 2, "total_subitems": 4 }
545
- ],
546
- "field_scores": [/* 28 entries */],
547
- "signal_version": "1.0"
548
- },
549
- "benchmark_comparability": {
550
- "variant_divergence_groups": [
551
- {
552
- "group_id": "openai__gpt-5__hfopenllm_v2_bbh_accuracy",
553
- "model_route_id": "openai__gpt-5",
554
- "divergence_magnitude": 0.12,
555
- "threshold_used": 0.05,
556
- "threshold_basis": "proportion_or_continuous_normalized",
557
- "differing_setup_fields": [
558
- { "field": "max_tokens", "values": [2048, 4096, 8192] }
559
- ]
560
- }
561
- ],
562
- "cross_party_divergence_groups": []
563
- }
564
- }
565
- ```
566
-
567
- Top-level `provenance_summary` example:
568
-
569
- ```jsonc
570
- {
571
- "total_results": 142,
572
- "total_groups": 47,
573
- "multi_source_groups": 3,
574
- "first_party_only_groups": 30,
575
- "source_type_distribution": {
576
- "first_party": 120,
577
- "third_party": 18,
578
- "collaborative": 0,
579
- "unspecified": 4
580
- }
581
- }
582
- ```
583
-
584
- `corpus-aggregates.json` structure (top of file):
585
-
586
- ```jsonc
587
- {
588
- "generated_at": "2026-04-27T...",
589
- "signal_version": "1.0",
590
- "stratification_dimensions": ["category"],
591
- "reproducibility": { "overall": {/* ReproducibilityCorpusBlock */}, "by_category": { "agentic": {...}, "general": {...}, ... } },
592
- "completeness": { "overall": {/* CompletenessCorpusBlock */}, "by_category": {...} },
593
- "provenance": { "overall": {/* ProvenanceCorpusBlock */}, "by_category": {...} },
594
- "comparability": { "overall": {/* ComparabilityCorpusBlock */}, "by_category": {...} }
595
- }
596
- ```
597
-
598
- ---
599
-
600
- ## 10. Audience-mode wording cheatsheet
601
-
602
- | Element | Research mode | Policy mode |
603
- |---|---|---|
604
- | Reproducibility gap badge | "Reproducibility gap" | "Setup not documented" |
605
- | Reproducibility tooltip | "Setup not fully documented. Missing: {fields}." | "This score's setup isn't documented, so it can't be re-run as-is." |
606
- | Reproducibility panel title | "Reproducibility" | "Re-runnability" |
607
- | Completeness chip label | "Documentation" | "Documentation" |
608
- | Completeness panel title | "Reporting completeness" | "How well is this benchmark documented?" |
609
- | Provenance: first-party | "1st party" | "Reported by model developer" |
610
- | Provenance: first-party only | "1st party only — no replication" | "Only the model developer reported this score" |
611
- | Provenance: third-party | "3rd party" | "Independently reported" |
612
- | Provenance: collaborative | "Collaborative" | "Joint report" |
613
- | Variant divergence badge | "Variant divergence" | "Score depends on setup" |
614
- | Variant divergence tooltip | "Scores diverge by {magnitude} across different setups: {fields}." | "Different runs of this evaluation produced different scores — the setup matters." |
615
- | Cross-party divergence badge | "Cross-party divergence" | "Sources disagree" |
616
- | Cross-party divergence tooltip | "Reports diverge by {magnitude} across organizations." | "Different organizations reported different scores for this same model on this same benchmark." |
617
-
618
- Adjust tone but keep the underlying numbers identical across modes — the data is the same, only the framing changes.
619
-
620
- ---
621
-
622
- *Last updated 2026-05-04. Maintainer: backend (`eval_card_backend`), frontend (`general-eval-card`). Questions on UX → discuss with @anka-evals + frontend team.*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
notes/backend-v2-migration.md DELETED
@@ -1,664 +0,0 @@
1
- # Frontend migration to backend v2 (Stage J view layer)
2
-
3
- > **Status:** spec, drafted 2026-05-03 against `eval_card_backend`'s
4
- > Stage J view-layer contract.
5
- >
6
- > **Sources:**
7
- > - Backend spec (the contract this consumes):
8
- > `../eval_card_backend/notes/08-frontend-view-layer.md`
9
- > - Canonical schema (audit/debug only; not in hot path):
10
- > `../eval_card_backend/notes/01-schema-from-frontend.md`
11
-
12
- ---
13
-
14
- ## Context
15
-
16
- The legacy producer (`eval_cards_backend_pipeline`) emitted ten
17
- parquets where each row carried a `payload_json` VARCHAR with the
18
- post-TS-adapter shape baked in. The frontend's "DuckDB backend"
19
- (`lib/duckdb-data.ts`) read these blobs and `JSON.parse`d them — column
20
- projection, filter pushdown, and type contracts were all forfeited.
21
-
22
- The new producer (`eval_card_backend`) emits a typed view layer over
23
- its canonical normalised tables. Three Parquet files cover every page
24
- shape, three small JSON sidecars cover corpus-level scalars and the
25
- hierarchy tree. Column names match the frontend's TS interfaces
26
- field-for-field, so the row→object cast is a typed spread for most
27
- accessors. Two interfaces (`ModelResultForBenchmark` and the
28
- `evaluations_by_category` body of `ModelEvaluationSummary`) require a
29
- small mechanical reshape over the row, since one nests fields that the
30
- view stores flat — see the per-accessor sections below. No
31
- HF-record-to-display adapter logic survives.
32
-
33
- This document specifies what changes in `general-eval-card` once
34
- backend v2 is faithfully implemented. **The visual frontend, page
35
- renderers, and TS interface shapes do not change.** Only the I/O
36
- boundary moves.
37
-
38
- ---
39
-
40
- ## What changes (overview)
41
-
42
- | layer | before (v1) | after (v2) |
43
- |---|---|---|
44
- | Distribution | `LOCAL_PIPELINE_OUTPUT` env var pointing at a producer output dir; `duckdb/v1/` subpath; implicit "warehouse/latest/" coupling | `SNAPSHOT_URL` env var (file:// or HF dataset URL); one snapshot pinned per deploy |
45
- | Storage shape | 10 parquets each with one `payload_json` column | 3 typed-column view parquets + 3 JSON sidecars |
46
- | Read pattern | `SELECT payload_json FROM read_parquet(?) WHERE id = ?`, then `JSON.parse` | `SELECT col1, col2, ... FROM <view> WHERE id = ?`, typed row spread |
47
- | List vs detail | Separate `*_lite.parquet` files | Column projection on the same parquet |
48
- | Suite/aggregate dispatch | Eval id prefix (`aggregate__`, `matrix__`) → different parquet | `is_summary_score` flag + `parent_benchmark_id` on `evals_view` |
49
- | Slug rule | Custom `replace('/', '__')` escapes; per-page slug helpers | Producer-owned RFC 3986 percent-encoded `route_id` / `evaluation_id` / `metric_summary_id`; frontend decodes only on `<Link>` href |
50
- | Corpus aggregates | `corpus-aggregates.json` over HF JSON loader | `headline.json` sidecar in the snapshot dir |
51
- | Hierarchy | Synthesised in the producer's `eval_hierarchy` JSON | `hierarchy.json` sidecar |
52
- | Backend manifest | `manifest.json` fetched from upstream HF dataset root via `lib/hf-data.ts` | `manifest.json` sidecar inside the snapshot dir, read via `SNAPSHOT_URL` |
53
-
54
- The TS interfaces (`EvaluationCardData`, `BenchmarkEvalSummary`,
55
- `ModelEvaluationSummary`, `ModelResultForBenchmark`, `CorpusAggregates`,
56
- `EvalHierarchy`, `BackendManifest`) stay as-is — the producer agreed to
57
- emit columns under those exact names.
58
-
59
- ---
60
-
61
- ## What does not change
62
-
63
- - All page components under `app/`. The renderer trees are unchanged.
64
- - TS interface declarations in `lib/benchmark-schema.ts`,
65
- `lib/eval-processing.ts`, `lib/backend-artifacts.ts`. These are now
66
- the contract surface — column names match field names by agreement
67
- with the producer.
68
- - Component files under `components/`.
69
- - `lib/glossary.ts`, `lib/known-issues.ts`, `lib/utils.ts`,
70
- `lib/na-utils.ts` — these are pure presentation helpers.
71
- - `app/api/*/route.ts` handlers stay as thin pass-throughs to
72
- `lib/data-backend.ts`.
73
-
74
- ---
75
-
76
- ## Distribution: `SNAPSHOT_URL`
77
-
78
- Frontend reads `SNAPSHOT_URL` from env at process start. One deploy =
79
- one snapshot. The URL points at a directory containing the six
80
- artifacts the frontend reads:
81
-
82
- ```
83
- $SNAPSHOT_URL/
84
- ├── models_view.parquet
85
- ├── evals_view.parquet
86
- ├── eval_results_view.parquet
87
- ├── headline.json
88
- ├── hierarchy.json
89
- └── manifest.json
90
- ```
91
-
92
- Examples:
93
-
94
- - Local dev: `SNAPSHOT_URL=file:///path/to/eval_card_backend/warehouse/2026-05-03T15-48-59Z`
95
- - Production (pinned snapshot): `SNAPSHOT_URL=https://huggingface.co/datasets/evaleval/eval-cards-data/resolve/<rev>/warehouse/<snapshot_id>`
96
- - Production (rolling): `SNAPSHOT_URL=https://huggingface.co/datasets/evaleval/eval-cards-data/resolve/main/warehouse/latest`
97
-
98
- `LOCAL_PIPELINE_OUTPUT` is removed. The `duckdb/v1/` subpath is
99
- removed. The producer maintains a `warehouse/latest/` alias that
100
- points at the most recent snapshot, so deploys can pin either to a
101
- timestamped snapshot (immutable, redeploy required to roll forward)
102
- or to `latest` (auto-rolls forward on the next Space rebuild). Within
103
- a running process the snapshot is still effectively constant — sidecar
104
- caches in `lib/sidecars.ts` are first-write-wins per process.
105
-
106
- ---
107
-
108
- ## DuckDB connection lifecycle
109
-
110
- `lib/duckdb.ts` (new file; replaces the connection-management portion
111
- of `lib/duckdb-data.ts`):
112
-
113
- ```ts
114
- import "server-only"
115
- import { DuckDBConnection } from "@duckdb/node-api"
116
-
117
- let connectionPromise: Promise<DuckDBConnection> | null = null
118
-
119
- const SNAPSHOT_URL = process.env.SNAPSHOT_URL
120
- if (!SNAPSHOT_URL) {
121
- throw new Error("SNAPSHOT_URL must be set; see notes/backend-v2-migration.md")
122
- }
123
-
124
- const VIEWS = {
125
- models_view: `${SNAPSHOT_URL}/models_view.parquet`,
126
- evals_view: `${SNAPSHOT_URL}/evals_view.parquet`,
127
- eval_results_view: `${SNAPSHOT_URL}/eval_results_view.parquet`,
128
- } as const
129
-
130
- export async function getConnection(): Promise<DuckDBConnection> {
131
- if (!connectionPromise) {
132
- connectionPromise = (async () => {
133
- const conn = await DuckDBConnection.create()
134
- // httpfs is built into duckdb-node-api; no INSTALL needed.
135
- // Register each parquet as a view so callers write `FROM models_view`,
136
- // not the full URL.
137
- for (const [name, path] of Object.entries(VIEWS)) {
138
- await conn.run(
139
- `CREATE OR REPLACE VIEW ${name} AS SELECT * FROM read_parquet(?)`,
140
- [path]
141
- )
142
- }
143
- return conn
144
- })()
145
- }
146
- return connectionPromise
147
- }
148
- ```
149
-
150
- One connection per Node process. Views are registered once at
151
- startup; subsequent queries write `FROM models_view` rather than
152
- re-passing the parquet URL. DuckDB's column projection means the cost
153
- of `SELECT route_id, model_name FROM models_view` is independent of
154
- how wide `models_view` is.
155
-
156
- ---
157
-
158
- ## Per-accessor mapping
159
-
160
- `lib/data-backend.ts` keeps its current export names. `lib/duckdb-data.ts`
161
- gets gutted; each function becomes a thin typed `SELECT`. The mapping
162
- below uses the column names spec'd in
163
- `../eval_card_backend/notes/08-frontend-view-layer.md` — the row
164
- returned by DuckDB casts directly to the TS interface.
165
-
166
- ### Models
167
-
168
- ```ts
169
- // getModelCards / getModelCardsLite — list pages
170
- export async function getModelCards(): Promise<EvaluationCardData[]> {
171
- const conn = await getConnection()
172
- const reader = await conn.runAndReadAll(`
173
- SELECT id, route_id, model_name, model_id, canonical_model_name, developer,
174
- evaluations_count, benchmarks_count, variant_count,
175
- categories, category_stats, latest_timestamp,
176
- evaluator_count, evaluator_names, source_type_count, source_types,
177
- evidence_count, missing_generation_config_count,
178
- third_party_eval_count, independent_verification_ratio,
179
- reproducibility_status, eval_libraries, latest_source_name,
180
- params_billions, benchmark_names, score_summary,
181
- reproducibility_summary, provenance_summary, comparability_summary,
182
- top_scores, source_urls, detail_urls,
183
- model_url, release_date, input_modalities, output_modalities,
184
- architecture, params, inference_engine, inference_platform
185
- FROM models_view
186
- ORDER BY latest_timestamp DESC
187
- `)
188
- return reader.getRowObjects() as EvaluationCardData[]
189
- }
190
-
191
- // "Lite" is just narrower projection — same parquet, fewer columns.
192
- export async function getModelCardsLite(): Promise<EvaluationCardData[]> {
193
- const conn = await getConnection()
194
- const reader = await conn.runAndReadAll(`
195
- SELECT id, route_id, model_name, model_id, developer,
196
- evaluations_count, benchmarks_count, categories,
197
- latest_timestamp, third_party_eval_count,
198
- independent_verification_ratio, reproducibility_status,
199
- latest_source_name, params_billions
200
- FROM models_view
201
- ORDER BY benchmarks_count DESC, evaluations_count DESC, model_name ASC
202
- `)
203
- return reader.getRowObjects() as EvaluationCardData[]
204
- }
205
-
206
- // getModelSummaryById — detail page.
207
- //
208
- // The row carries the metadata shell (variants[], categories,
209
- // category_stats, signal summaries, model_family_id, raw_model_ids,
210
- // total_evaluations, last_updated). The full `ModelEvaluationSummary`
211
- // also requires `evaluations_by_category: Record<CategoryType,
212
- // BenchmarkEvaluation[]>`, which is a heavyweight per-cell breakdown —
213
- // produced by a separate join over `eval_results_view`, see
214
- // `getModelEvaluationCells` below.
215
- //
216
- // Returning a `ModelSummaryShell` (Omit-ed type, defined alongside the
217
- // existing TS interface) makes the contract explicit and stops the cast
218
- // from lying. The model-detail page composes the full
219
- // `ModelEvaluationSummary` from `shell` + `cells`.
220
- export type ModelSummaryShell = Omit<
221
- ModelEvaluationSummary,
222
- "evaluations_by_category"
223
- >
224
-
225
- export async function getModelSummaryById(routeId: string): Promise<ModelSummaryShell | null> {
226
- const conn = await getConnection()
227
- const reader = await conn.runAndReadAll(
228
- `SELECT * FROM models_view WHERE route_id = ? OR model_family_id = ? LIMIT 1`,
229
- [routeId, routeId]
230
- )
231
- const rows = reader.getRowObjects()
232
- if (rows.length === 0) return null
233
- return rows[0] as unknown as ModelSummaryShell
234
- }
235
-
236
- // Per-cell reshape helper. `eval_results_view` rows carry the per-cell
237
- // fields scattered (model_info, score_details, evaluation_timestamp,
238
- // source_metadata, source_data, metric_*, etc.) rather than under a
239
- // nested `result: EvaluationResult` STRUCT. Reshape into the
240
- // `ModelResultForBenchmark` shape the leaderboard / model-detail
241
- // renderers expect. Single helper; reused by getEvalSummaryById and
242
- // getModelEvaluationCells. No HF-record-to-display logic survives.
243
- function reshapeCellToModelResult(row: Record<string, any>): ModelResultForBenchmark {
244
- return {
245
- model_info: row.model_info,
246
- model_route_id: row.model_route_id,
247
- score: row.score,
248
- score_details: row.score_details,
249
- evaluation_timestamp: row.evaluation_timestamp,
250
- source_metadata: row.source_metadata,
251
- source_data: row.source_data,
252
- source_record_url: row.source_record_url,
253
- aggregate_components: row.aggregate_components,
254
- result: {
255
- evaluation_name: row.metric_display_name,
256
- metric_summary_id: row.metric_summary_id,
257
- metric_key: row.metric_id,
258
- evaluation_timestamp: row.evaluation_timestamp,
259
- metric_config: { lower_is_better: row.lower_is_better, unit: row.metric_unit, /* …denormalised meta… */ },
260
- score_details: row.score_details,
261
- evalcards: row.evalcards_annotations ? { annotations: row.evalcards_annotations } : undefined,
262
- },
263
- }
264
- }
265
-
266
- // Helper for the model-detail page's evaluations_by_category body.
267
- // The page groups by `category` in TS after this returns.
268
- export async function getModelEvaluationCells(modelId: string): Promise<ModelResultForBenchmark[]> {
269
- const conn = await getConnection()
270
- const reader = await conn.runAndReadAll(
271
- `SELECT * FROM eval_results_view WHERE model_id = ? ORDER BY category, percentile DESC`,
272
- [modelId]
273
- )
274
- return reader.getRowObjects().map(reshapeCellToModelResult)
275
- }
276
- ```
277
-
278
- ### Evals
279
-
280
- ```ts
281
- // getEvalListData / getEvalListLiteData — list pages
282
- export async function getEvalListData(): Promise<{
283
- evals: BenchmarkEvalListItem[]
284
- totalModels: number
285
- }> {
286
- const conn = await getConnection()
287
- const [evalsReader, modelsReader] = await Promise.all([
288
- conn.runAndReadAll(`
289
- SELECT evaluation_id, evaluation_name, canonical_display_name,
290
- composite_benchmark_key, composite_benchmark_name,
291
- benchmark_family_key, benchmark_leaf_key, category,
292
- metric_config, models_count, evaluator_names, source_types,
293
- latest_source_name, third_party_ratio,
294
- missing_generation_config_count, best_model, worst_model,
295
- avg_score, avg_score_norm, has_card,
296
- is_aggregated, aggregate_sources, tags,
297
- metrics_count, metric_names, instance_data, top_score,
298
- subtasks_count, is_summary_score, summary_eval_ids,
299
- root_metrics, subtasks, leaderboard_metrics,
300
- reproducibility_summary, provenance_summary, comparability_summary,
301
- source_data
302
- FROM evals_view
303
- ORDER BY evaluation_name ASC
304
- `),
305
- conn.runAndReadAll(`SELECT COUNT(*) AS n FROM models_view`),
306
- ])
307
- return {
308
- evals: evalsReader.getRowObjects() as BenchmarkEvalListItem[],
309
- totalModels: Number(modelsReader.getRowObjects()[0].n),
310
- }
311
- }
312
-
313
- // getEvalSummaryById — detail page.
314
- //
315
- // No more aggregate__/matrix__ id-prefix dispatch — `evals_view` is the
316
- // single source for all eval shapes. Suite-vs-leaf is a column
317
- // (`is_summary_score`, `is_aggregated`) on the same parquet.
318
- //
319
- // `model_results[]` rows go through the same reshape helper as
320
- // `getModelEvaluationCells` (defined below) — they share the
321
- // ModelResultForBenchmark target shape, so the eval/metric/cell
322
- // → BenchmarkEvaluation reshape is one helper, two callers.
323
- export async function getEvalSummaryById(evalId: string): Promise<BenchmarkEvalSummary | null> {
324
- const conn = await getConnection()
325
- const [evalReader, cellsReader] = await Promise.all([
326
- conn.runAndReadAll(
327
- `SELECT * FROM evals_view WHERE evaluation_id = ? LIMIT 1`,
328
- [evalId]
329
- ),
330
- conn.runAndReadAll(
331
- `SELECT * FROM eval_results_view
332
- WHERE evaluation_id = ?
333
- AND metric_id = (SELECT primary_metric_id FROM evals_view WHERE evaluation_id = ?)
334
- ORDER BY position ASC`,
335
- [evalId, evalId]
336
- ),
337
- ])
338
- const evalRows = evalReader.getRowObjects()
339
- if (evalRows.length === 0) return null
340
- return {
341
- ...(evalRows[0] as Omit<BenchmarkEvalSummary, "model_results">),
342
- model_results: cellsReader.getRowObjects().map(reshapeCellToModelResult),
343
- } as BenchmarkEvalSummary
344
- }
345
- ```
346
-
347
- ### Developers
348
-
349
- ```ts
350
- // getDeveloperList — list page; reads from headline.json (precomputed,
351
- // including producer-owned route_id, model/benchmark/evaluation counts,
352
- // and popular_evals). DeveloperListEntry is satisfied directly by the
353
- // headline entry shape.
354
- export async function getDeveloperList(): Promise<DeveloperListEntry[]> {
355
- const headline = await fetchHeadline()
356
- return headline.developers as DeveloperListEntry[]
357
- }
358
-
359
- // getDeveloperSummaryById — detail page; reads models_view filtered by developer.
360
- // The route_id on headline.developers[] is the canonical lookup key — we don't
361
- // re-derive `developer` from the URL slug, since percent-decoding may not
362
- // round-trip exactly to the producer's source string.
363
- export async function getDeveloperSummaryById(routeId: string) {
364
- const headline = await fetchHeadline()
365
- const headlineEntry = headline.developers.find((d) => d.route_id === routeId)
366
- if (!headlineEntry) return null
367
- const conn = await getConnection()
368
- const reader = await conn.runAndReadAll(
369
- `SELECT * FROM models_view WHERE developer = ?`,
370
- [headlineEntry.developer]
371
- )
372
- const models = reader.getRowObjects() as EvaluationCardData[]
373
- return { ...headlineEntry, models }
374
- }
375
- ```
376
-
377
- ### Dashboard convenience accessor
378
-
379
- ```ts
380
- // Was: { models, evals } over both legacy parquets; same shape, new sources.
381
- export async function getDashboardData() {
382
- const [models, evalListData] = await Promise.all([
383
- getModelCards(),
384
- getEvalListData(),
385
- ])
386
- return { models, evals: evalListData.evals }
387
- }
388
- ```
389
-
390
- ---
391
-
392
- ## Sidecar fetchers (replace `lib/hf-data.ts` corpus calls)
393
-
394
- Three small JSON files live in the snapshot dir alongside the
395
- parquets. New module `lib/sidecars.ts` exposes typed fetchers.
396
- `lib/hf-data.ts`'s `fetchCorpusAggregates`, `fetchEvalHierarchy`,
397
- `fetchBackendManifest`, and `fetchBackendManifestStatus` get their
398
- implementations replaced — same export names, new sources.
399
-
400
- ```ts
401
- // lib/sidecars.ts
402
- import "server-only"
403
- import type {
404
- CorpusAggregates,
405
- EvalHierarchy,
406
- BackendManifest,
407
- } from "@/lib/backend-artifacts"
408
-
409
- const SNAPSHOT_URL = process.env.SNAPSHOT_URL!
410
-
411
- let cache: {
412
- manifest?: Promise<BackendManifest>
413
- headline?: Promise<CorpusAggregates>
414
- hierarchy?: Promise<EvalHierarchy>
415
- } = {}
416
-
417
- async function fetchJson<T>(name: string): Promise<T> {
418
- const url = `${SNAPSHOT_URL}/${name}`
419
- const res = url.startsWith("file://")
420
- ? await import("fs/promises").then((fs) => fs.readFile(new URL(url), "utf8"))
421
- : await fetch(url, { next: { revalidate: 3600 } }).then((r) => r.text())
422
- return JSON.parse(typeof res === "string" ? res : res.toString()) as T
423
- }
424
-
425
- export function fetchManifest(): Promise<BackendManifest> {
426
- return (cache.manifest ??= fetchJson<BackendManifest>("manifest.json"))
427
- }
428
-
429
- export function fetchHeadline(): Promise<CorpusAggregates> {
430
- return (cache.headline ??= fetchJson<CorpusAggregates>("headline.json"))
431
- }
432
-
433
- export function fetchHierarchy(): Promise<EvalHierarchy> {
434
- return (cache.hierarchy ??= fetchJson<EvalHierarchy>("hierarchy.json"))
435
- }
436
- ```
437
-
438
- Then in `lib/hf-data.ts`:
439
-
440
- ```ts
441
- // fetchBackendManifest: was a fetchHFJsonSafe call; now reads the snapshot sidecar.
442
- export const fetchBackendManifest = fetchManifest
443
- export const fetchCorpusAggregates = fetchHeadline
444
- export const fetchEvalHierarchy = fetchHierarchy
445
-
446
- // fetchBackendManifestStatus: simplified — single snapshot pin, no "latest" comparison.
447
- export async function fetchBackendManifestStatus(): Promise<BackendManifestStatus> {
448
- const m = await fetchManifest()
449
- return {
450
- currentManifest: m,
451
- latestManifest: m, // no separate "latest" — snapshot is pinned
452
- currentManifestSignature: m.generated_at,
453
- latestManifestSignature: m.generated_at,
454
- updateAvailable: false,
455
- refreshing: false,
456
- pendingRefreshCount: 0,
457
- }
458
- }
459
- ```
460
-
461
- ---
462
-
463
- ## What deletes
464
-
465
- After v2 is live, the following code is dead and can be removed in a
466
- follow-up cleanup:
467
-
468
- - `lib/duckdb-data.ts` — replaced by typed SELECTs split between
469
- `lib/duckdb.ts` (connection) and `lib/data-backend.ts` (queries).
470
- - The `payload_json` parser helpers (`parsePayload`, `readPayloads`,
471
- `readPayloadById`, `assertDeveloperListShape`) — no JSON blobs to
472
- parse.
473
- - The `aggregate__` / `matrix__` eval-id prefix dispatch in
474
- `getEvalSummaryByIdFromDuckDB` — the typed view is the only path.
475
- - `lib/model-data.ts` — most of its functions exist to convert HF
476
- JSON records into `BenchmarkEvaluation` / `EvaluationCardData`. Once
477
- the producer emits those shapes directly, the adapter logic deletes.
478
- Keep only the helpers that don't touch HF records (slug parsing,
479
- display formatters).
480
- - `lib/eval-processing.ts` — the `groupEvaluationsByModel`,
481
- `createModelSummary`, `createBenchmarkEvalSummary`, and
482
- `inferCategoryFromBenchmark` adapter functions are no longer called
483
- in the data path. The exported types stay.
484
- - `scripts/audit-adapters.mjs`, `scripts/dump-adapter-outputs.mts`,
485
- `scripts/compare-data-backends.mjs`, `scripts/refresh-fixtures.mjs`,
486
- `scripts/cache-hf-data.mjs` — adapter / parity-check tooling for the
487
- legacy pipeline. Delete once v1 is retired.
488
- - `data/models/`, `data/developers/`, `data/benchmarks.json`,
489
- `data/models.json`, `data/developers.json` — bundled snapshots of
490
- v1 output for fixture tests. Replace with v2 fixtures if needed.
491
- - `LOCAL_PIPELINE_OUTPUT` env var, `duckdb/v1/` subpath conventions,
492
- and the parity-emitter expectations documented in
493
- `lib/duckdb-data.ts`'s preamble.
494
- - `inferCategoryFromBenchmark` regex chain in
495
- `lib/benchmark-schema.ts` — producer is the source of truth for
496
- category. Keep the `EVALUATION_CATEGORIES` const + `CategoryType`
497
- type; delete the inference function and `BENCHMARK_PRIORITY_RULES`.
498
-
499
- ---
500
-
501
- ## Slug rule
502
-
503
- Producer emits all URL-bearing identifiers in
504
- RFC 3986 percent-encoded form (`route_id`, `evaluation_id`,
505
- `metric_summary_id`). Frontend treats them as opaque except for
506
- `<Link>` href construction:
507
-
508
- ```tsx
509
- // Old: href={`/models/${model.route_id}`} // route_id was already escaped via __ rule
510
- // New: href={`/models/${model.route_id}`} // same code; route_id is now percent-encoded
511
- ```
512
-
513
- Decode happens inside the route handler when looking up by slug:
514
-
515
- ```ts
516
- // app/models/[id]/page.tsx
517
- export default async function ModelDetailPage({ params }: { params: { id: string } }) {
518
- const summary = await getModelSummaryById(params.id) // pass encoded form straight through
519
- ...
520
- }
521
- ```
522
-
523
- `getModelSummaryById` looks up by `route_id = ?` directly without
524
- decoding — the producer's `route_id` column matches the URL path
525
- segment byte-for-byte. The legacy `replace('/', '__')` and
526
- `replace(/\//g, ...)` helpers in `lib/utils.ts` and `lib/model-family.ts`
527
- become dead code; remove them in the cleanup pass.
528
-
529
- ---
530
-
531
- ## Migration strategy
532
-
533
- A feature flag gates v1 vs v2 during the transition:
534
-
535
- ```ts
536
- // lib/data-backend.ts
537
- const BACKEND_VERSION = process.env.DATA_BACKEND ?? "v1"
538
-
539
- export const getModelCards =
540
- BACKEND_VERSION === "v2"
541
- ? (await import("@/lib/duckdb")).getModelCards
542
- : (await import("@/lib/duckdb-data")).getModelCardsFromDuckDB
543
- // ... same pattern for other accessors
544
- ```
545
-
546
- Phase plan:
547
-
548
- 1. **Producer ships Stage J.** `eval_card_backend` emits the six
549
- v2 artifacts in `warehouse/<snapshot_id>/`. Existing canonical
550
- parquets stay alongside.
551
- 2. **Frontend lands `lib/duckdb.ts` + `lib/sidecars.ts`** behind the
552
- `DATA_BACKEND=v2` flag. CI builds both backends; default stays v1.
553
- 3. **Smoke test in dev with `DATA_BACKEND=v2`,
554
- `SNAPSHOT_URL=file://...`.** Verify each page renders identical
555
- bytes (modulo source-of-data labels). Where they diverge, file
556
- producer issues — do not patch the frontend to paper over.
557
- 4. **Flip the production default to v2.** Keep v1 path compilable but
558
- unreachable. Monitor for a release.
559
- 5. **Delete v1 path** (the "What deletes" list above).
560
-
561
- The flag is intentionally process-wide, not per-accessor. Mixing
562
- backends within one render produces inconsistent snapshots.
563
-
564
- ---
565
-
566
- ## What doesn't move
567
-
568
- - **Instance-level data fetching** (`fetchInstanceLevelData` in
569
- `lib/hf-data.ts`). Instance JSONL is referenced by URL in
570
- `eval_results_view.instance_file_path`; the lazy-load stays. Pointer
571
- shape on the row is unchanged from v1.
572
- - **Benchmark card metadata** lives inside `evals_view.benchmark_card`
573
- STRUCT now, not a separate `benchmark_card_*.json` per file. The
574
- page reads it from the eval row directly. Adapter-style readers
575
- (`fetchBenchmarkMetadataMap`) become a `SELECT benchmark_id, benchmark_card
576
- FROM evals_view` aggregation if anything still calls them — most
577
- callers should fold into `getEvalSummaryById`.
578
- - **EvalCards annotations** (`evalcards.annotations`) live on
579
- `eval_results_view.evalcards_annotations` per-row. The eval-detail
580
- page reads them inline; no separate fetcher.
581
-
582
- ---
583
-
584
- ## Open questions / risks
585
-
586
- - **httpfs cold-start latency.** First query against an HF-hosted
587
- parquet pays a round trip per file. Mitigate by pre-registering all
588
- three views at process start (above), so the first user query hits
589
- warm metadata. Measure on the production HF Space; if too slow,
590
- consider downloading the snapshot to local disk at container start
591
- (~MB per snapshot).
592
- - **Connection lifetime in serverless.** Vercel's serverless
593
- runtime tears down the Node process per request; the
594
- `connectionPromise` cache doesn't help. The HF Space deployment
595
- (Docker, long-lived) is unaffected. If we ever target serverless,
596
- switch to `duckdb-wasm` in the browser or a separate serving
597
- process.
598
- - **`aggregate_components[]` on `eval_results_view`.** This array is
599
- the per-suite-component breakdown for rollup rows. For non-rollup
600
- rows it's always empty. If suite rollups grow common, the storage
601
- cost of trailing-empty arrays is non-trivial; consider splitting
602
- into a dedicated parquet at that point.
603
- - **Category drift.** Producer's `category_mapping.json` will lag real
604
- benchmark tag changes. The mapping is producer-owned, so the
605
- frontend can't patch around drift — this is a feature, not a bug,
606
- but it requires operator discipline. Surface "uncategorised
607
- benchmark count" in the producer's run summary and the home-page
608
- manifest banner.
609
- - **Type widening for `score_summary` etc.** The producer emits these
610
- as DuckDB STRUCTs; the TS interface declares them as nested
611
- `{ count, min, max, average }`. `runAndReadAll` returns nested
612
- STRUCTs as plain JS objects, so the cast works — but if duckdb-node
613
- changes its STRUCT serialisation, audit the `as` casts here. Add a
614
- dev-only validator that runs `EvaluationCardData`'s shape check at
615
- the row level on the first `getModelCards()` call after process
616
- start.
617
-
618
- ## model-resolution-rework — view-layer contract additions
619
-
620
- This is the producer↔frontend column contract for the model-resolution
621
- rework (proposal §10.5, contract surface 3). The frontend interface field
622
- names below MUST match the producer `models_view` / `eval_results_view`
623
- column names exactly. All fields are **additive and nullable** — the
624
- frontend conditionally renders them and falls back gracefully when null,
625
- so producer and frontend can roll out independently.
626
-
627
- ### New columns the frontend reads
628
-
629
- | view column | frontend field | interface | meaning |
630
- | --- | --- | --- | --- |
631
- | `inference_platform` | `inference_platform` | `ModelInfo`, `EvaluationCardData` | FK to `inference_platforms.id`; "served by" provenance. Already present pre-rework; now populated by the resolver output. |
632
- | `model_family_id` | `model_family_id` | `EvaluationCardData`, `ModelEvaluationSummary`, `ModelResultForBenchmark`, `BenchmarkLeaderboardRow` | family canonical id (grouping root). Replaces the deleted client-side family computation (`lib/model-family.ts`). Routing fallback when `model_route_id` is absent. |
633
- | `lineage_origin_model_id` | `lineage_origin_model_id` | `EvaluationCardData`, `ModelSummaryCore` | deepest non-variant ancestor (base model for finetunes/merges/quants). Surfaced as "Base model" on the model detail page. |
634
- | `resolution_source` | `resolution_source` | `EvaluationCardData`, `ModelSummaryCore` | enum `hf \| models_dev \| curated \| inferred \| none`. Surfaced as "Resolved via". |
635
- | `resolution_granularity` | `resolution_granularity` | `EvaluationCardData`, `ModelSummaryCore` | enum `variant \| group \| family`. Surfaced as "Granularity". |
636
-
637
- These read off `SELECT *`-backed accessors (`getModelSummaryById`) and the
638
- per-cell reshape, so they flow through automatically once the producer view
639
- emits them. The explicit `MODEL_CARD_COLUMNS` projection in
640
- `lib/view-data.ts` is intentionally NOT extended with the three pure-display
641
- fields until the producer view layer ships them (an explicit SELECT of a
642
- non-existent column errors). Add them to that projection in lockstep with the
643
- producer change.
644
-
645
- ### Renames the frontend does NOT read (cosmetic)
646
-
647
- `root_model_id → model_group_id` and `lineage_origin_org_id →
648
- lineage_origin_model_org_id` are renamed in the registry/producer data layer.
649
- The frontend view-layer interfaces never read either name (verified by grep;
650
- the only `root_model_id` reference is in the redirect *generator*, which reads
651
- the registry `baseline_resolution.json`, not the view layer). No frontend
652
- change is required for these renames.
653
-
654
- ### `canonical_id` flip + URL redirects (breaking, data)
655
-
656
- Post-flip, `canonical_id` is the **leaf**; ~297 model URLs change (204 flips +
657
- 93 casing re-keys). Old bookmarked URLs are 301-redirected by `middleware.ts`
658
- using the generated `lib/model-url-redirects.ts` map. The map is regenerated
659
- from the registry `baseline_resolution.json` via
660
- `scripts/generate-model-redirects.ts`. **The committed map is PROVISIONAL** —
661
- built from the pre-final spec-dir baseline — and MUST be regenerated from the
662
- post-M9 baseline at integration. The redirect preserves query params and the
663
- ids round-trip through percent-encoding (RFC 3986, `/ → %2F`), matching the
664
- producer `route_id` form.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
notes/merge-cheatsheet-backend-v2.md DELETED
@@ -1,193 +0,0 @@
1
- # Merge cheatsheet: pulling `main` into `feat/use-new-backend-data`
2
-
3
- > Drafted 2026-05-04, before pulling. Companion to `backend-v2-migration.md`
4
- > (which is the design doc). This file is just a per-file conflict guide.
5
- >
6
- > Branch: `feat/use-new-backend-data` (2 commits ahead of `main`:
7
- > `7635aee` Integrate with test backend data, `bfce8f2` Drop
8
- > input/output_modalities from MODEL_CARD_COLUMNS).
9
-
10
- ## Triage at a glance
11
-
12
- | File | Risk | Strategy |
13
- |---|---|---|
14
- | `lib/data-backend.ts` | **High** | Keep ours wholesale; re-port any new accessors main added |
15
- | `lib/backend-artifacts.ts` | **High** | Keep our schema renames; reconcile any *new* main-side fields against producer output |
16
- | `components/signals/corpus-dashboard.tsx` | **Med** | Keep main's UI structure; rewire data fields to v2 names |
17
- | `components/signals/corpus-signals-strip.tsx` | **Med** | Same as above |
18
- | `lib/hf-data.ts` | **Med** | Keep `useViewLayerBackend()` short-circuits at top of 5 fetchers |
19
- | `Dockerfile` | **Med** | Keep our `DATA_BACKEND=v2` + `SNAPSHOT_URL` wiring; layer main's other changes on top |
20
- | `lib/benchmark-schema.ts` | **Low** | Trivial 1-line addition (`num_few_shot?`) |
21
- | `app/page.tsx` | **Low** | One-line copy change (`corpus-aggregates.json` → `headline.json`) |
22
-
23
- New files (no conflict possible): `lib/view-data.ts`, `lib/duckdb.ts`,
24
- `lib/sidecars.ts`, `tests/view-data.test.ts`,
25
- `notes/backend-v2-migration.md`.
26
-
27
- ---
28
-
29
- ## `lib/data-backend.ts` — High
30
-
31
- **What we did:** Replaced static re-exports from `lib/duckdb-data` with
32
- a `BACKEND_VERSION` env-flag dispatcher. Each accessor now branches on
33
- `useViewLayerBackend()` (true when `DATA_BACKEND=v2` or `stage-j`) and
34
- lazy-imports either `@/lib/view-data` or `@/lib/duckdb-data`.
35
- Manifest/hierarchy accessors branch between `@/lib/sidecars` and
36
- `@/lib/hf-data`.
37
-
38
- **Reconcile:**
39
- - Conflict almost certain if main touched any export wiring here.
40
- - **Keep our file as-is.** The dispatcher pattern is load-bearing.
41
- - If main added a new accessor (e.g. `getFooBar`), add a new dispatcher
42
- function following the same pattern — only the legacy branch needs
43
- to be wired immediately; v2 branch can throw `Not implemented` until
44
- `lib/view-data.ts` adds it.
45
-
46
- ---
47
-
48
- ## `lib/backend-artifacts.ts` — High
49
-
50
- **What we did:** Renamed corpus-block fields to match what the v2
51
- producer emits:
52
-
53
- | Block | v1 (main) | v2 (ours) |
54
- |---|---|---|
55
- | Completeness | `total_benchmarks`, `completeness_score_mean`, `completeness_score_median`, `per_field_population{}` | `total_triples`, `completeness_avg`, `completeness_min`, `completeness_max` |
56
- | Provenance | `multi_source_groups`, `multi_source_rate`, `first_party_only_groups`, `first_party_only_rate`, `total_groups` | `multi_source_triples`, `first_party_only_triples`, `total_triples` (rates dropped — derived in components via local `rate()` helper) |
57
- | Comparability | `variant_eligible_groups`, `variant_divergent_groups`, `variant_divergence_rate`, `cross_party_eligible_groups`, `cross_party_divergent_groups`, `cross_party_divergence_rate`, `total_groups` | `total_triples`, `variant_divergent_count`, `cross_party_divergent_count`, `groups_with_variant_check`, `groups_with_cross_party_check` |
58
-
59
- Also added: `DeveloperListEntry` interface, optional
60
- `developers/families/categories` arrays on `CorpusAggregates`,
61
- optional `eval_hierarchy` key in `BackendManifest.summary_artifacts`.
62
-
63
- **Reconcile:**
64
- - Producer is the source of truth for v2 field names — do **not** add
65
- back v1 names to satisfy a main-side change. If main added a field
66
- the v2 producer doesn't emit, either drop it or check
67
- `eval_card_backend/notes/08-frontend-view-layer.md` first.
68
- - Keep all three new optional sections on `CorpusAggregates`
69
- (developers, families, categories) — they back the new
70
- developer-list path.
71
- - The `summary_artifacts.eval_hierarchy` key is additive; safe to keep
72
- alongside whatever main added there.
73
-
74
- ---
75
-
76
- ## `components/signals/corpus-dashboard.tsx` — Medium
77
-
78
- **What we did:** Mechanical rewrite of every field reference in this
79
- file to use the v2 names from `lib/backend-artifacts.ts` (above).
80
- Removed the `per_field_population` per-field grid and replaced it with
81
- a `min / avg / max` MiniMetric trio. Added a local `rate(num, denom)`
82
- helper (returns null if either side is null/zero) since v2 stores
83
- counts, not pre-computed rates. Title-cased `CATEGORY_ORDER`
84
- (`"Agentic"`, `"General"`, …) and made the keys-to-render set extend
85
- gracefully to unknown categories.
86
-
87
- **Reconcile:**
88
- - If main touched this file for design/UX reasons, **prefer main's
89
- visual structure** — but keep our field accessors. The recipe is:
90
- - Anywhere main reads `multi_source_rate`, replace with `rate(prov.multi_source_triples, prov.total_triples)`.
91
- - Anywhere main reads `completeness_score_mean`, replace with `comp.completeness_avg`.
92
- - Anywhere main reads `*_eligible_groups` / `*_divergent_groups`, swap to `groups_with_*_check` / `*_divergent_count`.
93
- - Drop any new code that reads `per_field_population` — gone in v2.
94
- - Keep the local `rate()` helper at the bottom of the file.
95
- - Category lookup must use the new title-cased keys (or stay tolerant
96
- via the `available` set logic we added).
97
-
98
- ---
99
-
100
- ## `components/signals/corpus-signals-strip.tsx` — Medium
101
-
102
- **What we did:** Same field renames as above, same local `rate()`
103
- helper added. Headline copy updated from "groups" → "triples" where
104
- the underlying unit changed.
105
-
106
- **Reconcile:** Apply the same recipe as `corpus-dashboard.tsx`. The
107
- two files share field names and the `rate()` helper.
108
-
109
- ---
110
-
111
- ## `lib/hf-data.ts` — Medium
112
-
113
- **What we did:** Added an early-return guard at the top of five
114
- functions:
115
- - `fetchBackendManifestStatus` — synthesizes a status from the v2 manifest sidecar
116
- - `fetchBenchmarkMetadataMap` — delegates to `view-data.getBenchmarkMetadataMap`
117
- - `fetchBackendManifest` — delegates to `sidecars.fetchManifest`
118
- - `fetchEvalHierarchy` — delegates to `sidecars.fetchHierarchy` (still wraps in `adaptEvalHierarchy`)
119
- - `fetchCorpusAggregates` — delegates to `sidecars.fetchHeadline`
120
-
121
- Plus a module-level `useViewLayerBackend()` helper and a lazy
122
- `fetchSnapshotSidecars()` importer near the top of the file.
123
-
124
- **Reconcile:**
125
- - These are all additive guards at the start of existing functions —
126
- conflicts are likely only if main re-shaped the same function
127
- bodies.
128
- - Pattern: `if (useViewLayerBackend()) { return <v2 path> }` then fall
129
- through to the existing v1 implementation untouched.
130
- - If main renamed one of these functions, port the guard into the
131
- renamed version. Don't drop the guard.
132
-
133
- ---
134
-
135
- ## `Dockerfile` — Medium
136
-
137
- **What we did:**
138
- - Default `ARG DATA_BACKEND` flipped from `duckdb` → `v2` in **both**
139
- stages (builder and runner).
140
- - Added `ARG SNAPSHOT_URL` + `ENV SNAPSHOT_URL` in both stages,
141
- defaulting to a pinned `evaleval/eval-cards-data` warehouse path.
142
- - Comment block rewritten to reflect v2 + legacy coexistence.
143
- - Kept legacy `LOCAL_PIPELINE_OUTPUT`, `HF_DATA_LOCAL_DIR`,
144
- `HF_DATA_OFFLINE=1` envs intact (legacy backend still compilable).
145
-
146
- **Uncommitted tweak (working tree):** `SNAPSHOT_URL` default points at
147
- `j-chim/temp_evalcard_backend` instead of `evaleval/eval-cards-data` —
148
- this is the dev/test dataset for the temp HF Space deploy. Do **not**
149
- commit this override; revert before merging to main, or keep it only
150
- on local working copy.
151
-
152
- **Reconcile:**
153
- - Keep our `DATA_BACKEND=v2` default and `SNAPSHOT_URL` plumbing.
154
- - Layer main's non-data changes (base image bumps, `pnpm` version,
155
- build commands) on top.
156
-
157
- ---
158
-
159
- ## `lib/benchmark-schema.ts` — Low
160
-
161
- **What we did:** Added one optional field, `num_few_shot?: number`, on
162
- `GenerationConfig`. That's it.
163
-
164
- **Reconcile:** Trivially additive. Keep our line; merge tool should
165
- handle it cleanly unless main touched the same struct.
166
-
167
- ---
168
-
169
- ## `app/page.tsx` — Low
170
-
171
- **What we did:** One-line copy change in the empty-state banner —
172
- `corpus-aggregates.json` → `headline.json` (the v2 sidecar name).
173
-
174
- **Reconcile:** Trivial. Keep ours.
175
-
176
- ---
177
-
178
- ## Order of operations after `git pull`
179
-
180
- 1. Resolve `lib/backend-artifacts.ts` first — it's the schema source
181
- of truth that the components depend on.
182
- 2. Resolve `lib/data-backend.ts` and `lib/hf-data.ts` — backend wiring.
183
- 3. Resolve the two `components/signals/*` files using the rename recipe.
184
- 4. Resolve `Dockerfile` — keep our v2 envs.
185
- 5. `app/page.tsx` and `lib/benchmark-schema.ts` — should auto-merge or
186
- be trivial.
187
- 6. Run `pnpm tsc --noEmit` (or whatever the project's typecheck is) to
188
- catch any v1 field references main introduced that didn't conflict
189
- textually but break against our renamed types.
190
- 7. Run `pnpm test` — `tests/view-data.test.ts` and
191
- `tests/duckdb-data.test.ts` should both still pass.
192
- 8. Smoke test with `DATA_BACKEND=v2 SNAPSHOT_URL=file://…` and again
193
- without (legacy path) — both must render.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
notes/migration-plan.md DELETED
@@ -1,117 +0,0 @@
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 DELETED
@@ -1,353 +0,0 @@
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 DELETED
@@ -1,244 +0,0 @@
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 DELETED
@@ -1,155 +0,0 @@
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 DELETED
@@ -1,147 +0,0 @@
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 DELETED
@@ -1,173 +0,0 @@
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 DELETED
@@ -1,206 +0,0 @@
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 DELETED
@@ -1,159 +0,0 @@
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 DELETED
@@ -1,165 +0,0 @@
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 DELETED
@@ -1,329 +0,0 @@
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 DELETED
@@ -1,282 +0,0 @@
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 DELETED
@@ -1,376 +0,0 @@
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 DELETED
@@ -1,235 +0,0 @@
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 DELETED
@@ -1,258 +0,0 @@
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 DELETED
@@ -1,89 +0,0 @@
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 DELETED
@@ -1,247 +0,0 @@
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 DELETED
@@ -1,321 +0,0 @@
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 DELETED
@@ -1,230 +0,0 @@
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 DELETED
@@ -1,238 +0,0 @@
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 DELETED
@@ -1,194 +0,0 @@
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 DELETED
@@ -1,192 +0,0 @@
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 DELETED
@@ -1,285 +0,0 @@
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.