j-chim commited on
Commit
0e529dc
Β·
1 Parent(s): 25ba6d0

Use model_key as the addressable identifier and wire comparison-index sidecar

Browse files

model_key is non-null for both resolved and unresolved models (the latter
fall back to the raw source name); querying by model_id alone silently
dropped unresolved models. Update model lookups, eval-row joins, and
ModelInfo derivation to prefer model_key with model_id as fallback.

Also point the Dockerfile snapshot URL at the temp dataset
(j-chim/temp_evalcard_backend) and add fetchComparisonIndex sidecar
support so v2 mode no longer hits the legacy JSON path.

Dockerfile CHANGED
@@ -14,7 +14,7 @@ ARG PNPM_VERSION=10.25.0
14
  # Override at build time via `--build-arg ...`.
15
  ARG DATA_BACKEND=v2
16
  ARG HF_DATASET_REPO=https://huggingface.co/datasets/evaleval/card_backend
17
- ARG SNAPSHOT_URL=https://huggingface.co/datasets/evaleval/eval-cards-data/resolve/main/warehouse/2026-05-03T21-46-50Z
18
  # Static prerender (`next build`) executes route handlers. In legacy mode the
19
  # cache populated by `cache-hf-data.mjs` lives at `/app/.cache/hf-data`; in v2
20
  # the cache step is skipped and the app reads the pinned Stage J snapshot.
@@ -48,7 +48,7 @@ FROM node:18-bullseye-slim AS runner
48
  WORKDIR /app
49
 
50
  ARG DATA_BACKEND=v2
51
- ARG SNAPSHOT_URL=https://huggingface.co/datasets/evaleval/eval-cards-data/resolve/main/warehouse/2026-05-03T21-46-50Z
52
 
53
  # Runtime needs the same data-source envs that the builder used. Docker
54
  # multi-stage doesn't carry ENVs across stages, so keep backend selection and
 
14
  # Override at build time via `--build-arg ...`.
15
  ARG DATA_BACKEND=v2
16
  ARG HF_DATASET_REPO=https://huggingface.co/datasets/evaleval/card_backend
17
+ ARG SNAPSHOT_URL=https://huggingface.co/datasets/j-chim/temp_evalcard_backend/resolve/main/warehouse/2026-05-03T21-46-50Z
18
  # Static prerender (`next build`) executes route handlers. In legacy mode the
19
  # cache populated by `cache-hf-data.mjs` lives at `/app/.cache/hf-data`; in v2
20
  # the cache step is skipped and the app reads the pinned Stage J snapshot.
 
48
  WORKDIR /app
49
 
50
  ARG DATA_BACKEND=v2
51
+ ARG SNAPSHOT_URL=https://huggingface.co/datasets/j-chim/temp_evalcard_backend/resolve/main/warehouse/2026-05-03T21-46-50Z
52
 
53
  # Runtime needs the same data-source envs that the builder used. Docker
54
  # multi-stage doesn't carry ENVs across stages, so keep backend selection and
lib/hf-data.ts CHANGED
@@ -1005,6 +1005,10 @@ function adaptEvalHierarchy(raw: EvalHierarchy): EvalHierarchy {
1005
  }
1006
 
1007
  export async function fetchComparisonIndex(): Promise<ComparisonIndex> {
 
 
 
 
1008
  return fetchHFJson<ComparisonIndex>("comparison-index.json")
1009
  }
1010
 
 
1005
  }
1006
 
1007
  export async function fetchComparisonIndex(): Promise<ComparisonIndex> {
1008
+ if (useViewLayerBackend()) {
1009
+ return (await fetchSnapshotSidecars()).fetchComparisonIndex()
1010
+ }
1011
+
1012
  return fetchHFJson<ComparisonIndex>("comparison-index.json")
1013
  }
1014
 
lib/sidecars.ts CHANGED
@@ -2,6 +2,7 @@ import "server-only"
2
 
3
  import type {
4
  BackendManifest,
 
5
  CorpusAggregates,
6
  EvalHierarchy,
7
  } from "@/lib/backend-artifacts"
@@ -10,6 +11,7 @@ let cache: {
10
  manifest?: Promise<BackendManifest>
11
  headline?: Promise<CorpusAggregates>
12
  hierarchy?: Promise<EvalHierarchy>
 
13
  } = {}
14
 
15
  function getSnapshotUrl() {
@@ -54,6 +56,10 @@ export function fetchHierarchy(): Promise<EvalHierarchy> {
54
  return (cache.hierarchy ??= fetchJson<EvalHierarchy>("hierarchy.json"))
55
  }
56
 
 
 
 
 
57
  export function resetSidecarCacheForTests() {
58
  cache = {}
59
  }
 
2
 
3
  import type {
4
  BackendManifest,
5
+ ComparisonIndex,
6
  CorpusAggregates,
7
  EvalHierarchy,
8
  } from "@/lib/backend-artifacts"
 
11
  manifest?: Promise<BackendManifest>
12
  headline?: Promise<CorpusAggregates>
13
  hierarchy?: Promise<EvalHierarchy>
14
+ comparisonIndex?: Promise<ComparisonIndex>
15
  } = {}
16
 
17
  function getSnapshotUrl() {
 
56
  return (cache.hierarchy ??= fetchJson<EvalHierarchy>("hierarchy.json"))
57
  }
58
 
59
+ export function fetchComparisonIndex(): Promise<ComparisonIndex> {
60
+ return (cache.comparisonIndex ??= fetchJson<ComparisonIndex>("comparison-index.json"))
61
+ }
62
+
63
  export function resetSidecarCacheForTests() {
64
  cache = {}
65
  }
lib/view-data.ts CHANGED
@@ -28,7 +28,7 @@ import type {
28
  type Row = Record<string, any>
29
 
30
  const MODEL_CARD_COLUMNS = `
31
- id, route_id, model_name, model_id, canonical_model_name, developer,
32
  evaluations_count, benchmarks_count, variant_count,
33
  categories, category_stats, latest_timestamp,
34
  evaluator_count, evaluator_names, source_type_count, source_types,
@@ -250,8 +250,8 @@ function metricConfigFromRow(row: Row): MetricConfig {
250
 
251
  function modelInfoFromModelRow(row: Row): ModelInfo {
252
  return {
253
- name: asString(row.model_name ?? row.model_family_name ?? row.model_id, "Unknown model"),
254
- id: asString(row.model_id ?? row.id ?? row.route_id, "unknown-model"),
255
  developer: optionalString(row.developer),
256
  inference_platform: optionalString(row.inference_platform),
257
  inference_engine: optionalString(row.inference_engine),
@@ -390,23 +390,26 @@ function modelSummaryFromRows(modelRow: Row, cellRows: Row[]): ModelEvaluationSu
390
 
391
  return {
392
  ...core,
393
- model_family_id: asString(modelRow.model_family_id ?? modelRow.model_id, modelRow.model_id),
394
  model_route_id: asString(modelRow.model_route_id ?? modelRow.route_id, modelRow.route_id),
395
  model_family_name: asString(modelRow.model_family_name ?? modelRow.model_name, modelRow.model_name),
396
- raw_model_ids: rawModelIds.length > 0 ? rawModelIds : [asString(modelRow.model_id, "")].filter(Boolean),
397
  variants,
398
  }
399
  }
400
 
401
- async function getModelEvaluationRows(modelId: string): Promise<Row[]> {
 
 
 
402
  return readRows<Row>(
403
  `SELECT ${CELL_JOIN_COLUMNS}
404
  FROM eval_results_view r
405
  LEFT JOIN evals_view e ON r.evaluation_id = e.evaluation_id
406
- WHERE r.model_id = ?
407
  AND r.score IS NOT NULL
408
  ORDER BY r.category, r.percentile DESC NULLS LAST`,
409
- [modelId]
410
  )
411
  }
412
 
@@ -466,17 +469,21 @@ export async function getDashboardData() {
466
  }
467
 
468
  export async function getModelSummaryById(routeId: string): Promise<ModelEvaluationSummary | null> {
 
 
 
 
469
  const rows = await readRows<Row>(
470
  `SELECT *
471
  FROM models_view
472
- WHERE route_id = ? OR model_route_id = ? OR model_family_id = ? OR model_id = ?
473
  LIMIT 1`,
474
- [routeId, routeId, routeId, routeId]
475
  )
476
  const modelRow = rows[0]
477
  if (!modelRow) return null
478
 
479
- const cellRows = await getModelEvaluationRows(asString(modelRow.model_id, routeId))
480
  return modelSummaryFromRows(modelRow, cellRows)
481
  }
482
 
 
28
  type Row = Record<string, any>
29
 
30
  const MODEL_CARD_COLUMNS = `
31
+ id, model_key, route_id, model_name, model_id, canonical_model_name, developer,
32
  evaluations_count, benchmarks_count, variant_count,
33
  categories, category_stats, latest_timestamp,
34
  evaluator_count, evaluator_names, source_type_count, source_types,
 
250
 
251
  function modelInfoFromModelRow(row: Row): ModelInfo {
252
  return {
253
+ name: asString(row.model_name ?? row.model_family_name ?? row.model_id ?? row.model_key, "Unknown model"),
254
+ id: asString(row.model_key ?? row.model_id ?? row.id ?? row.route_id, "unknown-model"),
255
  developer: optionalString(row.developer),
256
  inference_platform: optionalString(row.inference_platform),
257
  inference_engine: optionalString(row.inference_engine),
 
390
 
391
  return {
392
  ...core,
393
+ model_family_id: asString(modelRow.model_family_id ?? modelRow.model_key ?? modelRow.model_id, modelRow.model_key ?? modelRow.model_id),
394
  model_route_id: asString(modelRow.model_route_id ?? modelRow.route_id, modelRow.route_id),
395
  model_family_name: asString(modelRow.model_family_name ?? modelRow.model_name, modelRow.model_name),
396
+ raw_model_ids: rawModelIds.length > 0 ? rawModelIds : [asString(modelRow.model_key ?? modelRow.model_id, "")].filter(Boolean),
397
  variants,
398
  }
399
  }
400
 
401
+ async function getModelEvaluationRows(modelKey: string): Promise<Row[]> {
402
+ // model_key is the producer's addressable identifier β€” non-null for both
403
+ // resolved and unresolved models (the latter fall back to the raw source
404
+ // name). Querying by model_id alone would silently miss unresolved models.
405
  return readRows<Row>(
406
  `SELECT ${CELL_JOIN_COLUMNS}
407
  FROM eval_results_view r
408
  LEFT JOIN evals_view e ON r.evaluation_id = e.evaluation_id
409
+ WHERE r.model_key = ?
410
  AND r.score IS NOT NULL
411
  ORDER BY r.category, r.percentile DESC NULLS LAST`,
412
+ [modelKey]
413
  )
414
  }
415
 
 
469
  }
470
 
471
  export async function getModelSummaryById(routeId: string): Promise<ModelEvaluationSummary | null> {
472
+ // Lookups use the addressable identifier (`model_key`/`route_id`/
473
+ // `model_route_id`/`model_family_id`) so unresolved models β€” whose
474
+ // `model_id` is NULL β€” are still findable. `model_id` is kept in the
475
+ // OR chain as a back-compat fallback for old links.
476
  const rows = await readRows<Row>(
477
  `SELECT *
478
  FROM models_view
479
+ WHERE model_key = ? OR route_id = ? OR model_route_id = ? OR model_family_id = ? OR model_id = ?
480
  LIMIT 1`,
481
+ [routeId, routeId, routeId, routeId, routeId]
482
  )
483
  const modelRow = rows[0]
484
  if (!modelRow) return null
485
 
486
+ const cellRows = await getModelEvaluationRows(asString(modelRow.model_key ?? modelRow.model_id, routeId))
487
  return modelSummaryFromRows(modelRow, cellRows)
488
  }
489
 
notes/backend-v2-migration.md ADDED
@@ -0,0 +1,616 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
notes/merge-cheatsheet-backend-v2.md ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
tests/view-data.test.ts CHANGED
@@ -22,6 +22,7 @@ async function writeSyntheticStageJSnapshot(snapshotDir: string) {
22
  `
23
  SELECT
24
  TIMESTAMP '2026-05-03 00:00:00' AS snapshot_id,
 
25
  'openai/gpt-5' AS model_id,
26
  'openai/gpt-5' AS id,
27
  'openai%2Fgpt-5' AS route_id,
@@ -205,6 +206,7 @@ async function writeSyntheticStageJSnapshot(snapshotDir: string) {
205
  'mmlu%3Aaccuracy' AS metric_summary_id,
206
  'mmlu' AS benchmark_id,
207
  'accuracy' AS metric_id,
 
208
  'openai/gpt-5' AS model_id,
209
  'openai%2Fgpt-5' AS model_route_id,
210
  struct_pack(
 
22
  `
23
  SELECT
24
  TIMESTAMP '2026-05-03 00:00:00' AS snapshot_id,
25
+ 'openai/gpt-5' AS model_key,
26
  'openai/gpt-5' AS model_id,
27
  'openai/gpt-5' AS id,
28
  'openai%2Fgpt-5' AS route_id,
 
206
  'mmlu%3Aaccuracy' AS metric_summary_id,
207
  'mmlu' AS benchmark_id,
208
  'accuracy' AS metric_id,
209
+ 'openai/gpt-5' AS model_key,
210
  'openai/gpt-5' AS model_id,
211
  'openai%2Fgpt-5' AS model_route_id,
212
  struct_pack(